From 9c8f21b75470868d95729fff5f65ce469f9fee29 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 20 Sep 2026 20:29:21 +0200 Subject: [PATCH] Split collections_ui.rs into a module directory by area of behaviour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collections_ui.rs had grown to 4,591 lines covering the sidebar controller, the click/drag selection policy, tree refresh, the drag gesture, the trash worker, twelve wiring functions, and the row's rename/create/context menu, all in one file. Split into collections_ui/ with one module per area, the way develop/ and library/ were already split on this branch: - controller.rs: CollectionsController and the pure drop/delete/release decisions (decide_drop, decide_delete, decide_release, menu_detail, delete_warning) that a test can drive without a window. - press.rs: PressUndo and the click-and-release selection policy (apply_press, select_row, commit_press, cancel_press). - tree_sync.rs: rebuilding the sidebar from the catalog and pushing catalog-derived state into the grid (refresh_tree, offline_state, sync_lifted/sync_selection/sync_reorderable/sync_badges, refresh_membership, direct_holdings). - drag.rs: the cursor bitmap (compose_drag_image, blit_scaled) and the hold/spring timers (arm_hold, arm_spring, should_spring, collapse_spring_opened) plus their delay constants. - trash.rs: the soft delete (start_trash, start_restore, drain_trash, stop_trash, refresh_trash, format_bytes). - wiring_grid.rs / wiring_tree.rs: the wire() entry point and its twelve wire_* functions, split in two because together they were the largest single piece (grid-facing selection/drag/trash vs. sidebar-facing navigation/create/rename/row-drag/menu/membership). - rename_menu.rs: creating, naming and renaming collections, and the row's context menu (apply_rename, create_child, unique_name, open_row_menu, close_row_menu, close_rename). mod.rs carries the module's own top-level doc comment, the `pub use` re-exports for the eight items the rest of the crate reaches by `collections_ui::` path (CollectionsController, wire, refresh_tree, sync_badges, sync_selection, select_row, commit_press, cancel_press), and a shared `test_support` for the one fixture (`ids`) more than one file's tests needed. Every item that only crossed a boundary within this module, not out of it, was narrowed to `pub(super)` rather than kept at the crate-wide `pub` a single file gave it for free. Extracted with a brace-aware pass that kept each item's own leading doc comment and attributes attached to it, and tests moved with the code they exercise; every TRACES/GESTURE comment lands on the same code it did before. No file outside the new directory changed — lib.rs's `mod collections_ui;` resolves to the directory automatically, and every outside caller's `collections_ui::` path still resolves through mod.rs's re-exports. --- docs/dev/traceability.md | 22 +- ui/dr-ui/src/collections_ui.rs | 4591 -------------------- ui/dr-ui/src/collections_ui/controller.rs | 567 +++ ui/dr-ui/src/collections_ui/drag.rs | 495 +++ ui/dr-ui/src/collections_ui/mod.rs | 74 + ui/dr-ui/src/collections_ui/press.rs | 763 ++++ ui/dr-ui/src/collections_ui/rename_menu.rs | 461 ++ ui/dr-ui/src/collections_ui/trash.rs | 296 ++ ui/dr-ui/src/collections_ui/tree_sync.rs | 501 +++ ui/dr-ui/src/collections_ui/wiring_grid.rs | 943 ++++ ui/dr-ui/src/collections_ui/wiring_tree.rs | 698 +++ 11 files changed, 4809 insertions(+), 4602 deletions(-) delete mode 100644 ui/dr-ui/src/collections_ui.rs create mode 100644 ui/dr-ui/src/collections_ui/controller.rs create mode 100644 ui/dr-ui/src/collections_ui/drag.rs create mode 100644 ui/dr-ui/src/collections_ui/mod.rs create mode 100644 ui/dr-ui/src/collections_ui/press.rs create mode 100644 ui/dr-ui/src/collections_ui/rename_menu.rs create mode 100644 ui/dr-ui/src/collections_ui/trash.rs create mode 100644 ui/dr-ui/src/collections_ui/tree_sync.rs create mode 100644 ui/dr-ui/src/collections_ui/wiring_grid.rs create mode 100644 ui/dr-ui/src/collections_ui/wiring_tree.rs diff --git a/docs/dev/traceability.md b/docs/dev/traceability.md index d7b757b..e48658f 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 | 415 | +| Source files scanned | 423 | | TRACES tags found | 1571 | | Requirements defined | 184 | | Requirements deferred (post-v1) | 24 | @@ -39,14 +39,14 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | 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:1784`](../../ui/dr-ui/src/lib.rs#L1784), [`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:900`](../../ui/dr-ui/ui/app.slint#L900), [`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.rs:1435`](../../ui/dr-ui/src/collections_ui.rs#L1435), [`ui/dr-ui/src/collections_ui.rs:2395`](../../ui/dr-ui/src/collections_ui.rs#L2395), [`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-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-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:387`](../../ui/dr-ui/ui/app.slint#L387), [`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.rs:1766`](../../ui/dr-ui/src/collections_ui.rs#L1766), [`ui/dr-ui/src/collections_ui.rs:1872`](../../ui/dr-ui/src/collections_ui.rs#L1872), [`ui/dr-ui/src/collections_ui.rs:1931`](../../ui/dr-ui/src/collections_ui.rs#L1931), [`ui/dr-ui/src/collections_ui.rs:275`](../../ui/dr-ui/src/collections_ui.rs#L275), [`ui/dr-ui/src/collections_ui.rs:484`](../../ui/dr-ui/src/collections_ui.rs#L484), [`ui/dr-ui/src/collections_ui.rs:87`](../../ui/dr-ui/src/collections_ui.rs#L87), [`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:308`](../../ui/dr-ui/ui/app.slint#L308), [`ui/dr-ui/ui/app.slint:575`](../../ui/dr-ui/ui/app.slint#L575), [`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-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:308`](../../ui/dr-ui/ui/app.slint#L308), [`ui/dr-ui/ui/app.slint:575`](../../ui/dr-ui/ui/app.slint#L575), [`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:320`](../../ui/dr-ui/ui/app.slint#L320), [`ui/dr-ui/ui/app.slint:575`](../../ui/dr-ui/ui/app.slint#L575), [`ui/dr-ui/ui/app.slint:896`](../../ui/dr-ui/ui/app.slint#L896), [`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.rs:1117`](../../ui/dr-ui/src/collections_ui.rs#L1117), [`ui/dr-ui/src/collections_ui.rs:117`](../../ui/dr-ui/src/collections_ui.rs#L117), [`ui/dr-ui/src/collections_ui.rs:1225`](../../ui/dr-ui/src/collections_ui.rs#L1225), [`ui/dr-ui/src/collections_ui.rs:1739`](../../ui/dr-ui/src/collections_ui.rs#L1739), [`ui/dr-ui/src/collections_ui.rs:1785`](../../ui/dr-ui/src/collections_ui.rs#L1785), [`ui/dr-ui/src/collections_ui.rs:1`](../../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2045`](../../ui/dr-ui/src/collections_ui.rs#L2045), [`ui/dr-ui/src/collections_ui.rs:2118`](../../ui/dr-ui/src/collections_ui.rs#L2118), [`ui/dr-ui/src/collections_ui.rs:2178`](../../ui/dr-ui/src/collections_ui.rs#L2178), [`ui/dr-ui/src/collections_ui.rs:2804`](../../ui/dr-ui/src/collections_ui.rs#L2804), [`ui/dr-ui/src/collections_ui.rs:2858`](../../ui/dr-ui/src/collections_ui.rs#L2858), [`ui/dr-ui/src/collections_ui.rs:2904`](../../ui/dr-ui/src/collections_ui.rs#L2904), [`ui/dr-ui/src/collections_ui.rs:3014`](../../ui/dr-ui/src/collections_ui.rs#L3014), [`ui/dr-ui/src/collections_ui.rs:3070`](../../ui/dr-ui/src/collections_ui.rs#L3070), [`ui/dr-ui/src/collections_ui.rs:3239`](../../ui/dr-ui/src/collections_ui.rs#L3239), [`ui/dr-ui/src/collections_ui.rs:3261`](../../ui/dr-ui/src/collections_ui.rs#L3261), [`ui/dr-ui/src/collections_ui.rs:329`](../../ui/dr-ui/src/collections_ui.rs#L329), [`ui/dr-ui/src/collections_ui.rs:3327`](../../ui/dr-ui/src/collections_ui.rs#L3327), [`ui/dr-ui/src/collections_ui.rs:339`](../../ui/dr-ui/src/collections_ui.rs#L339), [`ui/dr-ui/src/collections_ui.rs:3447`](../../ui/dr-ui/src/collections_ui.rs#L3447), [`ui/dr-ui/src/collections_ui.rs:362`](../../ui/dr-ui/src/collections_ui.rs#L362), [`ui/dr-ui/src/collections_ui.rs:3752`](../../ui/dr-ui/src/collections_ui.rs#L3752), [`ui/dr-ui/src/collections_ui.rs:383`](../../ui/dr-ui/src/collections_ui.rs#L383), [`ui/dr-ui/src/collections_ui.rs:514`](../../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:718`](../../ui/dr-ui/src/collections_ui.rs#L718), [`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:311`](../../ui/dr-ui/ui/app.slint#L311), [`ui/dr-ui/ui/app.slint:3213`](../../ui/dr-ui/ui/app.slint#L3213), [`ui/dr-ui/ui/app.slint:570`](../../ui/dr-ui/ui/app.slint#L570), [`ui/dr-ui/ui/app.slint:599`](../../ui/dr-ui/ui/app.slint#L599), [`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-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:311`](../../ui/dr-ui/ui/app.slint#L311), [`ui/dr-ui/ui/app.slint:3213`](../../ui/dr-ui/ui/app.slint#L3213), [`ui/dr-ui/ui/app.slint:570`](../../ui/dr-ui/ui/app.slint#L570), [`ui/dr-ui/ui/app.slint:599`](../../ui/dr-ui/ui/app.slint#L599), [`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:2685`](../../ui/dr-ui/src/lib.rs#L2685), [`ui/dr-ui/src/lib.rs:2832`](../../ui/dr-ui/src/lib.rs#L2832), [`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-CULL-1 | [`core/dr-decode/src/preview.rs:121`](../../core/dr-decode/src/preview.rs#L121) | @@ -119,9 +119,9 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | 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.rs:3051`](../../ui/dr-ui/src/collections_ui.rs#L3051), [`ui/dr-ui/src/collections_ui.rs:3301`](../../ui/dr-ui/src/collections_ui.rs#L3301), [`ui/dr-ui/src/collections_ui.rs:4202`](../../ui/dr-ui/src/collections_ui.rs#L4202), [`ui/dr-ui/src/collections_ui.rs:785`](../../ui/dr-ui/src/collections_ui.rs#L785), [`ui/dr-ui/src/collections_ui.rs:856`](../../ui/dr-ui/src/collections_ui.rs#L856), [`ui/dr-ui/src/lib.rs:1572`](../../ui/dr-ui/src/lib.rs#L1572), [`ui/dr-ui/src/lib.rs:2715`](../../ui/dr-ui/src/lib.rs#L2715), [`ui/dr-ui/src/lib.rs:2786`](../../ui/dr-ui/src/lib.rs#L2786), [`ui/dr-ui/src/lib.rs:3004`](../../ui/dr-ui/src/lib.rs#L3004), [`ui/dr-ui/src/lib.rs:3005`](../../ui/dr-ui/src/lib.rs#L3005), [`ui/dr-ui/src/lib.rs:3044`](../../ui/dr-ui/src/lib.rs#L3044), [`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:1610`](../../ui/dr-ui/ui/app.slint#L1610), [`ui/dr-ui/ui/app.slint:3163`](../../ui/dr-ui/ui/app.slint#L3163), [`ui/dr-ui/ui/app.slint:541`](../../ui/dr-ui/ui/app.slint#L541), [`ui/dr-ui/ui/app.slint:549`](../../ui/dr-ui/ui/app.slint#L549), [`ui/dr-ui/ui/app.slint:555`](../../ui/dr-ui/ui/app.slint#L555), [`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-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:2715`](../../ui/dr-ui/src/lib.rs#L2715), [`ui/dr-ui/src/lib.rs:2786`](../../ui/dr-ui/src/lib.rs#L2786), [`ui/dr-ui/src/lib.rs:3004`](../../ui/dr-ui/src/lib.rs#L3004), [`ui/dr-ui/src/lib.rs:3005`](../../ui/dr-ui/src/lib.rs#L3005), [`ui/dr-ui/src/lib.rs:3044`](../../ui/dr-ui/src/lib.rs#L3044), [`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:1610`](../../ui/dr-ui/ui/app.slint#L1610), [`ui/dr-ui/ui/app.slint:3163`](../../ui/dr-ui/ui/app.slint#L3163), [`ui/dr-ui/ui/app.slint:541`](../../ui/dr-ui/ui/app.slint#L541), [`ui/dr-ui/ui/app.slint:549`](../../ui/dr-ui/ui/app.slint#L549), [`ui/dr-ui/ui/app.slint:555`](../../ui/dr-ui/ui/app.slint#L555), [`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.rs:4202`](../../ui/dr-ui/src/collections_ui.rs#L4202), [`ui/dr-ui/src/collections_ui.rs:785`](../../ui/dr-ui/src/collections_ui.rs#L785), [`ui/dr-ui/src/collections_ui.rs:856`](../../ui/dr-ui/src/collections_ui.rs#L856), [`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-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-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-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:1784`](../../ui/dr-ui/src/lib.rs#L1784), [`ui/dr-ui/ui/import.slint:5`](../../ui/dr-ui/ui/import.slint#L5) | @@ -143,10 +143,10 @@ 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:3195`](../../ui/dr-ui/src/lib.rs#L3195), [`ui/dr-ui/src/lib.rs:3214`](../../ui/dr-ui/src/lib.rs#L3214), [`ui/dr-ui/src/lib.rs:3241`](../../ui/dr-ui/src/lib.rs#L3241), [`ui/dr-ui/src/lib.rs:3292`](../../ui/dr-ui/src/lib.rs#L3292), [`ui/dr-ui/src/lib.rs:3325`](../../ui/dr-ui/src/lib.rs#L3325), [`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:1010`](../../ui/dr-ui/ui/app.slint#L1010), [`ui/dr-ui/ui/app.slint:2842`](../../ui/dr-ui/ui/app.slint#L2842), [`ui/dr-ui/ui/app.slint:906`](../../ui/dr-ui/ui/app.slint#L906), [`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.rs:116`](../../ui/dr-ui/src/collections_ui.rs#L116), [`ui/dr-ui/src/collections_ui.rs:1197`](../../ui/dr-ui/src/collections_ui.rs#L1197), [`ui/dr-ui/src/collections_ui.rs:1207`](../../ui/dr-ui/src/collections_ui.rs#L1207), [`ui/dr-ui/src/collections_ui.rs:154`](../../ui/dr-ui/src/collections_ui.rs#L154), [`ui/dr-ui/src/collections_ui.rs:1739`](../../ui/dr-ui/src/collections_ui.rs#L1739), [`ui/dr-ui/src/collections_ui.rs:1756`](../../ui/dr-ui/src/collections_ui.rs#L1756), [`ui/dr-ui/src/collections_ui.rs:2018`](../../ui/dr-ui/src/collections_ui.rs#L2018), [`ui/dr-ui/src/collections_ui.rs:2804`](../../ui/dr-ui/src/collections_ui.rs#L2804), [`ui/dr-ui/src/collections_ui.rs:3715`](../../ui/dr-ui/src/collections_ui.rs#L3715), [`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:594`](../../ui/dr-ui/ui/app.slint#L594), [`ui/dr-ui/ui/app.slint:608`](../../ui/dr-ui/ui/app.slint#L608), [`ui/dr-ui/ui/app.slint:71`](../../ui/dr-ui/ui/app.slint#L71), [`ui/dr-ui/ui/app.slint:994`](../../ui/dr-ui/ui/app.slint#L994), [`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.rs:209`](../../ui/dr-ui/src/collections_ui.rs#L209), [`ui/dr-ui/src/collections_ui.rs:2804`](../../ui/dr-ui/src/collections_ui.rs#L2804), [`ui/dr-ui/src/collections_ui.rs:414`](../../ui/dr-ui/src/collections_ui.rs#L414), [`ui/dr-ui/src/collections_ui.rs:427`](../../ui/dr-ui/src/collections_ui.rs#L427), [`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:2385`](../../ui/dr-ui/ui/app.slint#L2385), [`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.rs:116`](../../ui/dr-ui/src/collections_ui.rs#L116), [`ui/dr-ui/src/collections_ui.rs:117`](../../ui/dr-ui/src/collections_ui.rs#L117), [`ui/dr-ui/src/collections_ui.rs:1197`](../../ui/dr-ui/src/collections_ui.rs#L1197), [`ui/dr-ui/src/collections_ui.rs:1207`](../../ui/dr-ui/src/collections_ui.rs#L1207), [`ui/dr-ui/src/collections_ui.rs:126`](../../ui/dr-ui/src/collections_ui.rs#L126), [`ui/dr-ui/src/collections_ui.rs:154`](../../ui/dr-ui/src/collections_ui.rs#L154), [`ui/dr-ui/src/collections_ui.rs:1739`](../../ui/dr-ui/src/collections_ui.rs#L1739), [`ui/dr-ui/src/collections_ui.rs:1756`](../../ui/dr-ui/src/collections_ui.rs#L1756), [`ui/dr-ui/src/collections_ui.rs:1872`](../../ui/dr-ui/src/collections_ui.rs#L1872), [`ui/dr-ui/src/collections_ui.rs:2018`](../../ui/dr-ui/src/collections_ui.rs#L2018), [`ui/dr-ui/src/collections_ui.rs:2045`](../../ui/dr-ui/src/collections_ui.rs#L2045), [`ui/dr-ui/src/collections_ui.rs:209`](../../ui/dr-ui/src/collections_ui.rs#L209), [`ui/dr-ui/src/collections_ui.rs:2169`](../../ui/dr-ui/src/collections_ui.rs#L2169), [`ui/dr-ui/src/collections_ui.rs:2804`](../../ui/dr-ui/src/collections_ui.rs#L2804), [`ui/dr-ui/src/collections_ui.rs:2858`](../../ui/dr-ui/src/collections_ui.rs#L2858), [`ui/dr-ui/src/collections_ui.rs:3327`](../../ui/dr-ui/src/collections_ui.rs#L3327), [`ui/dr-ui/src/collections_ui.rs:3715`](../../ui/dr-ui/src/collections_ui.rs#L3715), [`ui/dr-ui/src/collections_ui.rs:3752`](../../ui/dr-ui/src/collections_ui.rs#L3752), [`ui/dr-ui/src/collections_ui.rs:414`](../../ui/dr-ui/src/collections_ui.rs#L414), [`ui/dr-ui/src/collections_ui.rs:427`](../../ui/dr-ui/src/collections_ui.rs#L427), [`ui/dr-ui/src/collections_ui.rs:514`](../../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:718`](../../ui/dr-ui/src/collections_ui.rs#L718), [`ui/dr-ui/src/collections_ui.rs:738`](../../ui/dr-ui/src/collections_ui.rs#L738), [`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:2567`](../../ui/dr-ui/src/lib.rs#L2567), [`ui/dr-ui/src/lib.rs:2786`](../../ui/dr-ui/src/lib.rs#L2786), [`ui/dr-ui/src/lib.rs:2846`](../../ui/dr-ui/src/lib.rs#L2846), [`ui/dr-ui/src/lib.rs:3005`](../../ui/dr-ui/src/lib.rs#L3005), [`ui/dr-ui/src/lib.rs:3429`](../../ui/dr-ui/src/lib.rs#L3429), [`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:171`](../../ui/dr-ui/ui/app.slint#L171), [`ui/dr-ui/ui/app.slint:2080`](../../ui/dr-ui/ui/app.slint#L2080), [`ui/dr-ui/ui/app.slint:2202`](../../ui/dr-ui/ui/app.slint#L2202), [`ui/dr-ui/ui/app.slint:2609`](../../ui/dr-ui/ui/app.slint#L2609), [`ui/dr-ui/ui/app.slint:2742`](../../ui/dr-ui/ui/app.slint#L2742), [`ui/dr-ui/ui/app.slint:3213`](../../ui/dr-ui/ui/app.slint#L3213), [`ui/dr-ui/ui/app.slint:570`](../../ui/dr-ui/ui/app.slint#L570), [`ui/dr-ui/ui/app.slint:599`](../../ui/dr-ui/ui/app.slint#L599), [`ui/dr-ui/ui/app.slint:608`](../../ui/dr-ui/ui/app.slint#L608), [`ui/dr-ui/ui/app.slint:712`](../../ui/dr-ui/ui/app.slint#L712), [`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.rs:1`](../../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/develop_ui.rs:849`](../../ui/dr-ui/src/develop_ui.rs#L849), [`ui/dr-ui/src/lib.rs:3389`](../../ui/dr-ui/src/lib.rs#L3389), [`ui/dr-ui/src/lib.rs:3580`](../../ui/dr-ui/src/lib.rs#L3580), [`ui/dr-ui/src/masks_ui.rs:1584`](../../ui/dr-ui/src/masks_ui.rs#L1584), [`ui/dr-ui/ui/app.slint:2569`](../../ui/dr-ui/ui/app.slint#L2569), [`ui/dr-ui/ui/app.slint:2609`](../../ui/dr-ui/ui/app.slint#L2609), [`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-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:594`](../../ui/dr-ui/ui/app.slint#L594), [`ui/dr-ui/ui/app.slint:608`](../../ui/dr-ui/ui/app.slint#L608), [`ui/dr-ui/ui/app.slint:71`](../../ui/dr-ui/ui/app.slint#L71), [`ui/dr-ui/ui/app.slint:994`](../../ui/dr-ui/ui/app.slint#L994), [`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:2385`](../../ui/dr-ui/ui/app.slint#L2385), [`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:2567`](../../ui/dr-ui/src/lib.rs#L2567), [`ui/dr-ui/src/lib.rs:2786`](../../ui/dr-ui/src/lib.rs#L2786), [`ui/dr-ui/src/lib.rs:2846`](../../ui/dr-ui/src/lib.rs#L2846), [`ui/dr-ui/src/lib.rs:3005`](../../ui/dr-ui/src/lib.rs#L3005), [`ui/dr-ui/src/lib.rs:3429`](../../ui/dr-ui/src/lib.rs#L3429), [`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:171`](../../ui/dr-ui/ui/app.slint#L171), [`ui/dr-ui/ui/app.slint:2080`](../../ui/dr-ui/ui/app.slint#L2080), [`ui/dr-ui/ui/app.slint:2202`](../../ui/dr-ui/ui/app.slint#L2202), [`ui/dr-ui/ui/app.slint:2609`](../../ui/dr-ui/ui/app.slint#L2609), [`ui/dr-ui/ui/app.slint:2742`](../../ui/dr-ui/ui/app.slint#L2742), [`ui/dr-ui/ui/app.slint:3213`](../../ui/dr-ui/ui/app.slint#L3213), [`ui/dr-ui/ui/app.slint:570`](../../ui/dr-ui/ui/app.slint#L570), [`ui/dr-ui/ui/app.slint:599`](../../ui/dr-ui/ui/app.slint#L599), [`ui/dr-ui/ui/app.slint:608`](../../ui/dr-ui/ui/app.slint#L608), [`ui/dr-ui/ui/app.slint:712`](../../ui/dr-ui/ui/app.slint#L712), [`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:3389`](../../ui/dr-ui/src/lib.rs#L3389), [`ui/dr-ui/src/lib.rs:3580`](../../ui/dr-ui/src/lib.rs#L3580), [`ui/dr-ui/src/masks_ui.rs:1584`](../../ui/dr-ui/src/masks_ui.rs#L1584), [`ui/dr-ui/ui/app.slint:2569`](../../ui/dr-ui/ui/app.slint#L2569), [`ui/dr-ui/ui/app.slint:2609`](../../ui/dr-ui/ui/app.slint#L2609), [`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:3214`](../../ui/dr-ui/src/lib.rs#L3214), [`ui/dr-ui/ui/adjust.slint:974`](../../ui/dr-ui/ui/adjust.slint#L974), [`ui/dr-ui/ui/app.slint:2325`](../../ui/dr-ui/ui/app.slint#L2325), [`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) | @@ -164,7 +164,7 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | 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:2318`](../../ui/dr-ui/src/lib.rs#L2318) | | 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.rs:1`](../../ui/dr-ui/src/collections_ui.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-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-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) | diff --git a/ui/dr-ui/src/collections_ui.rs b/ui/dr-ui/src/collections_ui.rs deleted file mode 100644 index 3e459fd..0000000 --- a/ui/dr-ui/src/collections_ui.rs +++ /dev/null @@ -1,4591 +0,0 @@ -//! TRACES: FR-CAT-7 | FR-UI-5 | NFR-P9 -//! The collections sidebar, grid selection, and the drag between them. -//! -//! `dr_catalog::collections` owns the data rules — hierarchy, membership, -//! revisions, cycles. This module owns the *interaction*: what is selected, -//! what a drag is carrying, and where a release lands. -//! -//! # What this module does and does not own -//! -//! Slint's `DragArea`/`DropArea` own the *gesture* — pointer capture, the -//! threshold separating a click from a drag, arbitration against the grid's -//! Flickable, the image under the cursor, and hit-testing the release. So the -//! drop arrives already addressed to a collection, and nothing here tracks -//! pointer positions or guesses a target. -//! -//! What is left here is what Slint cannot know: -//! -//! - **the payload** — which images the drag carries, built from the selection -//! at the moment the drag starts; -//! - **the spring** — a dwell timer that opens a collapsed collection so a -//! nested child can be reached mid-drag, and closes again what the drag only -//! passed over. -//! -//! An earlier version hand-rolled the whole gesture on `TouchArea` and did not -//! work, for a reason worth keeping: an interactive `Flickable` claims any drag -//! starting inside it for scrolling and cancels the child TouchArea's press, so -//! the drag could never leave the grid. -//! -//! # Selection -//! -//! Selection is by **catalog image id**, never by row index. The grid is a -//! window over the catalog (FR-CAT-4) and scrubbing replaces every row, so an -//! index-based selection would silently come to mean forty different -//! photographs after a scrub. Ids survive that; they also survive a rescan. - -use std::cell::RefCell; -use std::collections::BTreeSet; -use std::rc::Rc; - -use dr_catalog::collections::{self as coll, CollectionKind}; -use dr_catalog::Catalog; -use dr_types::{CollectionId, ImageId}; -use rusqlite::OptionalExtension as _; -use slint::{ComponentHandle, Model as _}; - -use crate::library; -use crate::{AppWindow, CollectionRow}; - -/// The selection as it stood before a press, for [`cancel_press`]. -struct PressUndo { - selection: BTreeSet, - anchor: Option, - cursor: Option, -} - -impl PressUndo { - /// Everything [`select_row`] is about to change. - /// - /// The three are the whole of what a press touches, which is the property - /// the restore depends on and the reason it is worth a test of its own: a - /// press that grew a fourth piece of state would leave that piece behind - /// after a cancel, silently. - fn capture( - selection: &BTreeSet, - anchor: Option, - cursor: Option, - ) -> Self { - Self { - selection: selection.clone(), - anchor, - cursor, - } - } - - /// Put it all back. Returns the one the caller holds in a `Cell`. - fn restore( - self, - selection: &mut BTreeSet, - anchor: &mut Option, - ) -> Option { - *selection = self.selection; - *anchor = self.anchor; - self.cursor - } -} - -/// TRACES: FR-CAT-5 -/// How a run of grid ordinals is turned into the ids it names. -/// -/// See [`CollectionsController::span_source`] for why this is supplied rather -/// than reached for. -type SpanIds = Rc Vec>; - -/// Selection, drag, and tree state for the running window. -/// -/// Everything is `RefCell` because Slint callbacks are `Fn`, not `FnMut`, and -/// they all run on the one event-loop thread — the same shape -/// [`crate::library_ui::LibraryController`] uses. -#[derive(Default)] -pub struct CollectionsController { - /// Selected images, by catalog id. A `BTreeSet` rather than a `Vec` so - /// membership tests are cheap during a rubber-band and the order a drop - /// applies in is stable across runs. - selection: RefCell>, - /// Where a shift-click extends from. The last cell *clicked*, not the last - /// added — extending from the far end of a previous range is not what the - /// gesture means anywhere else. - /// - /// An **image ordinal in the library**, not a row of the loaded window. - /// The grid is a window over the catalog, so row 7 names a different - /// photograph after every scroll: held as a row, an anchor set before a - /// scroll described a range from wherever that row had since drifted to. - /// Selection is by id for the same reason (see the preamble); this is the - /// same argument applied to the one index that has to survive a move. - anchor: RefCell>, - /// TRACES: FR-UI-2 | FR-UI-4 - /// TRACES: FR-CAT-7 | FR-UI-4 - /// A photograph the most recent press asked to take *out* of the - /// selection, held until the release says the press was a tap. - /// - /// See [`Press::Deferred`] for why removal cannot happen on the press: - /// this is the whole of what stops a drag of forty photographs carrying - /// one. Overwritten by the next press and dropped by the drag that - /// consumes it, so at most one is ever pending. - pending_toggle: std::cell::Cell>, - /// TRACES: FR-UI-4 - /// What the selection was immediately before the most recent press, so a - /// gesture that turns out not to have been a press can put it back. - /// - /// A press has to act immediately — the drag that may follow reads the - /// selection to build its payload, so deciding on release is too late. - /// That is right for a drag and wrong for a pinch, which begins as an - /// ordinary one-finger press and only becomes a pinch when the second - /// finger lands. By then a cell has been selected that the user never - /// meant to touch; they were reaching for the grid with two fingers. - /// - /// Cheap to keep: a few hundred ids at most, cloned once per press. - press_undo: RefCell>, - /// Where the keyboard is, as an image ordinal. - /// - /// Distinct from the anchor, and it has to be: shift+arrow grows a range - /// *from* the anchor *to* the cursor, so the two are the two ends and - /// cannot be one field. `None` until the user has taken hold of the grid, - /// so the first arrow press starts from what is on screen rather than - /// jumping to the top of the library. - cursor: std::cell::Cell>, - /// Whether the press that began the current gesture carried ctrl or shift. - /// - /// A modified click is a *selection* gesture and must not also open the - /// image: building a selection would otherwise throw the user into the - /// develop view on the second ctrl-click. Slint does not report modifiers on - /// `clicked`, so the press records them and the click consults this. - modified_press: std::cell::Cell, - /// TRACES: FR-UI-2 | FR-UI-4 - /// Whether a tap in the grid selects rather than opens. - /// - /// Touch has no ctrl and no shift, so without a mode there is no way to - /// select a second photograph: the first tap would open the first one. In - /// this mode a plain press is reported as a ctrl-press and goes through the - /// same [`apply_press`] as everything else — a separate touch policy would - /// be a second copy of these rules to keep in step. - select_mode: std::cell::Cell, - /// The timer that turns a held cell into a selection. - /// - /// Here rather than in `.slint` because Slint has no long-press gesture and - /// a hand-rolled one would need a `Timer` element per visible cell — a - /// hundred timers to answer a question about one finger. Held so that - /// dropping it cancels: a press that ends, or is taken by the Flickable - /// when the finger travels, must not arrive as a selection a moment later. - hold_timer: RefCell>, - /// Collection ids parallel to the sidebar's rows, so a hovered row index - /// resolves to an id without another query. - row_ids: RefCell>, - /// Which rows are saved filters, so a drop onto one is refused *before* the - /// release rather than after. - row_smart: RefCell>, - /// Which rows have children, so the spring knows there is anything to open. - row_has_children: RefCell>, - /// Collapsed collections, by id. Collapse is a view preference and - /// deliberately not persisted to the catalog — it is not something to sync - /// between devices. - collapsed: RefCell>, - /// Which collection scopes the grid. `None` is the whole library. - scope: RefCell>, - /// The collection whose name is being edited in the sidebar, if any. - /// - /// Held here rather than in Slint because a rename can also be *started* - /// from Rust — creating a collection opens its field — and because a - /// commit that the catalog refuses has to leave the field open on the name - /// the user typed rather than silently closing over a rejected edit. - renaming: RefCell>, - /// Whether the grid is showing the trash rather than the library. - /// - /// Separate from `scope` because the trash is not a collection: its contents - /// come from `trashed_at`, not from membership, and every other query in the - /// library *excludes* exactly what this view exists to show. Folding it into - /// `scope` as a sentinel id would put that inversion inside a type that - /// means "a collection". - viewing_trash: std::cell::Cell, - /// The live drag: what it carries. Empty means no drag. - dragging: RefCell>, - /// The collection being dragged, when the drag is a tree rearrangement - /// rather than a filing of photographs. - /// - /// Set when the drag starts and read by the drop, the same way `dragging` - /// works — the drop callback carries only the target's id, so what is being - /// dropped has to be remembered rather than inspected. - dragging_collection: RefCell>, - /// TRACES: FR-UI-3 | FR-UI-4 - /// The collection a hold has picked up, and the timer that picks it up. - /// - /// The tree is inside a Flickable, which claims any drag beginning inside - /// it — so with a finger, a drag on a row is a scroll unless something says - /// otherwise first. The hold is that something. It arms the drag *and* - /// opens the row menu, and which of the two the user gets is decided by - /// whether they then moved: the same fork the grid already uses to tell a - /// hold-to-select from a drag-to-file. - lifted: RefCell>, - /// Whether the lifted row's drag actually began. Reset by the press that - /// arms the next one, so a release can tell a rearrangement from a menu. - drag_began: std::cell::Cell, - /// The hold timer for a sidebar row. One, replaced per press, so a press - /// that became a scroll leaves nothing queued to fire over the list the - /// user is now scrolling. - row_hold_timer: RefCell>, - /// Whether each visible row has a parent, in `row_ids` order — what decides - /// whether "All photographs" lights up as a drop target. Kept beside the - /// rows it indexes rather than queried per drag: `refresh_tree` already has - /// the parent in hand, and a query would answer for a tree that may have - /// been rebuilt since. - row_nested: RefCell>, - /// The collection the row menu is open on. - /// - /// Held here as well as in the window's title property because the two say - /// different things: the property is what the sheet *draws*, and this is - /// what every action *acts on*. A rename committed from the menu changes - /// the first and must not change the second. - menu_for: RefCell>, - /// Whether the menu's delete has been asked once and is waiting to be - /// confirmed. - /// - /// A `Cell` rather than a window property alone so the decision is made in - /// Rust: the sheet must not be able to reach the destructive branch by - /// flipping a bit of its own. - menu_confirming: std::cell::Cell, - /// The collection a drag is currently over, by id. - /// - /// Only the spring needs this — the *drop* is hit-tested by Slint and - /// arrives with its own id, so nothing here has to remember where the - /// pointer was. - hover_id: RefCell>, - /// Spring-loaded expansion: the timer that opens a collapsed parent the - /// pointer has been dwelling on mid-drag. - /// - /// One timer, restarted per row, so moving on cancels the pending - /// expansion rather than leaving a queue of them to fire later. - spring_timer: RefCell>, - /// Collections the spring opened during *this* drag, so they can be closed - /// again if the drag ends elsewhere. Without this, dragging across a deep - /// tree leaves every parent it passed over hanging open. - spring_opened: RefCell>, - /// Images dropped on the trash, recorded by `dropped-on-trash` and acted on - /// in `drag-finished` — the same deferral, for the same reason. - trash_requested: RefCell>>, - /// Drains a trash worker. Held so a second operation replaces the first - /// rather than two timers fighting over the same model. - trash_timer: RefCell>, - /// Which collection a drop landed on, recorded by `dropped` and acted on in - /// `drag-finished`. - /// - /// Deferred because every consequence of a drop replaces a Slint model — - /// the tree, the grid cells — and doing that from inside the `dropped` - /// handler destroys the elements Slint is still using to deliver the event. - dropped_on: RefCell>, - /// TRACES: FR-CAT-5 - /// How a run of grid ordinals is turned into ids. - /// - /// Supplied by [`crate::library_ui`] at wiring time, because the catalog, - /// the scope and the rating filter — everything the run depends on — belong - /// to the grid's controller, not to this one. Held as a closure rather than - /// reached for through a handle so the selection rules stay testable with - /// no library open: the tests pass a run that reads a plain slice. - /// - /// `None` before wiring, which the caller reads as "nothing to ask" and - /// falls back to the loaded window. - span_source: RefCell>, - /// Where the trash workers report what they are doing. - /// - /// Shared with [`crate::library_ui`]: a delete and a scan are two jobs in - /// one list, and the user asking what the application is busy with does not - /// care which module started them. - activity: Rc, -} - -/// What a release over a collection row means. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Drop { - /// File these photographs in the target. - FileImages(Vec), - /// Move this collection under the target. - Reparent(CollectionId), - /// Nothing to do — an empty drag, or a row dropped on itself. - Nothing, -} - -/// Decide what a drop on `target` should do. -/// -/// Separated from the callback because the gesture cannot be driven from a -/// test — Slint owns it — while this decision is where it can actually go -/// wrong. The order matters: an image drag always fills `carried`, so -/// photographs can never be mistaken for a rearrangement, and only an empty -/// payload consults the row remembered from the press. -pub fn decide_drop( - carried: &[ImageId], - pressed_row: Option, - target: CollectionId, -) -> Drop { - if !carried.is_empty() { - return Drop::FileImages(carried.to_vec()); - } - match pressed_row { - // A collection cannot go inside itself. Caught here as well as in the - // catalog so the common case is a no-op rather than an error message. - Some(source) if source != target => Drop::Reparent(source), - _ => Drop::Nothing, - } -} - -/// TRACES: FR-CAT-7 -/// What the row menu's Delete should do on this press. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DeleteStep { - /// Delete it now. Nothing is lost that the user cannot see is nothing. - Now, - /// Say what goes, and wait to be asked again. - Confirm, -} - -/// TRACES: FR-CAT-7 -/// Whether deleting this collection needs to be confirmed first. -/// -/// The gesture this replaced refused outright whenever the collection held -/// anything, which made a full collection undeletable without emptying it by -/// hand — child by child, since a parent counts its descendants. Refusing is -/// not a safety property; it is the absence of one, because the user goes and -/// does the same thing the long way round. -/// -/// So: an empty collection goes on the first press, because there is nothing -/// to warn about and a dialogue asking "delete this empty thing?" is the kind -/// that teaches people to dismiss dialogues. Anything else is asked once. -/// -/// Split from the handler because this is the whole of the safety rule, and a -/// handler needs a window to run. -pub fn decide_delete(holds: usize, children: usize, confirmed: bool) -> DeleteStep { - if confirmed || (holds == 0 && children == 0) { - DeleteStep::Now - } else { - DeleteStep::Confirm - } -} - -/// TRACES: FR-CAT-7 -/// The line under the menu's title: what this collection directly holds. -/// -/// Direct members and direct children, not the sidebar's deep count, because -/// this line sits above a Delete — and delete drops *this* collection's member -/// rows while promoting its children rather than taking them. A number here -/// that counted descendants' photographs would be describing something the -/// button below it does not do. -pub fn menu_detail(holds: usize, children: usize) -> String { - let photos = match holds { - 0 => "no photographs".to_string(), - 1 => "1 photograph".to_string(), - n => format!("{n} photographs"), - }; - match children { - 0 => photos, - 1 => format!("{photos} · 1 collection inside"), - n => format!("{photos} · {n} collections inside"), - } -} - -/// TRACES: FR-CAT-7 -/// What deleting this collection actually does, in full. -/// -/// Every clause here is one a user has got wrong about a collections feature -/// before: that deleting a collection deletes the photographs (it does not — -/// membership is a join table), and that deleting a parent takes the -/// collections nested in it (it does not — [`dr_catalog::collections::delete`] -/// promotes them, because losing a subtree because its container was tidied -/// away is not recoverable). -pub fn delete_warning(name: &str, holds: usize, children: usize) -> String { - let mut out = format!("Deleting “{name}” "); - match (holds, children) { - (0, _) => out.push_str("removes it from the sidebar."), - (1, _) => out.push_str("takes 1 photograph out of it."), - (n, _) => out.push_str(&format!("takes {n} photographs out of it.")), - } - if holds > 0 { - out.push_str(" They stay in your library and in every other collection they are in."); - } - match children { - 0 => {} - 1 => { - out.push_str(" The collection inside it moves up one level rather than going with it.") - } - n => out.push_str(&format!( - " The {n} collections inside it move up one level rather than going with it." - )), - } - out -} - -/// TRACES: FR-UI-3 | FR-UI-4 -/// What letting go of a held collection row means. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Release { - /// The hold fired and nothing moved: the user is asking what can be done - /// to this collection. - OpenMenu(CollectionId), - /// Either the hold never fired — an ordinary tap, which selects — or it - /// did and the row was then dragged, in which case the drop has already - /// done the work. - Nothing, -} - -/// TRACES: FR-UI-3 | FR-UI-4 -/// Decide what a release does, from what the press turned into. -/// -/// The whole of the fork that lets one gesture mean two things, and the reason -/// it is worth a test: get it wrong in the direction of `OpenMenu` and every -/// rearrangement ends with a sheet over the tree the user just tidied; get it -/// wrong the other way and the menu is unreachable with a finger. -pub fn decide_release(held: Option, dragged: bool) -> Release { - match held { - Some(id) if !dragged => Release::OpenMenu(id), - _ => Release::Nothing, - } -} - -impl CollectionsController { - pub fn new(activity: Rc) -> Rc { - Rc::new(Self { - activity, - ..Default::default() - }) - } - - /// Remember which row was pressed, so a drag that follows knows what it is - /// carrying. - /// - /// Recorded on the press rather than at the drag's start because Slint - /// builds the payload through a `pure` binding, which must not have side - /// effects — and the drop callback is handed only the *target's* id, so the - /// source has to be remembered somewhere. - pub fn note_row_press(&self, id: CollectionId) { - *self.dragging_collection.borrow_mut() = Some(id); - } - - /// Which collection the grid is scoped to, for [`crate::library_ui`] to - /// build its query from. - pub fn scope(&self) -> Option { - *self.scope.borrow() - } - - /// Whether the grid should be showing the trash. - pub fn viewing_trash(&self) -> bool { - self.viewing_trash.get() - } - - /// Whether the gesture in progress began with ctrl or shift held. - /// - /// A modified click selects and nothing more — opening the image as well - /// would eject the user from the grid they are selecting in. - pub fn press_was_modified(&self) -> bool { - self.modified_press.get() - } - - /// Selected image ids, in a stable order. - pub fn selected(&self) -> Vec { - self.selection.borrow().iter().copied().collect() - } - - /// TRACES: FR-CAT-5 - /// The ids of grid rows `first..=last`, in the order the grid shows them. - /// - /// Empty when nothing has been wired in — see [`Self::span_source`]. - fn span(&self, first: usize, last: usize) -> Vec { - let source = self.span_source.borrow().clone(); - source.map(|f| f(first, last)).unwrap_or_default() - } - - /// Drop the selection — after a scrub, or when the scope changes. - /// - /// Selection is by id and survives a window change, but a selection the - /// user cannot see is a selection they will act on by accident. Clearing on - /// a deliberate navigation is the safer of the two behaviours. - pub fn clear_selection(&self) { - self.selection.borrow_mut().clear(); - *self.anchor.borrow_mut() = None; - self.cursor.set(None); - } - - /// Where the keyboard cursor is, as an image ordinal. - pub fn cursor(&self) -> Option { - self.cursor.get() - } - - pub fn set_cursor(&self, at: Option) { - self.cursor.set(at); - } -} - -/// TRACES: FR-CAT-7 | FR-UI-4 -/// What a press did, and what is left for the release to do. -/// -/// Only one press has anything left over, and it is the one every multi-image -/// drag depends on — see [`Press::Deferred`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[must_use] -pub enum Press { - /// The selection is already what this press means. Nothing to finish. - Applied, - /// **Taking a photograph out of the selection waits for the release.** - /// - /// Putting one *in* has to happen on the press: the drag that may follow - /// reads the selection to decide what it carries, and by the time the - /// finger lifts it is over the sidebar. Taking one out is the opposite — - /// nothing between the press and the release needs the image gone, and one - /// thing very much needs it to stay. - /// - /// A plain press has said so since selection was written: pressing an - /// already-selected cell leaves the selection alone. Ctrl did not, and on a - /// tablet *every* press is a ctrl-press — that is what selection mode is. - /// So grabbing one of forty selected photographs deselected it on the way - /// down; the drag that followed found the cell under the finger no longer - /// in the selection, took that to mean an unselected image was being - /// dragged, and carried it alone. Forty photographs became one, and the - /// only clue was the grabbed cell's ring blinking out. - /// - /// So the removal is handed back for the *click* to apply, and a click - /// fires only for a press that stayed put — never for one that became a - /// drag. See `tap-slop` in `library.slint`. - Deferred(ImageId), -} - -/// Apply a press to the selection. -/// -/// Split from the callback so the policy is testable without a window: this is -/// the part a user notices being wrong. -/// -/// - **plain** — replace the selection with this one image -/// - **ctrl** — add this image to the selection, keeping the rest, and move the -/// anchor here; taking one *out* is [deferred](Press::Deferred) to the release -/// - **shift** — select the range from the anchor to here, *replacing* what was -/// selected; the anchor stays put, so an overshoot is corrected by -/// shift-clicking the right cell rather than starting again -/// - **ctrl+shift** — the same range, *added* to the selection, for picking up a -/// second run without losing the first -/// -/// A press on an image that is *already* selected never removes it here — -/// plainly or with ctrl. That is what makes dragging a multi-selection possible -/// at all: the press that begins the drag would otherwise take the grabbed -/// photograph out from under it. -/// -/// `ids` is the **loaded window**, `offset` where it starts in the library and -/// `row` a position within it; the anchor is kept as `offset + row`, an -/// ordinal that still names the same photograph after the window has moved. -/// -/// `span` turns a run of ordinals into the ids it names — the catalog's job, -/// since the run is mostly not loaded. Passed in rather than reached for so -/// this stays a pure function of what it is given. -#[allow(clippy::too_many_arguments)] -pub fn apply_press( - selection: &mut BTreeSet, - anchor: &mut Option, - ids: &[ImageId], - offset: usize, - row: usize, - ctrl: bool, - shift: bool, - span: &dyn Fn(usize, usize) -> Vec, -) -> Press { - let Some(&id) = ids.get(row) else { - return Press::Applied; - }; - let here = offset + row; - - if shift { - let Some(from) = *anchor else { - // No anchor to extend from: behave like a plain click and become - // the anchor, so the *next* shift-click has a range to describe. - selection.clear(); - selection.insert(id); - *anchor = Some(here); - return Press::Applied; - }; - - // The anchor deliberately does **not** move. Shift-clicking again - // re-describes the range from the same origin, so a user who overshoots - // corrects by shift-clicking the right cell rather than starting over. - // That also means the previous range must be cleared first — extending - // without clearing turns a correction into a union, and the user ends up - // dragging cells they thought they had deselected. - // - // Ctrl+shift is the exception: it *adds* a range to what is already - // selected, which is how a second run is picked up without losing the - // first. - if !ctrl { - selection.clear(); - } - - let (lo, hi) = if from <= here { - (from, here) - } else { - (here, from) - }; - - // The run is described in ordinals and resolved by the catalog, which - // is the only thing that knows what lies between them. Selection is by - // id, and an image outside the loaded window has no id here — so an - // earlier version truncated the run to what was on screen, and - // shift-clicking two ends of a morning selected the dozen cells that - // happened to be loaded. The user cannot see that they did not get - // what they asked for until the drop files a dozen photographs - // instead of two hundred. - let mut run = span(lo, hi); - if run.is_empty() { - // Nothing to ask — no library open, or a query that failed. The - // loaded window is a poorer answer than the catalog's and a far - // better one than selecting nothing. - // - // The slice is always in range and needs no guard: `ids.get(row)` - // succeeded, so the window is non-empty and `here` is inside it, - // and `here` is one of the two bounds. - let last = ids.len() - 1; - let lo_row = lo.saturating_sub(offset); - let hi_row = hi.saturating_sub(offset).min(last); - run = ids[lo_row..=hi_row].to_vec(); - } - selection.extend(run); - return Press::Applied; - } - - if ctrl { - *anchor = Some(here); - - // Taking one out waits for the release — see [`Press::Deferred`]. - if selection.contains(&id) { - return Press::Deferred(id); - } - - selection.insert(id); - return Press::Applied; - } - - // Plain press on something already selected: leave it. The drag that may - // follow carries the whole selection, and collapsing it here would make a - // multi-image drag impossible to start. - if selection.contains(&id) { - *anchor = Some(here); - return Press::Applied; - } - - selection.clear(); - selection.insert(id); - *anchor = Some(here); - Press::Applied -} - -/// Apply a press and push the result into the grid — the whole of what a -/// click, or an arrow key, does to the selection. -/// -/// Shared so the keyboard and the pointer cannot drift: they are the same -/// gesture reached two ways, and the moment one of them grew its own copy of -/// the selection policy, "click here, shift+down twice" would stop meaning -/// what "click here, shift-click there" means. -pub fn select_row( - window: &AppWindow, - ctl: &Rc, - ids: &[ImageId], - offset: usize, - row: usize, - ctrl: bool, - shift: bool, -) { - // Before anything moves — see `press_undo`. A press that turns out to be - // the opening finger of a pinch is undone from here. - *ctl.press_undo.borrow_mut() = Some(PressUndo::capture( - &ctl.selection.borrow(), - *ctl.anchor.borrow(), - ctl.cursor(), - )); - - // Whatever the last press left pending is answered by this one: two - // presses without a click in between means the first never was a tap. - ctl.pending_toggle.set(None); - - if let Press::Deferred(id) = apply_press( - &mut ctl.selection.borrow_mut(), - &mut ctl.anchor.borrow_mut(), - ids, - offset, - row, - ctrl, - shift, - &|first, last| ctl.span(first, last), - ) { - ctl.pending_toggle.set(Some(id)); - } - // The cursor follows the press, so an arrow key after a click continues - // from the cell that was clicked rather than from wherever the keyboard - // was last. - ctl.set_cursor(Some(offset + row)); - sync_selection(window, ctl, ids); -} - -/// TRACES: FR-CAT-7 | FR-UI-4 -/// Finish a press that turned out to be a tap. -/// -/// The other half of [`Press::Deferred`]: a ctrl-press on an already-selected -/// photograph leaves it in, because the drag that may follow has to be able to -/// carry it, and this takes it out again once the release has proved there was -/// no drag. -/// -/// Called from the click, which Slint reports only for a press that stayed -/// within `tap-slop` of where it landed — so a press that became a drag never -/// reaches here, and a press taken over by the Flickable never reaches here -/// either. Both leave the pending removal to be dropped by the next press. -pub fn commit_press(window: &AppWindow, ctl: &Rc, ids: &[ImageId]) { - let Some(id) = ctl.pending_toggle.take() else { - return; - }; - ctl.selection.borrow_mut().remove(&id); - sync_selection(window, ctl, ids); -} - -/// TRACES: FR-UI-4 -/// Put the selection back as it was before the most recent press. -/// -/// For the gesture that begins as a press and turns out to be something else. -/// A pinch is the case that matters: it starts as one finger on a cell, which -/// selects it, and only becomes a pinch when the second finger lands — by -/// which point the user has selected a photograph they were not reaching for. -/// -/// Idempotent, and a no-op when there is nothing to undo, so it is safe to -/// call on every gesture start rather than only on the ones that need it. -pub fn cancel_press(window: &AppWindow, ctl: &Rc, ids: &[ImageId]) { - // The press is being unmade, so what it left for the release to finish is - // unmade with it. Cleared before the early return: a press that changed - // nothing to undo can still have deferred a removal — and a finger that - // held long enough to pick a photograph up before the second one landed - // has to put it back down, or the ring stays open around a cell nobody is - // touching and the grid stays frozen with it. - ctl.pending_toggle.set(None); - window.set_library_held_row(-1); - - let Some(undo) = ctl.press_undo.borrow_mut().take() else { - return; - }; - let cursor = undo.restore( - &mut ctl.selection.borrow_mut(), - &mut ctl.anchor.borrow_mut(), - ); - ctl.set_cursor(cursor); - sync_selection(window, ctl, ids); -} - -/// Rebuild the sidebar from the catalog. -/// -/// Called after every edit. The whole tree rather than a patch: a rename can -/// reorder siblings, a delete promotes children, and a drop changes counts on -/// every ancestor — diffing that against the model would be more code than the -/// query costs, and the tree is tens of rows, not thousands. -pub fn refresh_tree(window: &AppWindow, ctl: &Rc, catalog: &Catalog) { - let rows = match coll::tree(catalog.connection()) { - Ok(r) => r, - Err(e) => { - window.set_collection_error(format!("reading collections: {e}").into()); - return; - } - }; - - let collapsed = ctl.collapsed.borrow(); - // TRACES: FR-NC-6a | FR-NC-6c - // How much of each collection is already on the device, rolled up the tree - // — computed once for the whole sidebar rather than per row. - let held = offline_state(catalog, &rows); - - // A row is hidden when any ancestor is collapsed. The tree arrives - // depth-first, so tracking the shallowest collapsed depth seen is enough — - // no ancestor lookup per row. - let mut hide_below: Option = None; - let mut ids = Vec::new(); - let mut smart = Vec::new(); - let mut has_kids = Vec::new(); - let mut nested = Vec::new(); - let mut out = Vec::new(); - - for row in &rows { - if let Some(depth) = hide_below { - if row.depth > depth { - continue; - } - hide_below = None; - } - - let id = row.collection.id; - let expanded = !collapsed.contains(&id); - if row.has_children && !expanded { - hide_below = Some(row.depth); - } - - // Deep counts are one query per row. That is fine at sidebar scale and - // wrong at grid scale, which is why the grid does not do this. - let deep = - coll::deep_count(catalog.connection(), id).unwrap_or(row.collection.direct_count); - - let offline = held.get(&id).copied().unwrap_or_default(); - - ids.push(id); - smart.push(row.collection.kind == CollectionKind::Smart); - has_kids.push(row.has_children); - nested.push(row.collection.parent.is_some()); - out.push(CollectionRow { - id: id.0 as i32, - name: row.collection.name.as_str().into(), - depth: row.depth as i32, - direct_count: row.collection.direct_count as i32, - deep_count: deep as i32, - has_children: row.has_children, - expanded, - smart: row.collection.kind == CollectionKind::Smart, - // An empty collection is never "kept": every one of its zero - // photographs being here is true and says nothing, and a full tray - // beside an empty collection is a lie about what a trip would cost - // to take. - pinned: offline.total > 0 && offline.pinned == offline.total, - partly_pinned: offline.pinned > 0 && offline.pinned < offline.total, - }); - } - - *ctl.row_ids.borrow_mut() = ids; - *ctl.row_smart.borrow_mut() = smart; - *ctl.row_has_children.borrow_mut() = has_kids; - *ctl.row_nested.borrow_mut() = nested; - window.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out))); - - // The rows are what `sync_reorderable` reads, so it has to be told they - // changed: a collection that gains a child, or one that is deleted out from - // under the scope, changes whether the grid can be reordered without the - // scope itself moving. - sync_reorderable(window); -} - -/// TRACES: FR-NC-6a | FR-NC-6c -/// How many of a collection's photographs are pinned, and how many there are. -/// -/// Counted over *members*, not distinct images: an image filed in both a parent -/// and a child is counted in each, which is what makes the roll-up below a sum -/// rather than a set union. That inflates the totals of a tree that files the -/// same photograph twice, and it does not matter here — the only questions -/// asked of these numbers are "all of them?" and "none of them?", and both -/// survive the double count. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -struct OfflineCount { - total: usize, - pinned: usize, -} - -/// The offline state of every collection in the tree, descendants included. -/// -/// One query for the direct membership, then a single reverse pass to fold each -/// row into its parent. The alternative — `descendants` plus a count per row — -/// is two queries per collection, and the sidebar rebuilds after every drop, -/// rename and rating change. -/// -/// The reverse pass is correct because `tree` emits parents before children: -/// walking it backwards means every child has already been folded in by the -/// time its parent is reached, however deep the nesting goes. -fn offline_state( - catalog: &Catalog, - rows: &[coll::TreeRow], -) -> std::collections::HashMap { - let mut counts: std::collections::HashMap = - std::collections::HashMap::new(); - - // `tier_actual`, not `pinned`: the question a user is asking of this icon is - // "will these open on the aeroplane", and a pin whose download has not run - // yet answers no (see the note on `tier_actual` in `dr_catalog::cache`). - let sql = "SELECT cm.collection_id, - count(*), - coalesce(sum(CASE WHEN ic.tier_actual >= ?1 THEN 1 ELSE 0 END), 0) - FROM collection_members cm - LEFT JOIN image_cache ic ON ic.image_id = cm.image_id - GROUP BY cm.collection_id"; - - match catalog.connection().prepare(sql).and_then(|mut stmt| { - let rows = stmt.query_map(rusqlite::params![dr_types::Tier::Original.stored()], |r| { - Ok(( - CollectionId(r.get::<_, i64>(0)? as u64), - r.get::<_, i64>(1)? as usize, - r.get::<_, i64>(2)? as usize, - )) - })?; - rows.collect::, _>>() - }) { - Ok(direct) => { - for (id, total, pinned) in direct { - counts.insert(id, OfflineCount { total, pinned }); - } - } - Err(e) => { - // Not fatal: the tray then reads "nothing kept", which is the safe - // direction — it offers a download rather than claiming a trip is - // already on the device. - log::debug!("reading offline state: {e}"); - return counts; - } - } - - for row in rows.iter().rev() { - let Some(parent) = row.collection.parent else { - continue; - }; - let child = counts.get(&row.collection.id).copied().unwrap_or_default(); - if child.total == 0 { - continue; - } - let entry = counts.entry(parent).or_default(); - entry.total += child.total; - entry.pinned += child.pinned; - } - - counts -} - -/// Build the bitmap that travels under the cursor. -/// -/// One image is drawn as itself. Several are **fanned**, back to front with the -/// topmost last, so the cursor carries a visibly thicker stack the more is being -/// dragged — the count is legible from the shape rather than needing a number. -/// -/// Composited here rather than in Slint because `DragArea.drag-image` takes a -/// single bitmap, and Slint cannot render a pile of thumbnails into one. -/// -/// Only the top few are drawn. A forty-image drag would otherwise be forty -/// composites for a stack whose lower layers are hidden by the ones above. -fn compose_drag_image(thumbs: &[slint::Image]) -> slint::Image { - /// Layers drawn, at most. Past this the stack looks no thicker. - const MAX_LAYERS: usize = 4; - /// Pixel step between layers, in the composite's own space. - const FAN: u32 = 10; - /// Long edge of the composed bitmap. - const EDGE: u32 = 160; - - let layers: Vec<&slint::Image> = thumbs.iter().rev().take(MAX_LAYERS).collect(); - let Some(top) = layers.first() else { - return slint::Image::default(); - }; - - // The whole composite is the top image's box plus room for the fan. - let offset = FAN * (layers.len().saturating_sub(1)) as u32; - let size = top.size(); - if size.width == 0 || size.height == 0 { - return slint::Image::default(); - } - // Scale the top thumbnail so its long edge is EDGE, then add the fan. - let scale = EDGE as f32 / size.width.max(size.height) as f32; - let tw = ((size.width as f32 * scale) as u32).max(1); - let th = ((size.height as f32 * scale) as u32).max(1); - - let mut canvas = slint::SharedPixelBuffer::::new(tw + offset, th + offset); - let cw = canvas.width(); - let stride = cw as usize; - let pixels = canvas.make_mut_slice(); - - // Back to front: `layers` is already reversed, so the last drawn is the - // image the user grabbed and it lands on top. - for (n, layer) in layers.iter().enumerate().rev() { - // The furthest-back layer sits at the largest offset, so the stack fans - // down and right from the top image at (0, 0). - let dx = FAN * n as u32; - let dy = FAN * n as u32; - - // Each layer is fitted to the *top* image's box rather than stretched to - // it: a portrait frame behind a landscape one would otherwise be visibly - // distorted, and the stack stops reading as a pile of photographs. - let s = layer.size(); - let (lw, lh) = if s.width == 0 || s.height == 0 { - (tw, th) - } else { - let fit = (tw as f32 / s.width as f32).min(th as f32 / s.height as f32); - ( - ((s.width as f32 * fit) as u32).max(1), - ((s.height as f32 * fit) as u32).max(1), - ) - }; - // Centred in the slot, so a narrower frame is not pinned to one edge. - let cx = dx + (tw - lw.min(tw)) / 2; - let cy = dy + (th - lh.min(th)) / 2; - - blit_scaled(layer, pixels, stride, cx, cy, lw, lh, n > 0); - } - - slint::Image::from_rgba8_premultiplied(canvas) -} - -/// Draw one thumbnail into the composite, scaled to `tw`×`th` at `dx`,`dy`. -/// -/// Nearest-neighbour: this is a transient 160px cursor bitmap, and a filtered -/// resample would cost more than it could visibly buy. `dim` darkens the layers -/// beneath the top one so the stack reads as depth rather than as a smear. -/// -/// The buffer is premultiplied, so the alpha applied here is baked into the -/// colour channels as well. -#[allow(clippy::too_many_arguments)] -fn blit_scaled( - src: &slint::Image, - dst: &mut [slint::Rgba8Pixel], - stride: usize, - dx: u32, - dy: u32, - tw: u32, - th: u32, - dim: bool, -) { - let Some(buf) = src.to_rgba8() else { return }; - let (sw, sh) = (buf.width(), buf.height()); - if sw == 0 || sh == 0 { - return; - } - let src_px = buf.as_slice(); - - for y in 0..th { - let sy = (y * sh / th).min(sh - 1); - for x in 0..tw { - let sx = (x * sw / tw).min(sw - 1); - let s = src_px[(sy * sw + sx) as usize]; - - // Clipped per pixel on both axes. A row-major index alone would let - // an overhanging right edge wrap onto the next line, which draws as - // a smear rather than as an out-of-bounds panic. - let (px, py) = (dx + x, dy + y); - if px as usize >= stride { - continue; - } - let out = py as usize * stride + px as usize; - if out >= dst.len() { - continue; - } - // Layers below the top are darkened, not made transparent: the - // composite sits over whatever is on screen, and translucency there - // would show the desktop through the stack. - let f = if dim { 0.55 } else { 1.0 }; - dst[out] = slint::Rgba8Pixel { - r: (s.r as f32 * f) as u8, - g: (s.g as f32 * f) as u8, - b: (s.b as f32 * f) as u8, - a: s.a, - }; - } - } -} - -/// Mark which cells are currently lifted out by a drag. -/// -/// Separate from [`sync_selection`] because the two differ: the selection -/// persists after the drag, the lift lasts only while it is in flight. -pub fn sync_lifted(window: &AppWindow, lifted: &[ImageId], ids: &[ImageId]) { - let model = window.get_library_cells(); - for (row, id) in ids.iter().enumerate() { - let want = lifted.contains(id); - if let Some(mut cell) = model.row_data(row) { - if cell.lifted != want { - cell.lifted = want; - model.set_row_data(row, cell); - } - } - } -} - -/// Push the current selection into the grid model's `selected` flags. -/// -/// The model carries the flag per cell so Slint can style without a lookup; -/// this is what keeps the two in step after a scrub, a drop, or a click. -pub fn sync_selection(window: &AppWindow, ctl: &Rc, ids: &[ImageId]) { - let selection = ctl.selection.borrow(); - let model = window.get_library_cells(); - - // The anchor is deliberately **not** pushed to the grid. - // - // It used to be, and it was drawn as a thin ring inside the cell. Two - // things were wrong with that. It was the clearest mark on the cell, so it - // read as *the* selection to anyone who had not written it; and an anchor - // outlives a deselection, so the ring sat around the last photograph - // touched while nothing was selected at all — indistinguishable from a cell - // that had stayed behind. - // - // The ordinal still lives in the controller and still decides where a range - // extends from. What is gone is the claim that the user needs to see it: - // the selection bar says "Tap the last photograph" while a range is armed, - // which answers the question the ring was there to answer. - for (row, id) in ids.iter().enumerate() { - let want = selection.contains(id); - if let Some(mut cell) = model.row_data(row) { - if cell.selected != want { - cell.selected = want; - model.set_row_data(row, cell); - } - } - } - - window.set_library_selected_count(selection.len() as i32); -} - -/// TRACES: FR-CAT-7 -/// Whether what the grid is showing has an order the user can change. -/// -/// A single manual collection: not the whole library, not a saved filter whose -/// membership is a rule, and not a set — a set draws its descendants' images -/// too, and two children's `position` columns are unrelated integers that would -/// interleave arbitrarily. -/// -/// Read off the tree rows the sidebar already draws rather than asked of the -/// catalog. `smart` and `has_children` are the only two facts it needs and both -/// are in the model already, so this cannot disagree with what the user is -/// looking at, and a scope change costs no extra query. -fn sync_reorderable(window: &AppWindow) { - let id = window.get_collection_selected(); - let rows = window.get_collection_rows(); - let manual = id > 0 - && (0..rows.row_count()) - .filter_map(|i| rows.row_data(i)) - .find(|r| r.id == id) - .is_some_and(|r| !r.smart && !r.has_children); - window.set_library_reorderable(manual); -} - -/// Refresh the per-cell "in this many collections" badges. -/// -/// One query for the whole window rather than one per cell: 120 cells is 120 -/// round trips otherwise, on every drop. -pub fn sync_badges(window: &AppWindow, catalog: &Catalog, ids: &[ImageId]) { - if ids.is_empty() { - return; - } - - let placeholders = std::iter::repeat_n("?", ids.len()) - .collect::>() - .join(","); - let sql = format!( - "SELECT m.image_id, count(*) - FROM collection_members m - JOIN collections c ON c.id = m.collection_id - WHERE m.image_id IN ({placeholders}) AND c.deleted = 0 - GROUP BY m.image_id" - ); - - let params: Vec = ids - .iter() - .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) - .collect(); - - let mut counts = std::collections::HashMap::new(); - if let Ok(mut stmt) = catalog.connection().prepare(&sql) { - if let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { - Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)) - }) { - for (id, n) in rows.flatten() { - counts.insert(id, n as i32); - } - } - } - - let model = window.get_library_cells(); - for (row, id) in ids.iter().enumerate() { - let want = counts.get(&(id.0 as i64)).copied().unwrap_or(0); - if let Some(mut cell) = model.row_data(row) { - if cell.collection_count != want { - cell.collection_count = want; - model.set_row_data(row, cell); - } - } - } -} - -/// How long the pointer must dwell on a collapsed parent before it springs -/// open, mid-drag. -/// -/// Long enough that crossing a parent on the way somewhere else does not open -/// it — a tree that flaps open under every passing pointer is worse than one -/// that never opens. Short enough to feel like a response rather than a wait; -/// this is the range file managers have settled on for the same gesture. -const SPRING_DELAY_MS: u64 = 500; - -/// TRACES: FR-UI-2 | FR-UI-4 -/// How long a cell must be held before the grid enters selection mode. -/// -/// The platform convention, and the reason to match it rather than pick: every -/// gallery on the device this exists for opens a selection on a hold of about -/// this length, so a user who has never read a word about DarkRoom already -/// knows the gesture. Shorter and a slow tap becomes a selection; longer and -/// the hand lets go first, having concluded nothing was going to happen. -pub(crate) const HOLD_DELAY_MS: u64 = 450; - -/// TRACES: FR-UI-2 | FR-UI-4 -/// Start the timer that turns a held cell into a selection. -/// -/// Restarted per press, and cancelled by the release — see `hold_timer`. The -/// press that armed it has *already* selected the cell under the finger, so -/// what firing adds is the mode: from here taps toggle rather than open, and -/// the header's buttons appear to act on what has been gathered. -fn arm_hold(window: &AppWindow, ctl: &Rc, row: i32) { - let timer = slint::Timer::default(); - let weak = window.as_weak(); - let ctl_cb = ctl.clone(); - - timer.start( - slint::TimerMode::SingleShot, - std::time::Duration::from_millis(HOLD_DELAY_MS), - move || { - let Some(w) = weak.upgrade() else { return }; - - // TRACES: FR-CAT-7 - // The photograph is in the user's hand: the grid draws a ring - // opening around it and stops scrolling underneath it, so the drag - // that may follow cannot be lost to a flick. See `held-row` in - // `library.slint`. - // - // This half happens whether or not selection mode was already on — - // it is the half a *drag* needs, and a drag out of a selection of - // forty starts in a mode that is already on. - w.set_library_held_row(row); - - if !ctl_cb.select_mode.get() { - ctl_cb.select_mode.set(true); - w.set_library_select_mode(true); - } - - // The release that follows this hold must not also open the image: - // the user asked for a selection and would land in develop instead. - // Reusing `modified_press` rather than adding a second flag — the - // click already consults it, and it means exactly this: "the press - // was a selection gesture". - ctl_cb.modified_press.set(true); - }, - ); - - *ctl.hold_timer.borrow_mut() = Some(timer); -} - -/// Whether hovering this row should schedule a spring expansion. -/// -/// Pure so the rule is testable: only a *collapsed parent* has anything to -/// open. A leaf would flash a pointless rebuild, and one already expanded is -/// where the user can already see the children. -fn should_spring( - row: Option, - row_ids: &[CollectionId], - row_has_children: &[bool], - collapsed: &std::collections::HashSet, -) -> Option { - let row = row?; - let &id = row_ids.get(row)?; - let has_children = row_has_children.get(row).copied().unwrap_or(false); - (has_children && collapsed.contains(&id)).then_some(id) -} - -/// Start (or restart) the dwell timer that opens a collapsed collection. -/// -/// Called on every hover change during a drag. Restarting on each change is -/// what makes the dwell a dwell: moving to another row cancels the pending -/// expansion instead of queueing a second one. -fn arm_spring( - window: &AppWindow, - ctl: &Rc, - catalog: &Rc>>, - row: Option, -) { - // Dropping the old timer cancels it. Anything already scheduled for the row - // the pointer has just left must not fire. - *ctl.spring_timer.borrow_mut() = None; - - let Some(row) = row else { return }; - - // Only a collapsed parent has anything to spring. A leaf, or one already - // open, is left alone rather than being pointlessly "expanded". - let target = should_spring( - Some(row), - &ctl.row_ids.borrow(), - &ctl.row_has_children.borrow(), - &ctl.collapsed.borrow(), - ); - let Some(id) = target else { return }; - - let timer = slint::Timer::default(); - let weak = window.as_weak(); - let ctl_cb = ctl.clone(); - let catalog = catalog.clone(); - - timer.start( - slint::TimerMode::SingleShot, - std::time::Duration::from_millis(SPRING_DELAY_MS), - move || { - let Some(w) = weak.upgrade() else { return }; - // The drag may have ended, or moved on, during the dwell. - // Expanding then would rearrange the sidebar for no reason the user - // can connect to what they did. `dragging` being non-empty *is* the - // "a drag is live" test — Slint owns the gesture now, so there is no - // window flag to consult. - if ctl_cb.dragging.borrow().is_empty() || *ctl_cb.hover_id.borrow() != Some(id) { - return; - } - - ctl_cb.collapsed.borrow_mut().remove(&id); - // Remembered so it can be closed again if the drag ends elsewhere. - ctl_cb.spring_opened.borrow_mut().push(id); - - let borrow = catalog.borrow(); - if let Some(cat) = borrow.as_ref() { - // The rebuild inserts the children below this row. The pointer - // is still over this same collection, and its own `DropArea` - // re-establishes the highlight — there is no index to re-point, - // which is the second thing the native drag API removed. - refresh_tree(&w, &ctl_cb, cat); - } - }, - ); - - *ctl.spring_timer.borrow_mut() = Some(timer); -} - -/// Close whatever the spring opened during a drag that did not land in it. -/// -/// A collection the user dropped into stays open — they are working in it. One -/// merely passed over is put back, so a drag across a deep tree does not leave -/// it unfolded. -fn collapse_spring_opened( - window: &AppWindow, - ctl: &Rc, - catalog: &Rc>>, - keep: Option, -) { - *ctl.spring_timer.borrow_mut() = None; - let opened = std::mem::take(&mut *ctl.spring_opened.borrow_mut()); - if opened.is_empty() { - return; - } - - { - let mut collapsed = ctl.collapsed.borrow_mut(); - for id in opened { - // The collection dropped into stays open, and so does every - // ancestor of it — closing a parent would hide the very row that - // just received the images. - let keep_this = keep.is_some_and(|k| { - k == id - || catalog - .borrow() - .as_ref() - .and_then(|cat| coll::descendants(cat.connection(), id).ok()) - .is_some_and(|d| d.contains(&k)) - }); - if !keep_this { - collapsed.insert(id); - } - } - } - - let borrow = catalog.borrow(); - if let Some(cat) = borrow.as_ref() { - refresh_tree(window, ctl, cat); - } -} - -/// Begin a soft delete: plan the moves, then hand them to a worker. -/// -/// The plan is built here because it reads the catalog, which is not `Send`; the -/// worker gets paths and ids and needs no catalog to do its half. -#[allow(clippy::too_many_arguments)] -fn start_trash( - window: &AppWindow, - ctl: &Rc, - catalog: &Rc>>, - session: &Rc Option>, - images: &[ImageId], - reload: &Rc, -) { - let Some(conn) = session() else { - window.set_collection_error("Open a library first.".into()); - return; - }; - - let moves = { - let borrow = catalog.borrow(); - let Some(cat) = borrow.as_ref() else { return }; - match crate::trash::plan_trash(cat, &conn.account.root, images) { - Ok(m) => m, - Err(e) => { - window.set_collection_error(format!("planning delete: {e}").into()); - return; - } - } - }; - - if moves.is_empty() { - return; - } - - log::info!("moving {} image(s) to the trash", moves.len()); - window.set_library_status(format!("Moving {} to the trash…", moves.len()).into()); - // The images are leaving the grid; a selection pointing at them would - // survive as a set of ids the user can no longer see. - ctl.clear_selection(); - - let count = moves.len(); - let rx = crate::trash::spawn_move( - conn.clone(), - moves, - crate::trash::Direction::ToTrash, - crate::library::catalog_path(&conn.account), - ); - - drain_trash( - window.as_weak(), - ctl.clone(), - catalog.clone(), - rx, - reload.clone(), - format!("Moving {count} photograph(s) to the trash"), - ); -} - -/// TRACES: FR-CAT-15 -/// Put trashed images back where they came from. -/// -/// The mirror of [`start_trash`], and separate from it rather than a `direction` -/// parameter on one function: the two differ in what they plan, what they report -/// and what they say when the plan comes back empty, and the shared part is the -/// three lines that spawn the worker. -/// -/// An image whose origin was never recorded is skipped by -/// [`crate::trash::plan_restore`] rather than guessed at. That can make the plan -/// shorter than the selection, which is why an empty plan is reported here -/// instead of returning silently — the user pressed a button and is owed an -/// answer either way. -fn start_restore( - window: &AppWindow, - ctl: &Rc, - catalog: &Rc>>, - session: &Rc Option>, - images: &[ImageId], - reload: &Rc, -) { - let Some(conn) = session() else { - window.set_collection_error("Open a library first.".into()); - return; - }; - - let moves = { - let borrow = catalog.borrow(); - let Some(cat) = borrow.as_ref() else { return }; - match crate::trash::plan_restore(cat, images) { - Ok(m) => m, - Err(e) => { - window.set_collection_error(format!("planning restore: {e}").into()); - return; - } - } - }; - - if moves.is_empty() { - // Said out loud rather than passed over in silence: a button that does - // nothing visible reads as broken, and the reason here is specific. - window.set_collection_error( - "Nothing to restore — no record of where these came from.".into(), - ); - return; - } - - log::info!("restoring {} image(s) from the trash", moves.len()); - window.set_library_status(format!("Restoring {}…", moves.len()).into()); - // The images are leaving the trash view, so a selection pointing at them - // would survive as ids the user can no longer see. - ctl.clear_selection(); - - let count = moves.len(); - let rx = crate::trash::spawn_move( - conn.clone(), - moves, - crate::trash::Direction::Restore, - crate::library::catalog_path(&conn.account), - ); - - drain_trash( - window.as_weak(), - ctl.clone(), - catalog.clone(), - rx, - reload.clone(), - format!("Restoring {count} photograph(s)"), - ); -} - -/// Drain a trash worker on the UI thread. -/// -/// Same shape as the scan and thumbnail drains: an mpsc channel polled by a -/// Slint timer, so nothing blocks the event loop (NFR-P9). -fn drain_trash( - weak: slint::Weak, - ctl: Rc, - catalog: Rc>>, - rx: std::sync::mpsc::Receiver, - reload: Rc, - // What the register calls this operation. Passed in rather than derived - // here: the three callers move files to the trash, back out of it, and - // delete them outright, and "Deleting 40 photographs" is the one word of - // the three that must not appear over a restore. - title: String, -) { - use crate::trash::TrashMessage; - - let timer = slint::Timer::default(); - let ctl_cb = ctl.clone(); - - // A server-side MOVE per file, so it is a transfer in the sense that - // matters: it takes as long as the connection is slow, and it can fail - // halfway with the library in two states at once. - let job = ctl.activity.begin(crate::activity::Kind::Trash, title); - - timer.start( - slint::TimerMode::Repeated, - std::time::Duration::from_millis(120), - 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) => { - // A worker that died without reporting must not leave the - // status line mid-sentence. - job.fail("stopped without finishing"); - stop_trash(&ctl_cb); - return; - } - }; - - match msg { - TrashMessage::Progress { - done, - total, - failed, - } => { - let status = if failed > 0 { - format!("{done} / {total} · {failed} failed") - } else { - format!("{done} / {total}") - }; - job.progress(done, total); - if failed > 0 { - job.detail(format!("{failed} failed")); - } - w.set_library_status(status.into()); - } - TrashMessage::Done { moved, failed } => { - // Reported honestly, including the partial case: "38 of - // 40" is the truth when two files could not be moved, - // and claiming 40 would hide a real problem. - let status = if failed.is_empty() { - format!("{moved} image(s) done") - } else { - format!("{moved} done · {} failed", failed.len()) - }; - // A partial failure is a failure in the register: the - // library is now in two states at once, which is - // exactly the thing worth keeping on the list. - if failed.is_empty() { - job.finish(status.clone()); - } else { - job.fail(status.clone()); - } - w.set_library_status(status.into()); - if let Some(first) = failed.first() { - w.set_collection_error(first.as_str().into()); - } - - let borrow = catalog.borrow(); - if let Some(cat) = borrow.as_ref() { - refresh_trash(&w, cat); - refresh_tree(&w, &ctl_cb, cat); - } - drop(borrow); - - // The grid changed: images left the library, or came - // back into it. - reload(); - stop_trash(&ctl_cb); - return; - } - } - } - }, - ); - - *ctl.trash_timer.borrow_mut() = Some(timer); -} - -fn stop_trash(ctl: &Rc) { - if let Some(t) = ctl.trash_timer.borrow().as_ref() { - t.stop(); - } -} - -/// Refresh the sidebar's trash count and size. -/// -/// The size is formatted here rather than in Slint, which has no byte-size -/// formatting — and the number is what tells the user whether emptying is worth -/// it. -pub fn refresh_trash(window: &AppWindow, catalog: &Catalog) { - let (n, bytes) = dr_catalog::trash::summary(catalog.connection()).unwrap_or((0, 0)); - window.set_trash_count(n as i32); - window.set_trash_label(if n == 0 { - slint::SharedString::new() - } else { - format!("{n} · {}", format_bytes(bytes)).into() - }); -} - -/// Bytes as a human-readable size. -/// -/// Binary units, one decimal place past a kilobyte: a RAW library is measured in -/// gigabytes and "3.4 GB" is the figure a photographer reasons about, where -/// 3_650_722_201 is not. -fn format_bytes(bytes: u64) -> String { - const KB: f64 = 1024.0; - let b = bytes as f64; - if bytes < 1024 { - return format!("{bytes} B"); - } - for (limit, unit) in [ - (KB * KB, "kB"), - (KB * KB * KB, "MB"), - (KB * KB * KB * KB, "GB"), - ] { - if b < limit { - return format!("{:.1} {unit}", b / (limit / KB)); - } - } - format!("{:.1} TB", b / (KB * KB * KB * KB)) -} - -/// Connect the sidebar and drag callbacks. -/// -/// `on_scope_changed` reloads the grid — that lives in [`crate::library_ui`], -/// which owns the window and the thumbnail workers, so it is passed in rather -/// than reached for. -#[allow(clippy::too_many_arguments)] -pub fn wire( - window: &AppWindow, - ctl: Rc, - catalog: Rc>>, - on_scope_changed: S, - visible_ids: R, - span_ids: P, - session: C, -) where - S: Fn() + 'static, - R: Fn() -> Vec + 'static, - P: Fn(usize, usize) -> Vec + 'static, - C: Fn() -> Option + 'static, -{ - // Coerced to trait objects here rather than at each use: each is shared by - // several of the functions below, and a generic parameter would make every - // one of them a separate instantiation for no gain. - let on_scope_changed: Rc = Rc::new(on_scope_changed); - let visible_ids: Rc Vec> = Rc::new(visible_ids); - // A shift-click asks the catalog what lies between its two ends, and the - // catalog belongs to the grid's controller — see `span_source`. - *ctl.span_source.borrow_mut() = Some(Rc::new(span_ids)); - let session: Rc Option> = Rc::new(session); - - wire_selection(window, &ctl, &catalog, &visible_ids, &on_scope_changed); - wire_selection_filing(window, &ctl, &catalog, &visible_ids, &on_scope_changed); - wire_drag( - window, - &ctl, - &catalog, - &visible_ids, - &on_scope_changed, - &session, - ); - wire_trash(window, &ctl, &catalog, &session, &on_scope_changed); - wire_trash_from_grid( - window, - &ctl, - &catalog, - &session, - &on_scope_changed, - &visible_ids, - ); - wire_tree_navigation(window, &ctl, &catalog, &on_scope_changed); - wire_create(window, &ctl, &catalog); - wire_rename(window, &ctl, &catalog); - wire_remove(window, &ctl, &catalog, &visible_ids, &on_scope_changed); - wire_row_drag(window, &ctl, &catalog); - wire_row_menu(window, &ctl, &catalog, &on_scope_changed, &visible_ids); - wire_membership(window, &ctl, &catalog, &visible_ids, &on_scope_changed); -} - -/// Selection. -fn wire_selection( - window: &AppWindow, - ctl: &Rc, - catalog: &Rc>>, - visible_ids: &Rc Vec>, - on_scope_changed: &Rc, -) { - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let visible = visible_ids.clone(); - window.on_library_cell_pressed(move |row, ctrl_held, shift_held| { - let Some(w) = weak.upgrade() else { return }; - let ids = visible(); - - // Consulted by the click that follows: a modified press is building - // a selection and must not also navigate to develop. - ctl.modified_press.set(ctrl_held || shift_held); - - // Where the window starts, so the press is recorded as the ordinal - // it is rather than as a row that stops meaning this photograph on - // the next scroll. - let offset = w.get_library_offset().max(0) as usize; - select_row(&w, &ctl, &ids, offset, row as usize, ctrl_held, shift_held); - - // TRACES: FR-UI-2 | FR-UI-4 | FR-CAT-7 - // And start counting, in case this press is a hold. The press has - // already selected this one cell; what the hold adds is the *mode*, - // so the taps that follow go on selecting instead of opening the - // next photograph the user touches — and the *pick-up*, which is - // what makes the drag reliable. - // - // Armed even when the mode is already on, which it did not used to - // be: there was nothing left for the hold to switch on, so it was - // skipped. But the mode being on is exactly the state a - // multi-image drag starts from, and skipping the hold left that - // drag with no pick-up and no cue — the one gesture that most - // needed both. See `arm_hold`. - arm_hold(&w, &ctl, row); - }); - } - - // TRACES: FR-UI-2 | FR-UI-4 - // The press ended — lifted, or taken by the Flickable when the finger - // travelled. Either way the hold is off. - { - let ctl = ctl.clone(); - window.on_library_cell_press_ended(move || { - *ctl.hold_timer.borrow_mut() = None; - }); - } - - // TRACES: FR-CAT-5 - // Put the selection down without leaving select mode. - // - // Distinct from "Done", which does both: after picking forty photographs - // and filing them, the next forty start with the mode already on, and - // making the user re-enter it is asking them to undo a step they did not - // take. It is also the only way back from a mis-tap that is not tapping - // every one of them again. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let visible = visible_ids.clone(); - window.on_library_clear_selection(move || { - let Some(w) = weak.upgrade() else { return }; - ctl.clear_selection(); - sync_selection(&w, &ctl, &visible()); - }); - } - - // TRACES: FR-CAT-7 - // Drag a photograph, or a whole selection of them, to a new place in the - // collection being shown. - // - // `collection_members.position` and `Sort::CollectionPosition` have been in - // the catalog since collections were, and until now nothing above it ever - // wrote or read them — the grid ordered by capture time whatever it was - // scoped to. `library::grid_order_for` reads them; this writes them. - // - // The membership is rewritten whole rather than patched. `set_order` sets - // the positions it is given and leaves the rest, so a partial write would - // interleave the moved run with rows whose positions nobody touched — and - // it is read unfiltered for the same reason: the user reorders what they - // can see, and what the filter is hiding keeps its place relative to it. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let catalog = catalog.clone(); - let visible = visible_ids.clone(); - let reload = on_scope_changed.clone(); - window.on_library_reorder_to(move |row, after| { - let Some(w) = weak.upgrade() else { return }; - // Both guards belong here rather than only in `library.slint`: the - // scope can change between the drag starting and the drop landing. - if !w.get_library_reorderable() { - return; - } - let Some(scope) = *ctl.scope.borrow() else { - return; - }; - - let moving = ctl.selected(); - if moving.is_empty() { - return; - } - - let ids = visible(); - let Some(&target) = usize::try_from(row).ok().and_then(|r| ids.get(r)) else { - return; - }; - - // Dropped on one of its own. There is no gap between a run and - // itself to land in, and rewriting the whole membership to say so - // would be a revision bump for no change. - if moving.contains(&target) { - return; - } - - let borrow = catalog.borrow(); - let Some(cat) = borrow.as_ref() else { return }; - - let current = match library::read_member_order(cat, scope) { - Ok(current) => current, - Err(e) => { - w.set_collection_error(format!("reading the collection's order: {e}").into()); - return; - } - }; - let wanted = library::reordered(¤t, &moving, target, after); - - match coll::set_order(cat.connection(), scope, &wanted) { - Ok(()) => { - w.set_collection_error(slint::SharedString::new()); - w.set_library_status( - format!( - "{} photograph{} moved", - moving.len(), - if moving.len() == 1 { "" } else { "s" } - ) - .into(), - ); - drop(borrow); - - // The selection survives. It is what was just moved, and - // dropping it would make a second nudge — which is how a - // drag-to-reorder is usually corrected — start over. - reload(); - sync_selection(&w, &ctl, &visible()); - } - Err(e) => { - drop(borrow); - w.set_collection_error(format!("reordering: {e}").into()); - } - } - }); - } - - // TRACES: FR-CAT-5 | FR-UI-4 - // Everything the grid is showing. - // - // Asked of the catalog through `span`, not read off the loaded window, for - // the reason spelled out in `apply_press`: the window is a hundred cells - // over a library of thousands, and a "select all" that quietly meant - // "select the hundred that happen to be loaded" is a lie the user cannot - // see until the export runs. `library_total` is the count the same scope - // and filter produced, so the run is the whole of what the grid claims. - // - // Replaces rather than adds. "All" is a statement about the result, not an - // increment, and a user who wanted the rest kept would not have reached for - // this. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let visible = visible_ids.clone(); - window.on_library_select_all(move || { - let Some(w) = weak.upgrade() else { return }; - let ids = visible(); - let total = w.get_library_total().max(0) as usize; - if total == 0 { - return; - } - - // The loaded window is the fallback, not the answer — the same - // trade the shift-click path makes. An empty span means no library - // or a failed query, and selecting what is on screen beats - // selecting nothing. - let all = match ctl.span(0, total - 1) { - run if run.is_empty() => ids.clone(), - run => run, - }; - - { - let mut selection = ctl.selection.borrow_mut(); - selection.clear(); - selection.extend(all); - } - - // The first frame becomes the anchor, so a "Select to…" straight - // afterwards describes a range from the top rather than from - // wherever the last individual tap left it — which, after taking - // everything, is not a place the user is still thinking about. - *ctl.anchor.borrow_mut() = Some(0); - sync_selection(&w, &ctl, &ids); - }); - } -} - -/// Selection, continued: turning it into a collection, toggling the -/// mode, and filing it into one that already exists. -fn wire_selection_filing( - window: &AppWindow, - ctl: &Rc, - catalog: &Rc>>, - visible_ids: &Rc Vec>, - on_scope_changed: &Rc, -) { - // TRACES: FR-CAT-5 - // A collection holding exactly what is selected. - // - // This was four steps — make a collection, find it in the tree, select the - // photographs again because creating one changed the scope, then add them - // — and the selection is usually made *because* it is going somewhere. One - // press instead. - // - // Created at the top level rather than inside the current scope, unlike - // the tree's "+". A selection can be gathered from anywhere, including - // across collections, so filing it under whichever one happens to be open - // would put it somewhere its contents did not come from. - // - // The name arrives already chosen. It used to be a placeholder, with the - // sidebar's rename field opened straight afterwards to correct it — which - // on a tablet meant opening a field inside a panel that is instantiated but - // not drawn, so it took the on-screen keyboard and could never give it - // back. `library.slint`'s naming sheet asks first, and nothing reaches the - // catalog until it is answered. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let catalog = catalog.clone(); - let visible = visible_ids.clone(); - let reload = on_scope_changed.clone(); - window.on_library_collection_from_selection(move |name| { - let Some(w) = weak.upgrade() else { return }; - let images = ctl.selected(); - if images.is_empty() { - return; - } - - let borrow = catalog.borrow(); - let Some(cat) = borrow.as_ref() else { - w.set_collection_error("Open a library first.".into()); - return; - }; - - // The sheet refuses an empty name in two places, so this is - // belt-and-braces rather than a path the UI can reach — but a - // collection with no name at all is unfindable in the tree, and - // falling back to the placeholder is recoverable where an empty - // row is not. - let name = match name.trim() { - "" => unique_name(cat.connection(), None), - typed => typed.to_string(), - }; - - // The id is not carried out. It used to be, to open the rename - // field on the row it names; now the name is already right, and - // `refresh_tree` redraws the tree from the catalog either way. - let made = coll::create(cat.connection(), &name, None, CollectionKind::Manual) - .and_then(|id| coll::add_images(cat.connection(), id, &images)); - - match made { - Ok(added) => { - w.set_collection_error(slint::SharedString::new()); - w.set_library_status( - format!( - "{added} photograph{} in {name}", - if added == 1 { "" } else { "s" } - ) - .into(), - ); - refresh_tree(&w, &ctl, cat); - drop(borrow); - - // The selection has been filed; holding on to it invites - // the next press acting on photographs the user considers - // dealt with. - ctl.clear_selection(); - sync_selection(&w, &ctl, &visible()); - - // No rename opened here. The name was chosen before the - // collection existed, so there is nothing left to correct — - // and opening the sidebar's field is what stranded the - // keyboard on a tablet. - reload(); - } - Err(e) => { - drop(borrow); - w.set_collection_error(format!("making a collection: {e}").into()); - } - } - }); - } - - // TRACES: FR-UI-2 | FR-UI-4 - // The button half of selection mode. The long press is faster and this is - // the one that can be found — a gesture with no visible counterpart is a - // feature only its author knows about. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let visible = visible_ids.clone(); - window.on_library_toggle_select_mode(move || { - let Some(w) = weak.upgrade() else { return }; - let on = !ctl.select_mode.get(); - ctl.select_mode.set(on); - w.set_library_select_mode(on); - *ctl.hold_timer.borrow_mut() = None; - - // Leaving the mode drops the selection. "Done" reads as finishing - // with these photographs, and a selection that outlived the mode - // would still be acted on by the buttons in the header — which is - // how forty images get exported by a user who thought they had put - // them down. - if !on { - ctl.clear_selection(); - sync_selection(&w, &ctl, &visible()); - } - }); - } - - // TRACES: FR-CAT-7 | FR-UI-4 - // File the selection in a collection without dragging it there. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let catalog = catalog.clone(); - let visible = visible_ids.clone(); - let reload = on_scope_changed.clone(); - window.on_library_file_in_collection(move |id, moves| { - let Some(w) = weak.upgrade() else { return }; - let target = CollectionId(id as u64); - let chosen = ctl.selected(); - if chosen.is_empty() { - return; - } - - let borrow = catalog.borrow(); - let Some(cat) = borrow.as_ref() else { return }; - - // Moving is only a move when there is somewhere to move *from*. - // Unscoped, the sheet does not offer it; this guards the callback - // anyway, and degrades to filing rather than refusing. - let from = if moves { *ctl.scope.borrow() } else { None }; - let result = match from { - Some(from) => coll::move_images(cat.connection(), from, target, &chosen), - None => coll::add_images(cat.connection(), target, &chosen), - }; - - match result { - Ok(filed) => { - w.set_collection_error(slint::SharedString::new()); - // The honest count, as the drop reports it: claiming all - // twelve when nine were already there teaches the user to - // distrust every number the app shows them. - let msg = if from.is_some() { - format!("Moved {filed} of {}", chosen.len()) - } else if filed == chosen.len() { - format!("Added {filed} to collection") - } else { - format!( - "Added {filed} of {} — the rest were already there", - chosen.len() - ) - }; - w.set_library_status(msg.into()); - - refresh_tree(&w, &ctl, cat); - sync_badges(&w, cat, &visible()); - // A move changes what the collection on screen holds, so - // the grid has to be reread; filing elsewhere does not, but - // the badge count on every cell just changed and the reread - // is one query. - drop(borrow); - reload(); - } - Err(e) => w.set_collection_error(format!("filing: {e}").into()), - } - }); - } -} - -/// Drag. -/// -/// Slint owns the gesture (see the preamble). What is left here is the -/// payload — the image ids the drop will act on — and the spring. -fn wire_drag( - window: &AppWindow, - ctl: &Rc, - catalog: &Rc>>, - visible_ids: &Rc Vec>, - on_scope_changed: &Rc, - session: &Rc Option>, -) { - // TRACES: FR-CAT-7 - // **What arms the drag, not what it carries.** - // - // `DragArea` declines to start a drag while its `data` is empty, and it - // tests that on every pointer event that reaches it — the first one - // included, which arrives long before any drag. The binding in - // `library.slint` calls this callback, and a binding that calls a callback - // has nothing Slint can invalidate it on: it is evaluated once, when a - // finger first lands on that cell, and cached. `dragging` is empty at that - // moment and stays empty until `drag-started` fires. - // - // So this is answered at the wrong time, and always will be. That is - // harmless only because **`set_user_data` is called unconditionally**: an - // empty `Vec` is still user data, so the transfer is never `is_empty()` and - // the `DragArea` stays armed. Skipping the call for an empty selection — - // which looks like an obvious tidy-up — would disarm every cell a finger - // had ever touched outside a drag, and dragging would simply stop working - // with nothing to see. - // - // What the drop actually reads is `dragging`, set by `drag-started` and - // read back by `dropped-on`. `user_data` rather than plain text so that - // nothing outside the application can interpret it as a paste. - { - let ctl = ctl.clone(); - window.on_library_drag_payload(move || { - let carried = ctl.dragging.borrow().clone(); - let mut data = slint::DataTransfer::default(); - data.set_user_data(Rc::new(carried)); - data - }); - } - - // Dragging a collection row. The id travels in the payload for anything - // that cares to read it; the drop itself uses the remembered press, since - // it is handed only the target. - { - window.on_collection_drag_payload(move |id| { - let mut data = slint::DataTransfer::default(); - data.set_user_data(Rc::new(CollectionId(id as u64))); - data - }); - } - - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let visible = visible_ids.clone(); - window.on_library_drag_started(move |row| { - let Some(w) = weak.upgrade() else { return }; - let ids = visible(); - - // TRACES: FR-UI-4 - // A drag is not a hold, and the timer must not outlive the press - // that armed it. Grabbing a cell and moving inside 450 ms left the - // hold armed underneath the drag, so it fired mid-gesture and put - // the grid into selection mode the user had not asked for — the - // drag finished into a mode that changed what every later tap - // meant. The pinch already cancels for exactly this reason. - *ctl.hold_timer.borrow_mut() = None; - - // TRACES: FR-CAT-7 - // And this press was not a tap, so it never gets to take anything - // out of the selection — see [`Press::Deferred`]. Dropped here as - // well as on the next press because the drag reads the selection - // one line below, and a removal still pending would be a - // photograph the user can see is selected and the drop would not - // carry. - ctl.pending_toggle.set(None); - - // Dragging an *unselected* cell carries only that one, and makes it - // the selection — otherwise the images that travel are not the ones - // the user grabbed. Dragging a selected cell carries the whole - // selection, which is the multi-image gesture. - let carried: Vec = { - let mut selection = ctl.selection.borrow_mut(); - match ids.get(row as usize) { - Some(id) if !selection.contains(id) => { - selection.clear(); - selection.insert(*id); - vec![*id] - } - _ => selection.iter().copied().collect(), - } - }; - - // The bitmap under the cursor, built from the thumbnails already in - // the model — the drag carries what the user can see, and a cell - // whose preview has not landed yet contributes nothing rather than - // a placeholder. - let thumbs: Vec = { - let model = w.get_library_cells(); - carried - .iter() - .filter_map(|id| ids.iter().position(|v| v == id)) - .filter_map(|row| model.row_data(row)) - .filter(|c| c.has_thumb) - .map(|c| c.thumbnail) - .collect() - }; - w.set_library_drag_image(compose_drag_image(&thumbs)); - - *ctl.dragging.borrow_mut() = carried.clone(); - sync_selection(&w, &ctl, &ids); - // The lift-out: these cells fade and shrink in place, so the grid - // shows where the photographs came from while the cursor shows them - // in full colour. - sync_lifted(&w, &carried, &ids); - }); - } - - // A drag dwelling over a collapsed collection springs it open, so a nested - // child can be reached without putting the images down first. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let catalog_for_spring = catalog.clone(); - window.on_collection_drag_over(move |id, over| { - let Some(w) = weak.upgrade() else { return }; - let id = CollectionId(id as u64); - - if !over { - // Left this row. Cancel its pending expansion rather than - // letting it fire over whatever the pointer moved on to. - if *ctl.hover_id.borrow() == Some(id) { - *ctl.hover_id.borrow_mut() = None; - *ctl.spring_timer.borrow_mut() = None; - } - return; - } - - *ctl.hover_id.borrow_mut() = Some(id); - let row = ctl.row_ids.borrow().iter().position(|&c| c == id); - arm_spring(&w, &ctl, &catalog_for_spring, row); - }); - } - - // A drop. Slint hit-tested the release and `can-drop` already refused a - // saved filter, so reaching here means this collection accepted. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let catalog = catalog.clone(); - // No model refresh or reload here on purpose — see the note at the end - // of this handler. `drag-finished` owns those. - window.on_collection_dropped(move |id| { - let Some(w) = weak.upgrade() else { return }; - let id = CollectionId(id as u64); - let carried = ctl.dragging.borrow().clone(); - // Taken, not read: a remembered press must be spent by the drop it - // belongs to, or a later empty drop — a file dragged in from - // outside, say — would move a collection nobody touched. - let pressed = ctl.dragging_collection.borrow_mut().take(); - - let carried = match decide_drop(&carried, pressed, id) { - Drop::Nothing => return, - Drop::Reparent(source) => { - let result = { - let borrow = catalog.borrow(); - let Some(cat) = borrow.as_ref() else { return }; - coll::set_parent(cat.connection(), source, Some(id)) - }; - match result { - Ok(()) => { - w.set_collection_error(slint::SharedString::new()); - w.set_library_status("Moved collection".into()); - let borrow = catalog.borrow(); - if let Some(cat) = borrow.as_ref() { - refresh_tree(&w, &ctl, cat); - } - } - // The catalog refuses a cycle rather than letting the - // tree walk spin. Saying so is better than a drop that - // silently does nothing. - Err(e) => w.set_collection_error(format!("moving collection: {e}").into()), - } - return; - } - Drop::FileImages(images) => images, - }; - - // Scoped so the borrow is released before the spring cleanup below, - // which needs the catalog itself. - let result = { - let borrow = catalog.borrow(); - let Some(cat) = borrow.as_ref() else { return }; - coll::add_images(cat.connection(), id, &carried) - }; - - match result { - Ok(added) => { - w.set_collection_error(slint::SharedString::new()); - // "Added 3 of 12" is the honest report when nine were - // already there; claiming 12 would teach the user to - // distrust the count. - let msg = if added == carried.len() { - format!("Added {added} to collection") - } else { - format!( - "Added {added} of {} — the rest were already there", - carried.len() - ) - }; - w.set_library_status(msg.into()); - } - Err(e) => w.set_collection_error(format!("adding to collection: {e}").into()), - } - - // Recorded, not acted on. Every visible consequence — rebuilding - // the tree, refreshing the badges, rereading the grid — happens in - // `drag-finished`, because all three replace models that Slint is - // *currently walking* to deliver this very event. Tearing down a - // live `DropArea` from inside its own `dropped` handler is the same - // hazard `sync_rows` in lib.rs documents for the adjust panel. - *ctl.dropped_on.borrow_mut() = Some(id); - }); - } - - // The drag ended: dropped, or abandoned. This is where the consequences of - // a drop land, once Slint has finished with the elements involved. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let catalog = catalog.clone(); - let visible = visible_ids.clone(); - let reload = on_scope_changed.clone(); - let session = session.clone(); - window.on_library_drag_finished(move || { - let Some(w) = weak.upgrade() else { return }; - - let landed = ctl.dropped_on.borrow_mut().take(); - let to_trash = ctl.trash_requested.borrow_mut().take(); - ctl.dragging.borrow_mut().clear(); - // The grid clears this itself on the cancel that starts a drag; - // this is for the endings that reach no cell — a drop, or a drag - // abandoned over nothing. - w.set_library_held_row(-1); - *ctl.hover_id.borrow_mut() = None; - *ctl.spring_timer.borrow_mut() = None; - - // A soft delete, deferred out of the drop handler so the models it - // replaces are no longer being walked. - if let Some(images) = to_trash { - start_trash(&w, &ctl, &catalog, &session, &images, &reload); - } - - // The cells settle back into the grid, and the cursor bitmap is - // released — it holds a copy of every thumbnail it composited. - sync_lifted(&w, &[], &visible()); - w.set_library_drag_image(slint::Image::default()); - - if landed.is_some() { - let borrow = catalog.borrow(); - if let Some(cat) = borrow.as_ref() { - // Counts changed on the target and every ancestor, and the - // dropped images now carry one more collection badge. - refresh_tree(&w, &ctl, cat); - sync_badges(&w, cat, &visible()); - } - } - - // Put back whatever the spring opened on the way. The collection - // that received the images — and its ancestors — stay open, since - // that is where the user is now working; on an abandoned drag - // `landed` is `None` and everything closes. - collapse_spring_opened(&w, &ctl, &catalog, landed); - - // A drop into the collection currently being shown changes what that - // collection holds, so the grid has to be reread. - if landed.is_some() && landed == *ctl.scope.borrow() { - reload(); - } - }); - } -} - -/// Trash. -/// -/// TRACES: FR-CAT-15 -/// A drop here is a *soft delete*: the file moves to a trash folder on the -/// server and the catalog records where it came from. Nothing is destroyed -/// until the user empties it, which is a separate, deliberate action. -fn wire_trash( - window: &AppWindow, - ctl: &Rc, - catalog: &Rc>>, - session: &Rc Option>, - on_scope_changed: &Rc, -) { - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let session = session.clone(); - window.on_trash_dropped(move || { - let Some(w) = weak.upgrade() else { return }; - let carried = ctl.dragging.borrow().clone(); - if carried.is_empty() { - return; - } - // Recorded like a collection drop, and acted on in `drag-finished` - // for the same reason: the work replaces Slint models that are - // still being walked to deliver this event. - *ctl.trash_requested.borrow_mut() = Some(carried); - let _ = session; - w.set_collection_error(slint::SharedString::new()); - }); - } - - // Restore. Only reachable while the trash is being looked at, and it acts - // on the selection rather than on everything — the trash is where a user - // goes to recover *one* mistake, not usually to undo the lot. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let catalog = catalog.clone(); - let session = session.clone(); - let reload = on_scope_changed.clone(); - window.on_trash_restore(move || { - let Some(w) = weak.upgrade() else { return }; - let chosen = ctl.selected(); - if chosen.is_empty() { - return; - } - start_restore(&w, &ctl, &catalog, &session, &chosen, &reload); - }); - } -} - -/// Trash from the grid. -/// -/// Until now the only route to the trash was dragging onto the sidebar row. -/// These two are the direct gestures: the trash target on a cell's rating -/// strip, and the `Delete` key. -/// -/// Both land here rather than in `library_ui` because everything the -/// operation needs — the selection, the session closure, `start_trash` and -/// its drain — already lives in this module. Reaching them from the grid -/// side would mean either duplicating the worker plumbing or moving it, and -/// trash is one feature whichever component happens to trigger it. -fn wire_trash_from_grid( - window: &AppWindow, - ctl: &Rc, - catalog: &Rc>>, - session: &Rc Option>, - on_scope_changed: &Rc, - visible_ids: &Rc Vec>, -) { - // The trash glyph on one cell. Acts on that photograph alone: the pointer - // named it, and a click that silently trashed an entire selection would be - // exactly the trap the strip's other targets are laid out to avoid. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let catalog = catalog.clone(); - let session = session.clone(); - let reload = on_scope_changed.clone(); - let visible = visible_ids.clone(); - window.on_library_cell_trashed(move |row| { - let Some(w) = weak.upgrade() else { return }; - // Resolved through the visible ids rather than the row index alone: - // the grid is a window over the catalog, so a stale index from - // before a scroll would name a different photograph — and here that - // would move the wrong file. - let Some(&id) = visible().get(row as usize) else { - return; - }; - start_trash(&w, &ctl, &catalog, &session, &[id], &reload); - }); - } - - // `Delete` on the selection — the bulk gesture. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let catalog = catalog.clone(); - let session = session.clone(); - let reload = on_scope_changed.clone(); - window.on_library_trash_selection(move || { - let Some(w) = weak.upgrade() else { return }; - let chosen = ctl.selected(); - if chosen.is_empty() { - // A keystroke that does nothing reads as a broken key, so it - // says why rather than failing silently. - w.set_library_status("Select an image first".into()); - return; - } - start_trash(&w, &ctl, &catalog, &session, &chosen, &reload); - }); - } - - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let catalog = catalog.clone(); - let session = session.clone(); - let reload = on_scope_changed.clone(); - window.on_trash_empty(move || { - let Some(w) = weak.upgrade() else { return }; - - let conn = match session() { - Some(c) => c, - None => return, - }; - - let borrow = catalog.borrow(); - let Some(cat) = borrow.as_ref() else { return }; - - // Everything in the trash, with the path and stable id each delete - // needs. Read here rather than in the worker: the catalog is not - // `Send`, and the worker opens its own connection only to write back. - let listed = match dr_catalog::trash::list(cat.connection(), usize::MAX) { - Ok(l) => l, - Err(e) => { - w.set_collection_error(format!("reading trash: {e}").into()); - return; - } - }; - if listed.is_empty() { - return; - } - - let paths: Vec<(ImageId, Option, String)> = listed - .iter() - .map(|t| (t.image_id, t.file_id, t.source_ref.clone())) - .collect(); - let ids: Vec = listed.iter().map(|t| t.image_id).collect(); - - log::info!("emptying trash: {} image(s)", ids.len()); - w.set_library_status(format!("Deleting {} image(s)…", ids.len()).into()); - let count = ids.len(); - - let rx = crate::trash::spawn_purge( - conn.clone(), - ids, - paths, - crate::library::catalog_path(&conn.account), - crate::library::thumbs_dir(&conn.account), - ); - - drain_trash( - w.as_weak(), - ctl.clone(), - catalog.clone(), - rx, - reload.clone(), - format!("Deleting {count} photograph(s) permanently"), - ); - }); - } -} - -/// Tree navigation. -fn wire_tree_navigation( - window: &AppWindow, - ctl: &Rc, - catalog: &Rc>>, - on_scope_changed: &Rc, -) { - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let reload = on_scope_changed.clone(); - window.on_collection_select(move |id| { - let Some(w) = weak.upgrade() else { return }; - - // -1 is the trash. It is not a collection, so it clears `scope` - // rather than setting it — see `viewing_trash`. - ctl.viewing_trash.set(id == -1); - *ctl.scope.borrow_mut() = if id <= 0 { - None - } else { - Some(CollectionId(id as u64)) - }; - - // A selection the user cannot see is one they will act on by - // accident, and the new scope shows different images. - ctl.clear_selection(); - - let label = if id == -1 { - "Trash".to_string() - } else if id == 0 { - String::new() - } else { - let rows = w.get_collection_rows(); - (0..rows.row_count()) - .filter_map(|i| rows.row_data(i)) - .find(|r| r.id == id) - .map(|r| r.name.to_string()) - .unwrap_or_default() - }; - w.set_collection_selected(id); - w.set_collection_scope_label(label.into()); - sync_reorderable(&w); - reload(); - }); - } - - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let catalog = catalog.clone(); - window.on_collection_toggle(move |id| { - let Some(w) = weak.upgrade() else { return }; - let id = CollectionId(id as u64); - { - let mut collapsed = ctl.collapsed.borrow_mut(); - if !collapsed.remove(&id) { - collapsed.insert(id); - } - } - let borrow = catalog.borrow(); - if let Some(cat) = borrow.as_ref() { - refresh_tree(&w, &ctl, cat); - } - }); - } -} - -/// Create. -fn wire_create( - window: &AppWindow, - ctl: &Rc, - catalog: &Rc>>, -) { - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let catalog = catalog.clone(); - window.on_collection_new(move || { - let Some(w) = weak.upgrade() else { return }; - let borrow = catalog.borrow(); - let Some(cat) = borrow.as_ref() else { - w.set_collection_error("Open a library first.".into()); - return; - }; - - // Created inside whatever is selected, which is how a hierarchy - // gets built without leaving the header: select the parent, press - // +. The row menu aims the same act at a row instead. - create_child(&w, &ctl, cat, *ctl.scope.borrow()); - }); - } -} - -/// Rename. -/// -/// Inline in the row, opened by a double-click or `F2`. The gesture is worth -/// the field rather than a dialog: renaming is how a hierarchy gets tidied, -/// and it is done in runs of several — a modal per collection would make -/// that a chore. -fn wire_rename( - window: &AppWindow, - ctl: &Rc, - catalog: &Rc>>, -) { - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_collection_rename_start(move |id| { - let Some(w) = weak.upgrade() else { return }; - // `F2` arrives with whatever the sidebar has selected, which may be - // "All photographs" (0) or the trash (-1). Neither has a name to - // change, so the key does nothing rather than opening a field on a - // row that is not a collection. - if id <= 0 { - return; - } - let id = CollectionId(id as u64); - - // A saved filter is renameable like any other — its *membership* is - // computed, its name is not — so there is no kind check here. - *ctl.renaming.borrow_mut() = Some(id); - w.set_collection_renaming(id.0 as i32); - w.set_collection_error(slint::SharedString::new()); - }); - } - - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let catalog = catalog.clone(); - window.on_collection_rename_commit(move |id, name| { - let Some(w) = weak.upgrade() else { return }; - let id = CollectionId(id as u64); - - // The field reports a commit when it loses focus as well as on - // Enter, so a second one can arrive for a rename already closed — - // Enter commits, and the focus the field then gives up commits - // again. Ignored rather than reapplied: the second would bump the - // revision for no change and beat a real edit on another device. - if *ctl.renaming.borrow() != Some(id) { - return; - } - - let borrow = catalog.borrow(); - let Some(cat) = borrow.as_ref() else { return }; - - match apply_rename(cat.connection(), id, name.as_str()) { - Ok(Rename::Applied(name)) => { - close_rename(&w, &ctl); - w.set_collection_error(slint::SharedString::new()); - refresh_tree(&w, &ctl, cat); - // The header names the collection being shown, so a rename - // of the current scope has to reach it too. - if *ctl.scope.borrow() == Some(id) { - w.set_collection_scope_label(name.as_str().into()); - } - log::info!("renamed collection {} to {name}", id.0); - } - Ok(Rename::Unchanged) => close_rename(&w, &ctl), - Err(e) => { - // The field stays open on what the user typed. Closing it - // would drop their text and leave the old name showing, - // with only a line of red to explain where it went. - w.set_collection_error(format!("renaming: {e}").into()); - } - } - }); - } - - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_collection_rename_cancel(move || { - let Some(w) = weak.upgrade() else { return }; - close_rename(&w, &ctl); - w.set_collection_error(slint::SharedString::new()); - }); - } -} - -/// Remove from the collection being shown. -fn wire_remove( - window: &AppWindow, - ctl: &Rc, - catalog: &Rc>>, - visible_ids: &Rc Vec>, - on_scope_changed: &Rc, -) { - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let catalog = catalog.clone(); - let visible = visible_ids.clone(); - let reload = on_scope_changed.clone(); - window.on_library_remove_from_collection(move || { - let Some(w) = weak.upgrade() else { return }; - let Some(scope) = *ctl.scope.borrow() else { - // Unscoped, there is no collection to remove from. The button - // is hidden in that state; this guards the callback anyway. - return; - }; - let chosen = ctl.selected(); - if chosen.is_empty() { - return; - } - - let borrow = catalog.borrow(); - let Some(cat) = borrow.as_ref() else { return }; - - match coll::remove_images(cat.connection(), scope, &chosen) { - Ok(n) => { - w.set_collection_error(slint::SharedString::new()); - // Named explicitly as a membership change: the images are - // still in the library, and a user who reads this as a - // delete will not trust the feature again. - w.set_library_status( - format!("Removed {n} from this collection; still in the library").into(), - ); - ctl.clear_selection(); - refresh_tree(&w, &ctl, cat); - sync_badges(&w, cat, &visible()); - reload(); - } - Err(e) => w.set_collection_error(format!("removing: {e}").into()), - } - }); - } -} - -/// The tree row's own hold-drag: lift a row, drag it onto another to -/// reparent, or onto the root to un-nest it. -fn wire_row_drag( - window: &AppWindow, - ctl: &Rc, - catalog: &Rc>>, -) { - // TRACES: FR-CAT-7 | FR-UI-2 | FR-UI-3 | FR-UI-4 - // The hold on a sidebar row: it arms the drag that rearranges the tree, - // and it opens the row menu. Which one the user gets is decided on release - // by whether they moved. - // - // Both from one gesture because there is only one to spend. A finger has - // no right button, and this tree lives in a Flickable that claims any drag - // beginning inside it — so without a hold, a touch drag on a row is a - // scroll, and arranging the tree is a pointer-only feature. The row lifts - // the moment the timer fires, which is what tells the user the next - // movement will carry the collection rather than scroll past it. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_collection_row_press(move |id, down| { - let Some(w) = weak.upgrade() else { return }; - let id = CollectionId(id as u64); - - if !down { - // Let go. A drag that began has already been dealt with by the - // drop; a hold that did not move is a request for the menu. - *ctl.row_hold_timer.borrow_mut() = None; - let held = ctl.lifted.borrow_mut().take(); - let dragged = ctl.drag_began.replace(false); - w.set_collection_lifted(0); - if let Release::OpenMenu(held) = decide_release(held, dragged) { - w.invoke_collection_menu(held.0 as i32); - } - return; - } - - // A drag of this row, if one follows, carries this collection. - ctl.note_row_press(id); - ctl.drag_began.set(false); - - let timer = slint::Timer::default(); - let weak = w.as_weak(); - let ctl_cb = ctl.clone(); - timer.start( - slint::TimerMode::SingleShot, - std::time::Duration::from_millis(HOLD_DELAY_MS), - move || { - let Some(w) = weak.upgrade() else { return }; - // Picked up, not yet acted on. The menu waits for the - // release, because opening it here would put a sheet over - // the tree the user may be about to drag this row across. - *ctl_cb.lifted.borrow_mut() = Some(id); - w.set_collection_lifted(id.0 as i32); - }, - ); - *ctl.row_hold_timer.borrow_mut() = Some(timer); - }); - } - - // TRACES: FR-CAT-7 | FR-UI-4 - // A row's drag crossed the threshold, or ended. - // - // This is what turns a hold into a rearrangement: once it has fired, the - // release that follows opens no menu. It also decides whether - // "All photographs" will accept the drop, which only makes sense for a - // collection that has a parent to be taken out of. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_collection_drag_active(move |id, active| { - let Some(w) = weak.upgrade() else { return }; - let id = CollectionId(id as u64); - - if !active { - w.set_collection_root_drop_allowed(false); - w.set_collection_lifted(0); - *ctl.lifted.borrow_mut() = None; - return; - } - - // The hold may not have fired — a pointer drag needs no hold — so - // the pick-up is recorded here as well as there, and the row lifts - // either way. - ctl.drag_began.set(true); - ctl.note_row_press(id); - *ctl.lifted.borrow_mut() = Some(id); - w.set_collection_lifted(id.0 as i32); - // The hold has been spent on a drag; nothing may fire behind it. - *ctl.row_hold_timer.borrow_mut() = None; - - // Only a nested collection has a top level to be returned to, and - // only a collection drag — never photographs — means anything - // there at all. - let carrying_images = !ctl.dragging.borrow().is_empty(); - let nested = ctl - .row_ids - .borrow() - .iter() - .position(|&c| c == id) - .and_then(|row| ctl.row_nested.borrow().get(row).copied()) - .unwrap_or(false); - w.set_collection_root_drop_allowed(nested && !carrying_images); - }); - } - - // TRACES: FR-CAT-7 - // Dropped on "All photographs": out to the top level. - // - // The gestural inverse of dropping one row onto another. `can-drop` has - // already refused everything but a nested collection, so reaching here - // means the drop is one — but the payload is taken rather than read, for - // the reason the row's own drop gives: a remembered press must be spent by - // the drop it belongs to, or a later empty drop moves a collection nobody - // touched. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let catalog = catalog.clone(); - window.on_collection_dropped_on_root(move || { - let Some(w) = weak.upgrade() else { return }; - let source = ctl.dragging_collection.borrow_mut().take(); - let Some(source) = source else { return }; - // Photographs cannot land here. Guarded as well as refused in - // `can-drop`, so a payload that somehow arrives does nothing - // rather than reparenting whatever row was last pressed. - if !ctl.dragging.borrow().is_empty() { - return; - } - - let borrow = catalog.borrow(); - let Some(cat) = borrow.as_ref() else { return }; - let moved = coll::set_parent(cat.connection(), source, None); - match moved { - Ok(()) => { - w.set_collection_error(slint::SharedString::new()); - w.set_library_status("Moved to the top level".into()); - refresh_tree(&w, &ctl, cat); - log::info!("promoted collection {} by drag", source.0); - } - Err(e) => w.set_collection_error(format!("moving collection: {e}").into()), - } - }); - } -} - -/// The row menu. -/// -/// Right-click, or a long press on touch. This gesture used to *delete an -/// empty collection outright* and refuse with an error message otherwise, -/// which was wrong in both directions at once: the destructive half fired -/// with no confirmation and nothing on screen said it would, and the -/// refusing half meant a collection holding anything could not be deleted -/// at all — the user emptied it by hand and then did the same thing. -fn wire_row_menu( - window: &AppWindow, - ctl: &Rc, - catalog: &Rc>>, - on_scope_changed: &Rc, - visible_ids: &Rc Vec>, -) { - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let catalog = catalog.clone(); - window.on_collection_menu(move |id| { - let Some(w) = weak.upgrade() else { return }; - let borrow = catalog.borrow(); - let Some(cat) = borrow.as_ref() else { return }; - open_row_menu(&w, &ctl, cat, CollectionId(id as u64)); - }); - } - - // Rename, from the menu into the row's own inline field. - // - // The menu closes first and the field opens second, and the order is - // load-bearing for the same reason it is in `collection-new`: the field is - // focused by an `init` on a row, and a row torn down by a model rebuild - // takes the focus with it. Here the tear-down is the sheet's, and a field - // opened behind a sheet that is still up is a field the user cannot see - // they are typing into. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_collection_menu_rename(move || { - let Some(w) = weak.upgrade() else { return }; - let Some(id) = *ctl.menu_for.borrow() else { - return; - }; - close_row_menu(&w, &ctl); - *ctl.renaming.borrow_mut() = Some(id); - w.set_collection_renaming(id.0 as i32); - }); - } - - // A new collection nested inside this one. - // - // The `+` in the header already creates inside whatever the grid is scoped - // to, which builds a hierarchy only if you first go and look at the parent. - // This is the same act aimed at a row, so a tree can be built from the tree. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let catalog = catalog.clone(); - window.on_collection_menu_new_child(move || { - let Some(w) = weak.upgrade() else { return }; - let Some(parent) = *ctl.menu_for.borrow() else { - return; - }; - let borrow = catalog.borrow(); - let Some(cat) = borrow.as_ref() else { return }; - close_row_menu(&w, &ctl); - create_child(&w, &ctl, cat, Some(parent)); - }); - } - - // TRACES: FR-CAT-7 - // Un-nesting: back out to the top level. - // - // Nesting has had a gesture since collections landed — drag a row onto - // another row — and its inverse had none, in either direction: "All - // photographs" ignores drops on purpose (an image is already in the - // library), and no menu existed to ask. So a collection dragged into - // another was in there permanently, and the only way out was to delete it - // and build it again. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let catalog = catalog.clone(); - window.on_collection_menu_promote(move || { - let Some(w) = weak.upgrade() else { return }; - let Some(id) = *ctl.menu_for.borrow() else { - return; - }; - let borrow = catalog.borrow(); - let Some(cat) = borrow.as_ref() else { return }; - - match coll::set_parent(cat.connection(), id, None) { - Ok(()) => { - w.set_collection_error(slint::SharedString::new()); - w.set_library_status("Moved to the top level".into()); - close_row_menu(&w, &ctl); - refresh_tree(&w, &ctl, cat); - log::info!("promoted collection {} to the top level", id.0); - } - // Left open on failure, showing the collection it failed on: - // closing would leave a red line referring to a row the user - // can no longer tell was the one they aimed at. - Err(e) => w.set_collection_error(format!("moving collection: {e}").into()), - } - }); - } - - // TRACES: FR-NC-6a - // The offline question, handed to the handler the tray on the row already - // uses. Routed through the window's own callback rather than reaching into - // `library_ui`'s prompt directly: that module owns the transfer state the - // prompt reads, and a second entry point into it is a second place for the - // two to disagree about what is downloading. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_collection_menu_offline(move || { - let Some(w) = weak.upgrade() else { return }; - let Some(id) = *ctl.menu_for.borrow() else { - return; - }; - close_row_menu(&w, &ctl); - w.invoke_collection_offline_menu(id.0 as i32); - }); - } - - // TRACES: FR-CAT-7 - // Delete, in one press or two — see [`decide_delete`]. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let catalog = catalog.clone(); - let reload = on_scope_changed.clone(); - let visible = visible_ids.clone(); - window.on_collection_menu_delete(move || { - let Some(w) = weak.upgrade() else { return }; - let Some(id) = *ctl.menu_for.borrow() else { - return; - }; - let borrow = catalog.borrow(); - let Some(cat) = borrow.as_ref() else { return }; - - let (holds, children) = direct_holdings(cat.connection(), id).unwrap_or((0, 0)); - if decide_delete(holds, children, ctl.menu_confirming.get()) == DeleteStep::Confirm { - // Asked once. The sheet swaps its other actions for the - // warning, so the second press cannot be a mis-aimed first one. - ctl.menu_confirming.set(true); - w.set_collection_menu_confirming(true); - w.set_collection_menu_delete_label("Delete anyway".into()); - return; - } - - // Hoisted out of the `match` rather than called in the scrutinee: - // the borrow `cat` comes from lives as long as the match does, and - // the arm below has to release it before rereading the grid. - let deleted = coll::delete(cat.connection(), id); - match deleted { - Ok(()) => { - w.set_collection_error(slint::SharedString::new()); - close_row_menu(&w, &ctl); - - // The grid was showing what no longer exists. - let was_scope = *ctl.scope.borrow() == Some(id); - if was_scope { - *ctl.scope.borrow_mut() = None; - w.set_collection_selected(0); - w.set_collection_scope_label(slint::SharedString::new()); - } - refresh_tree(&w, &ctl, cat); - // Named as a membership change, as the removal button is: - // a user who reads this as a delete of their photographs - // will not trust the feature again. - w.set_library_status(if holds > 0 { - format!( - "Deleted the collection; its {holds} photograph(s) stay in the library" - ) - .into() - } else { - slint::SharedString::from("Deleted the collection") - }); - log::info!("deleted collection {}", id.0); - - // The badge on every visible cell just lost a collection, - // and a scope that has gone needs the grid rereading. - let cells = visible(); - sync_badges(&w, cat, &cells); - drop(borrow); - if was_scope { - reload(); - } - } - Err(e) => w.set_collection_error(format!("deleting: {e}").into()), - } - }); - } - - // Dismiss. A pending confirmation is backed out of one step rather than - // closing the whole menu: Escape from "are you sure" means "no", and - // taking the sheet away with it would leave the user unsure whether the - // key had cancelled the delete or performed it. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_collection_menu_dismiss(move || { - let Some(w) = weak.upgrade() else { return }; - if ctl.menu_confirming.get() { - ctl.menu_confirming.set(false); - w.set_collection_menu_confirming(false); - w.set_collection_menu_delete_label("Delete".into()); - return; - } - close_row_menu(&w, &ctl); - }); - } -} - -/// Where the selection is filed. -fn wire_membership( - window: &AppWindow, - ctl: &Rc, - catalog: &Rc>>, - visible_ids: &Rc Vec>, - on_scope_changed: &Rc, -) { - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let catalog = catalog.clone(); - window.on_library_open_membership(move || { - let Some(w) = weak.upgrade() else { return }; - let borrow = catalog.borrow(); - let Some(cat) = borrow.as_ref() else { return }; - refresh_membership(&w, &ctl, cat); - w.set_membership_open(true); - }); - } - - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let catalog = catalog.clone(); - let visible = visible_ids.clone(); - let reload = on_scope_changed.clone(); - window.on_membership_remove(move |id| { - let Some(w) = weak.upgrade() else { return }; - let target = CollectionId(id as u64); - let chosen = ctl.selected(); - if chosen.is_empty() { - return; - } - let borrow = catalog.borrow(); - let Some(cat) = borrow.as_ref() else { return }; - - // Hoisted for the same reason the delete above is: the arm - // releases the catalog borrow before it rereads the grid. - let removed = coll::remove_images(cat.connection(), target, &chosen); - match removed { - Ok(n) => { - w.set_collection_error(slint::SharedString::new()); - // The same wording the scoped button uses, and for the same - // reason: this is a membership change, not a delete. - w.set_library_status( - format!("Removed {n} from that collection; still in the library").into(), - ); - // The sheet stays open. Taking photographs out of several - // collections is one job, and a sheet that closed after - // each would have to be reopened — with the selection - // still live — to finish it. - refresh_membership(&w, &ctl, cat); - refresh_tree(&w, &ctl, cat); - let cells = visible(); - sync_badges(&w, cat, &cells); - // Only the collection on screen changes what the grid - // holds. Removing from another one leaves the grid right, - // and rereading it would scroll the user's place away. - let showing = *ctl.scope.borrow() == Some(target); - drop(borrow); - if showing { - reload(); - } - } - Err(e) => w.set_collection_error(format!("removing: {e}").into()), - } - }); - } - - { - let weak = window.as_weak(); - window.on_membership_dismiss(move || { - let Some(w) = weak.upgrade() else { return }; - w.set_membership_open(false); - }); - } -} - -/// TRACES: FR-CAT-7 -/// What a collection directly holds: its own member rows, and its own children. -/// -/// One query each rather than reusing `deep_count`, which counts descendants' -/// photographs — see [`menu_detail`] for why the menu needs the direct numbers. -fn direct_holdings( - conn: &rusqlite::Connection, - id: CollectionId, -) -> Result<(usize, usize), dr_catalog::CatalogError> { - let holds: i64 = conn.query_row( - "SELECT count(*) FROM collection_members WHERE collection_id = ?1", - [id.0 as i64], - |r| r.get(0), - )?; - let children: i64 = conn.query_row( - "SELECT count(*) FROM collections WHERE parent_id = ?1 AND deleted = 0", - [id.0 as i64], - |r| r.get(0), - )?; - Ok((holds as usize, children as usize)) -} - -/// TRACES: FR-CAT-7 -/// Open the row menu on `id`, or leave it shut if the collection is gone. -/// -/// Everything the sheet draws is computed here, in one place, so the menu -/// cannot offer "Move to top level" on a collection already at the top, or a -/// delete warning about children it no longer has. Reopened from scratch after -/// every action for the same reason. -fn open_row_menu( - window: &AppWindow, - ctl: &Rc, - catalog: &Catalog, - id: CollectionId, -) { - let conn = catalog.connection(); - let row: Option<(String, Option, i64)> = conn - .query_row( - "SELECT name, parent_id, kind FROM collections WHERE id = ?1 AND deleted = 0", - [id.0 as i64], - |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), - ) - .optional() - .unwrap_or(None); - let Some((name, parent, kind)) = row else { - // Deleted underneath us — by a merge, or by the action that just ran. - close_row_menu(window, ctl); - return; - }; - - let (holds, children) = direct_holdings(conn, id).unwrap_or((0, 0)); - - *ctl.menu_for.borrow_mut() = Some(id); - ctl.menu_confirming.set(false); - window.set_collection_menu_confirming(false); - window.set_collection_menu_title(name.as_str().into()); - window.set_collection_menu_detail(menu_detail(holds, children).into()); - window.set_collection_menu_nested(parent.is_some()); - window.set_collection_menu_smart(kind == CollectionKind::Smart as i64); - window.set_collection_menu_confirm_detail(delete_warning(&name, holds, children).into()); - window.set_collection_menu_delete_label("Delete".into()); - - // TRACES: FR-NC-6a - // Read off the row the sidebar already drew rather than recomputed: the - // roll-up is one pass over the whole tree, and a menu that computed its own - // could disagree with the tray beside the name it is titled with. - let pinned = ctl - .row_ids - .borrow() - .iter() - .position(|&c| c == id) - .and_then(|row| window.get_collection_rows().row_data(row)) - .is_some_and(|r| r.pinned); - window.set_collection_menu_pinned(pinned); -} - -/// Shut the row menu, both halves. -/// -/// The controller's copy is what the handlers act on and the window property is -/// what draws the sheet; clearing one without the other leaves either an -/// invisible menu that still answers, or a visible one that acts on nothing. -fn close_row_menu(window: &AppWindow, ctl: &Rc) { - *ctl.menu_for.borrow_mut() = None; - ctl.menu_confirming.set(false); - window.set_collection_menu_title(slint::SharedString::new()); - window.set_collection_menu_confirming(false); -} - -/// TRACES: FR-CAT-7 | FR-UI-4 -/// Rebuild the membership sheet from the selection as it stands. -/// -/// Called on open and after every removal rather than patched: a removal can -/// take the last selected photograph out of a collection, and that row then has -/// to go — a patched model would leave a "Remove" that removes nothing. -fn refresh_membership(window: &AppWindow, ctl: &Rc, catalog: &Catalog) { - let chosen = ctl.selected(); - let total = chosen.len(); - let rows = match coll::membership_of(catalog.connection(), &chosen) { - Ok(r) => r, - Err(e) => { - window.set_collection_error(format!("reading collections: {e}").into()); - Vec::new() - } - }; - - let out: Vec = rows - .into_iter() - .map(|m| crate::MembershipRow { - id: m.id.0 as i32, - name: m.name.as_str().into(), - holding: m.holding as i32, - // "1 of 1" is arithmetic nobody asked for; with one photograph - // selected the name already says everything true about it. - detail: if total <= 1 { - slint::SharedString::new() - } else { - format!("{} of {total}", m.holding).into() - }, - }) - .collect(); - - window.set_membership_rows(slint::ModelRc::new(slint::VecModel::from(out))); -} - -/// Close the rename field, whatever the outcome. -/// -/// Both halves together, always: the controller's copy is what a stray second -/// commit is tested against, and the window property is what draws the field. -/// Clearing one without the other either leaves a field open that nothing will -/// close, or closes one that Rust still believes is open. -fn close_rename(window: &AppWindow, ctl: &Rc) { - *ctl.renaming.borrow_mut() = None; - window.set_collection_renaming(0); -} - -/// What a committed rename did. -#[derive(Debug, PartialEq, Eq)] -enum Rename { - /// Written, with the name as stored — trimmed. - Applied(String), - /// The name was the one it already had, so nothing was written. - /// - /// Distinguished from `Applied` because every write bumps the revision, and - /// a rename to the same name would let a device that changed nothing win a - /// merge against one that did real work. - Unchanged, -} - -/// Validate a typed name and store it. -/// -/// Split out so the rules are testable without a window — they are the part a -/// user runs into: -/// -/// - **blank is refused.** A nameless row is unclickable and unfindable, and -/// the schema is happy to store one. -/// - **a sibling's name is refused.** Two identically-named collections in one -/// parent are indistinguishable in the sidebar, which is how images end up in -/// the wrong one. The same reasoning as [`unique_name`], enforced here rather -/// than silently suffixing: the user typed a specific name and quietly -/// storing a different one is worse than saying no. -/// -/// Case-insensitive against siblings, because the sidebar sorts that way and -/// "Iceland" beside "iceland" is the same trap as an exact duplicate. -fn apply_rename( - conn: &rusqlite::Connection, - id: CollectionId, - typed: &str, -) -> Result { - let name = typed.trim(); - if name.is_empty() { - return Err(dr_catalog::CatalogError::BadName( - "a collection needs a name".into(), - )); - } - - // The current name, which also proves the collection is still there. - let current: String = conn.query_row( - "SELECT name FROM collections WHERE id = ?1 AND deleted = 0", - [id.0 as i64], - |r| r.get(0), - )?; - if current == name { - return Ok(Rename::Unchanged); - } - - // Siblings, excluding this collection: a rename that only changes case is a - // real rename, and must not be refused as a clash with itself. - let clash: Option = conn - .query_row( - "SELECT 1 FROM collections - WHERE deleted = 0 - AND id != ?1 - AND name = ?2 COLLATE NOCASE - AND parent_id IS (SELECT parent_id FROM collections WHERE id = ?1)", - rusqlite::params![id.0 as i64, name], - |r| r.get(0), - ) - .optional()?; - if clash.is_some() { - return Err(dr_catalog::CatalogError::BadName(format!( - "there is already a “{name}” here" - ))); - } - - coll::rename(conn, id, name)?; - Ok(Rename::Applied(name.to_string())) -} - -/// TRACES: FR-CAT-7 -/// Make a collection inside `parent` and open its name field. -/// -/// Shared by the header's `+` and the row menu's "New collection inside", -/// which differ only in where the parent comes from — and must not differ in -/// anything else, or building a tree from the tree would behave unlike -/// building one from the header. -fn create_child( - window: &AppWindow, - ctl: &Rc, - catalog: &Catalog, - parent: Option, -) { - let name = unique_name(catalog.connection(), parent); - - match coll::create(catalog.connection(), &name, parent, CollectionKind::Manual) { - Ok(id) => { - window.set_collection_error(slint::SharedString::new()); - // A new child is useless if its parent is collapsed. - if let Some(p) = parent { - ctl.collapsed.borrow_mut().remove(&p); - } - // Straight into the name field, with "New collection" selected. - // The name is a placeholder nobody wants to keep, so making the - // user find the rename gesture afterwards is asking them to finish - // a job we started. - // - // Opened *after* the rebuild, and the order is load-bearing: - // `refresh_tree` replaces the row model, which destroys and - // recreates every row. A field opened before it would be torn down - // along with the `init` that focuses it, leaving an edit box - // nothing had typed into. Setting the property afterwards puts the - // field on a row that already exists. - refresh_tree(window, ctl, catalog); - *ctl.renaming.borrow_mut() = Some(id); - window.set_collection_renaming(id.0 as i32); - log::info!("created collection {} ({name})", id.0); - } - Err(e) => window.set_collection_error(format!("creating collection: {e}").into()), - } -} - -/// A name no sibling is already using. -/// -/// Duplicate names are legal in the schema, and two identically-named -/// collections in one parent are indistinguishable in the sidebar — which is -/// how images end up in the wrong one. -fn unique_name(conn: &rusqlite::Connection, parent: Option) -> String { - let taken: Vec = { - let sql = match parent { - Some(_) => "SELECT name FROM collections WHERE deleted = 0 AND parent_id = ?1", - None => "SELECT name FROM collections WHERE deleted = 0 AND parent_id IS NULL", - }; - let Ok(mut stmt) = conn.prepare(sql) else { - return "New collection".into(); - }; - // Collected inside each arm: the two `query_map` calls bind different - // parameter types, so their iterators are different types and cannot - // be the arms of one `match`. - match parent { - Some(p) => stmt - .query_map([p.0 as i64], |r| r.get::<_, String>(0)) - .map(|rows| rows.flatten().collect()) - .unwrap_or_default(), - None => stmt - .query_map([], |r| r.get::<_, String>(0)) - .map(|rows| rows.flatten().collect()) - .unwrap_or_default(), - } - }; - - let base = "New collection"; - if !taken.iter().any(|t| t == base) { - return base.into(); - } - for n in 2..1000 { - let candidate = format!("{base} {n}"); - if !taken.contains(&candidate) { - return candidate; - } - } - base.into() -} - -#[cfg(test)] -mod tests { - - /// A press and the release that follows it — which is what a click is. - /// - /// These tests are about what a *user* sees, and a user only ever presses - /// and lets go. Calling [`apply_press`] alone would drop the half of the - /// policy that waits for the release ([`Press::Deferred`]) and quietly - /// assert the wrong thing about ctrl. - #[allow(clippy::too_many_arguments)] - fn click( - selection: &mut BTreeSet, - anchor: &mut Option, - ids: &[ImageId], - offset: usize, - row: usize, - ctrl: bool, - shift: bool, - span: &dyn Fn(usize, usize) -> Vec, - ) { - if let Press::Deferred(id) = - apply_press(selection, anchor, ids, offset, row, ctrl, shift, span) - { - selection.remove(&id); - } - } - use super::*; - - fn ids(n: u64) -> Vec { - (1..=n).map(ImageId).collect() - } - - /// The catalog's answer to "what is between these two ordinals". - /// - /// Stands in for [`crate::library_ui::LibraryController::ids_in_span`], - /// which does the same thing with a `LIMIT`/`OFFSET`: the whole library is - /// available to the query whatever the grid happens to have loaded, and an - /// ordinal indexes it directly. - fn library(all: &[ImageId]) -> impl Fn(usize, usize) -> Vec + '_ { - move |first, last| { - all.iter() - .copied() - .skip(first) - .take((last + 1).saturating_sub(first)) - .collect() - } - } - - /// No catalog to ask — what a press sees before a library is open, and if - /// the query fails. - fn unloaded(_first: usize, _last: usize) -> Vec { - Vec::new() - } - - #[test] - fn dragging_a_collection_onto_another_moves_it() { - // The gesture the tree rearrangement exists for: no images carried, a - // row remembered from the press, dropped somewhere else. - assert_eq!( - decide_drop(&[], Some(CollectionId(7)), CollectionId(9)), - Drop::Reparent(CollectionId(7)) - ); - } - - #[test] - fn photographs_are_filed_even_when_a_row_was_pressed_first() { - // Clicking a collection and then dragging photographs into another must - // file them, not move the collection that happens to be remembered. - // This is the ordering the whole discrimination rests on. - assert_eq!( - decide_drop(&ids(3), Some(CollectionId(7)), CollectionId(9)), - Drop::FileImages(ids(3)) - ); - } - - #[test] - fn a_collection_dropped_on_itself_does_nothing() { - // The catalog would refuse it as a cycle; catching it here makes the - // commonest slip a no-op rather than an error the user has to read. - assert_eq!( - decide_drop(&[], Some(CollectionId(7)), CollectionId(7)), - Drop::Nothing - ); - } - - #[test] - fn an_empty_drag_with_nothing_remembered_moves_nothing() { - // A file dragged in from another application lands here with no images - // and no pressed row. It must not disturb the tree. - assert_eq!(decide_drop(&[], None, CollectionId(9)), Drop::Nothing); - } - - #[test] - fn a_plain_press_replaces_the_selection() { - let all = ids(5); - let span = library(&all); - let mut sel = BTreeSet::new(); - let mut anchor = None; - - click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span); - click(&mut sel, &mut anchor, &all, 0, 2, false, false, &span); - - assert_eq!(sel.iter().copied().collect::>(), vec![ImageId(3)]); - } - - #[test] - fn ctrl_press_adds_and_then_removes() { - let all = ids(5); - let span = library(&all); - let mut sel = BTreeSet::new(); - let mut anchor = None; - - click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span); - click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span); - assert_eq!(sel.len(), 2); - - // Toggling: a second ctrl-press on the same cell takes it out again. - click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span); - assert_eq!(sel.iter().copied().collect::>(), vec![ImageId(1)]); - } - - #[test] - fn shift_press_extends_a_contiguous_range() { - let all = ids(10); - let span = library(&all); - let mut sel = BTreeSet::new(); - let mut anchor = None; - - click(&mut sel, &mut anchor, &all, 0, 2, false, false, &span); - click(&mut sel, &mut anchor, &all, 0, 6, false, true, &span); - - assert_eq!(sel.len(), 5, "rows 2..=6 inclusive"); - assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(7))); - } - - #[test] - fn shift_extends_backwards_too() { - let all = ids(10); - let span = library(&all); - let mut sel = BTreeSet::new(); - let mut anchor = None; - - click(&mut sel, &mut anchor, &all, 0, 6, false, false, &span); - click(&mut sel, &mut anchor, &all, 0, 2, false, true, &span); - assert_eq!(sel.len(), 5); - } - - #[test] - fn a_second_shift_click_re_describes_the_range_rather_than_adding_to_it() { - // Overshooting and correcting is the common case. Extending without - // clearing would turn the correction into a union, and the user would - // drag cells they believed they had just deselected. - let all = ids(20); - let span = library(&all); - let mut sel = BTreeSet::new(); - let mut anchor = None; - - click(&mut sel, &mut anchor, &all, 0, 5, false, false, &span); - click(&mut sel, &mut anchor, &all, 0, 15, false, true, &span); - assert_eq!(sel.len(), 11, "rows 5..=15"); - - // Corrected to a shorter range from the same anchor. - click(&mut sel, &mut anchor, &all, 0, 8, false, true, &span); - assert_eq!(sel.len(), 4, "rows 5..=8, and nothing from the first range"); - assert!(!sel.contains(&ImageId(16)), "row 15 is no longer selected"); - } - - #[test] - fn the_anchor_stays_put_across_shift_clicks() { - // If the anchor moved to each shift-click, a range could only ever be - // grown, never corrected inward. - let all = ids(20); - let span = library(&all); - let mut sel = BTreeSet::new(); - let mut anchor = None; - - click(&mut sel, &mut anchor, &all, 0, 10, false, false, &span); - click(&mut sel, &mut anchor, &all, 0, 14, false, true, &span); - click(&mut sel, &mut anchor, &all, 0, 12, false, true, &span); - - assert_eq!(anchor, Some(10)); - assert_eq!(sel.len(), 3, "rows 10..=12"); - } - - /// TRACES: FR-UI-2 | FR-UI-4 - /// Two taps on one cell put it back where it started, and take nothing else. - /// - /// This is the behaviour that replaced the double-tap range. That gesture - /// selected everything between the cell and wherever the selection began — - /// silently, with no visible state, from a thing a hand does by accident. - /// "Select to…" does the same job and says so first, so a double tap is now - /// two toggles and nothing more: the only outcome a user can predict from - /// what is on the screen. - #[test] - fn two_taps_on_one_cell_cancel_out_and_take_no_range() { - let all = ids(30); - let span = library(&all); - let mut sel = BTreeSet::new(); - let mut anchor = None; - - // Selecting began somewhere else, so a range gesture would have had an - // anchor to sweep from. - click(&mut sel, &mut anchor, &all, 0, 4, false, false, &span); - assert_eq!(sel.len(), 1); - - tap(&mut sel, &mut anchor, &all, 11); - tap(&mut sel, &mut anchor, &all, 11); - - assert_eq!( - sel.iter().copied().collect::>(), - vec![ImageId(5)], - "a double tap took a run instead of toggling one cell twice" - ); - } - - /// One tap in selection mode: a press reported as ctrl-held, which is what - /// `library.slint` sends while the mode is on. - fn tap(sel: &mut BTreeSet, anchor: &mut Option, all: &[ImageId], row: usize) { - click(sel, anchor, all, 0, row, true, false, &library(all)); - } - - /// TRACES: FR-CAT-7 | FR-UI-4 - /// The bug that made a forty-image drag file one photograph. - /// - /// In selection mode every press arrives as a ctrl-press, so grabbing one - /// of the selected cells to drag them all used to *deselect* the cell being - /// grabbed. `drag-started` then saw a press on an image that was not in the - /// selection, concluded that was the gesture, and carried it alone. - #[test] - fn grabbing_a_selected_cell_leaves_the_whole_selection_to_drag() { - let all = ids(20); - let span = library(&all); - let mut sel = BTreeSet::new(); - let mut anchor = None; - - // Three photographs picked in selection mode, which reports ctrl. - for row in [2, 5, 9] { - click(&mut sel, &mut anchor, &all, 0, row, true, false, &span); - } - assert_eq!(sel.len(), 3); - - // The press that begins the drag, on one of the three. - let outcome = apply_press(&mut sel, &mut anchor, &all, 0, 5, true, false, &span); - - assert_eq!( - outcome, - Press::Deferred(ImageId(6)), - "the removal has to be handed back, not performed" - ); - assert_eq!( - sel.len(), - 3, - "the drag reads the selection next, and all three have to still be in it" - ); - assert!(sel.contains(&ImageId(6)), "least of all the one being held"); - } - - /// And the other half: with no drag, the release still takes it out. - /// - /// A tap in selection mode has to toggle, or there is no way to correct a - /// mis-tap short of leaving the mode. - #[test] - fn a_tap_on_a_selected_cell_still_takes_it_out() { - let all = ids(20); - let span = library(&all); - let mut sel = BTreeSet::new(); - let mut anchor = None; - - for row in [2, 5, 9] { - click(&mut sel, &mut anchor, &all, 0, row, true, false, &span); - } - - click(&mut sel, &mut anchor, &all, 0, 5, true, false, &span); - - assert_eq!( - sel.iter().copied().collect::>(), - vec![ImageId(3), ImageId(10)], - "the tapped photograph is out and the other two stayed" - ); - } - - /// The anchor moves on the press whether or not the removal does. - /// - /// "Select to…" measures from the anchor, and a run taken after tapping a - /// selected cell has to start where the user last touched — otherwise the - /// gesture sweeps from wherever the anchor happened to be left. - #[test] - fn a_deferred_press_still_moves_the_anchor() { - let all = ids(20); - let span = library(&all); - let mut sel = BTreeSet::new(); - let mut anchor = None; - - click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span); - let _ = apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false, &span); - - assert_eq!(anchor, Some(3)); - } - - #[test] - fn ctrl_shift_adds_a_second_range_to_the_selection() { - // Picking up a second run without losing the first: the one case where - // a shift-click must not clear. - let all = ids(20); - let span = library(&all); - let mut sel = BTreeSet::new(); - let mut anchor = None; - - click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span); - click(&mut sel, &mut anchor, &all, 0, 2, false, true, &span); - assert_eq!(sel.len(), 3); - - // A new anchor by ctrl-click, then a ctrl+shift range from it. - click(&mut sel, &mut anchor, &all, 0, 10, true, false, &span); - click(&mut sel, &mut anchor, &all, 0, 12, true, true, &span); - - assert_eq!(sel.len(), 6, "rows 0..=2 and 10..=12"); - assert!(sel.contains(&ImageId(1)) && sel.contains(&ImageId(13))); - } - - #[test] - fn a_plain_press_on_a_selected_cell_keeps_the_selection() { - // This is what makes a multi-image drag possible: the press that starts - // the drag must not collapse what it is about to carry. - let all = ids(5); - let span = library(&all); - let mut sel = BTreeSet::new(); - let mut anchor = None; - - click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span); - click(&mut sel, &mut anchor, &all, 0, 1, true, false, &span); - click(&mut sel, &mut anchor, &all, 0, 2, true, false, &span); - assert_eq!(sel.len(), 3); - - // Pressing one of the three to begin a drag. - click(&mut sel, &mut anchor, &all, 0, 1, false, false, &span); - assert_eq!(sel.len(), 3, "the selection survived the press"); - } - - #[test] - fn a_press_undone_leaves_the_selection_exactly_as_it_was() { - // A pinch opens as one finger on a cell, so by the time it is known to - // be a pinch a photograph has been selected that the user was only - // reaching past. Undoing it has to be *exact*: a cancel that restored - // the selection but left the anchor moved would make the next - // shift-click select a run from a cell nobody pointed at. - let all = ids(6); - let span = library(&all); - let mut sel = BTreeSet::new(); - let mut anchor = None; - - // A selection built the ordinary way, and the state it leaves behind. - click(&mut sel, &mut anchor, &all, 0, 1, false, false, &span); - click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span); - let before = (sel.clone(), anchor); - - // The finger that opens a pinch. - let undo = PressUndo::capture(&sel, anchor, Some(3)); - click(&mut sel, &mut anchor, &all, 0, 5, false, false, &span); - assert_ne!( - (sel.clone(), anchor), - before, - "the press has to change something, or this proves nothing" - ); - - let cursor = undo.restore(&mut sel, &mut anchor); - - assert_eq!((sel, anchor), before, "selection and anchor are back"); - assert_eq!(cursor, Some(3), "and the keyboard cursor"); - } - - #[test] - fn a_press_past_the_end_of_the_window_is_ignored() { - // The grid is windowed and a stale row index can arrive after a scrub. - let all = ids(3); - let span = library(&all); - let mut sel = BTreeSet::new(); - let mut anchor = None; - - click(&mut sel, &mut anchor, &all, 0, 99, false, false, &span); - assert!(sel.is_empty()); - } - - #[test] - fn an_anchor_survives_the_window_moving_under_it() { - // The grid is a window over the catalog, and the window moves whenever - // the user scrolls. Held as a row, an anchor set before a scroll - // described a range from whatever photograph had since drifted into - // that row — so shift-clicking after scrolling selected a run the user - // never pointed at, silently and with no way to tell. - let all = ids(20); - let span = library(&all); - let first: Vec<_> = all[0..10].to_vec(); - let later: Vec<_> = all[4..14].to_vec(); - - let mut sel = BTreeSet::new(); - let mut anchor = None; - - // Anchor on the sixth image, in a window starting at the beginning. - click(&mut sel, &mut anchor, &first, 0, 5, false, false, &span); - assert_eq!(anchor, Some(5), "the anchor is an ordinal, not a row"); - - // The user scrolls — the window now starts four images in — and - // shift-clicks the image at ordinal 10. - click(&mut sel, &mut anchor, &later, 4, 6, false, true, &span); - - assert_eq!(sel.len(), 6, "ordinals 5..=10"); - assert!(sel.contains(&ImageId(6)), "the anchored image is still in"); - assert!(sel.contains(&ImageId(11)), "up to the one shift-clicked"); - assert!(!sel.contains(&ImageId(5)), "and nothing before the anchor"); - } - - #[test] - fn a_range_reaching_outside_the_window_selects_the_whole_run() { - // The bug this exists for: the anchor is far above the loaded window, - // so all but four of the photographs in the range are off screen. They - // are still what the user asked for, and the catalog is what knows - // their ids — reading the range from the window selected the four that - // happened to be loaded and looked as though it had worked. - let all = ids(30); - let span = library(&all); - let window: Vec<_> = all[10..20].to_vec(); - - let mut sel = BTreeSet::new(); - let mut anchor = Some(0); - - click(&mut sel, &mut anchor, &window, 10, 3, false, true, &span); - - assert_eq!(sel.len(), 14, "ordinals 0..=13, loaded or not"); - assert!( - sel.contains(&ImageId(1)), - "the anchored image, never loaded" - ); - assert!(sel.contains(&ImageId(14)), "up to the cell pressed"); - assert!(!sel.contains(&ImageId(15)), "and no further"); - } - - #[test] - fn a_range_running_off_the_far_end_is_whole_too() { - // The mirror of the case above, with the anchor *ahead* of the press - // instead of behind it. Neither direction may stop at the window, and - // neither may wrap round to the other end of it. - let all = ids(30); - let span = library(&all); - let window: Vec<_> = all[0..5].to_vec(); - - let mut sel = BTreeSet::new(); - let mut anchor = Some(25); - - click(&mut sel, &mut anchor, &window, 0, 2, false, true, &span); - - assert_eq!(sel.len(), 24, "ordinals 2..=25"); - assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(26))); - assert!( - !sel.contains(&ImageId(1)), - "nothing before the pressed cell" - ); - } - - #[test] - fn a_range_with_no_catalog_to_ask_falls_back_to_the_window() { - // Before a library is open, and if the query fails. What is loaded is - // a poorer answer than the catalog's and a far better one than a - // gesture that appears to do nothing. - let all = ids(30); - let window: Vec<_> = all[10..20].to_vec(); - - let mut sel = BTreeSet::new(); - let mut anchor = Some(0); - - click( - &mut sel, - &mut anchor, - &window, - 10, - 3, - false, - true, - &unloaded, - ); - - assert_eq!(sel.len(), 4, "ordinals 10..=13, the loaded part of 0..=13"); - assert!(sel.contains(&ImageId(11)) && sel.contains(&ImageId(14))); - } - - #[test] - fn shift_without_an_anchor_selects_just_the_one() { - let all = ids(5); - let span = library(&all); - let mut sel = BTreeSet::new(); - let mut anchor = None; - - click(&mut sel, &mut anchor, &all, 0, 3, false, true, &span); - assert_eq!(sel.iter().copied().collect::>(), vec![ImageId(4)]); - } - - /// A solid test thumbnail. - fn thumb(w: u32, h: u32) -> slint::Image { - let mut buf = slint::SharedPixelBuffer::::new(w, h); - for p in buf.make_mut_slice() { - *p = slint::Rgba8Pixel { - r: 200, - g: 120, - b: 60, - a: 255, - }; - } - slint::Image::from_rgba8(buf) - } - - #[test] - fn one_dragged_image_composites_to_a_single_frame() { - let img = compose_drag_image(&[thumb(64, 64)]); - let size = img.size(); - // No fan for one image: the bitmap is just the thumbnail's own box. - assert_eq!(size.width, size.height, "a square thumbnail stays square"); - assert!(size.width > 0); - } - - #[test] - fn a_stack_is_wider_than_a_single_image() { - // The fan is what makes the count legible from the shape rather than - // needing a number drawn on it. - let one = compose_drag_image(&[thumb(64, 64)]); - let many = compose_drag_image(&[thumb(64, 64), thumb(64, 64), thumb(64, 64)]); - - assert!( - many.size().width > one.size().width, - "three images fan wider than one" - ); - assert!(many.size().height > one.size().height); - } - - #[test] - fn the_stack_stops_growing_past_the_layer_cap() { - // A forty-image drag must not composite forty thumbnails for a pile - // whose lower layers are hidden anyway. - let five: Vec = (0..5).map(|_| thumb(64, 64)).collect(); - let forty: Vec = (0..40).map(|_| thumb(64, 64)).collect(); - - assert_eq!( - compose_drag_image(&five).size().width, - compose_drag_image(&forty).size().width, - "past the cap the stack looks no thicker" - ); - } - - #[test] - fn an_empty_drag_composites_to_nothing() { - // Every cell in the selection may still be waiting for its preview. - assert_eq!(compose_drag_image(&[]).size().width, 0); - } - - #[test] - fn a_portrait_thumbnail_keeps_its_proportions() { - // Fitted, not stretched: a distorted frame stops the stack reading as - // photographs. - let img = compose_drag_image(&[thumb(60, 120)]); - let size = img.size(); - assert!( - size.height > size.width, - "a tall thumbnail composites tall, {}x{}", - size.width, - size.height - ); - } - - #[test] - fn mixed_orientations_do_not_panic_or_wrap() { - // The layers below the top are fitted into its box and clipped. Getting - // that wrong draws as a smear across the next row, or panics. - let img = compose_drag_image(&[thumb(120, 60), thumb(60, 120), thumb(90, 90)]); - assert!(img.size().width > 0 && img.size().height > 0); - } - - #[test] - fn a_zero_sized_thumbnail_is_not_composited() { - // A decode that produced nothing must not become a zero-divide. - assert_eq!(compose_drag_image(&[thumb(0, 0)]).size().width, 0); - } - - /// The spring's inputs: rows, which have children, and which are collapsed. - fn spring_fixture() -> ( - Vec, - Vec, - std::collections::HashSet, - ) { - let ids = vec![CollectionId(1), CollectionId(2), CollectionId(3)]; - // 1 is a collapsed parent, 2 an expanded parent, 3 a leaf. - let has_children = vec![true, true, false]; - let collapsed = [CollectionId(1)].into_iter().collect(); - (ids, has_children, collapsed) - } - - #[test] - fn hovering_a_collapsed_parent_springs_it_open() { - // The point of the gesture: reaching a child of something closed. - let (ids, kids, collapsed) = spring_fixture(); - assert_eq!( - should_spring(Some(0), &ids, &kids, &collapsed), - Some(CollectionId(1)) - ); - } - - #[test] - fn hovering_an_already_open_parent_springs_nothing() { - // Its children are already reachable; rebuilding the tree would move - // rows under the pointer for no gain. - let (ids, kids, collapsed) = spring_fixture(); - assert_eq!(should_spring(Some(1), &ids, &kids, &collapsed), None); - } - - #[test] - fn hovering_a_leaf_springs_nothing() { - // A collection with no children has nothing to open, and flashing a - // rebuild would just shift the row the user is aiming at. - let (ids, kids, collapsed) = spring_fixture(); - assert_eq!(should_spring(Some(2), &ids, &kids, &collapsed), None); - } - - #[test] - fn hovering_nothing_springs_nothing() { - let (ids, kids, collapsed) = spring_fixture(); - assert_eq!(should_spring(None, &ids, &kids, &collapsed), None); - } - - #[test] - fn a_stale_row_index_springs_nothing() { - // The hover can outlive the row model it referred to. - let (ids, kids, collapsed) = spring_fixture(); - assert_eq!(should_spring(Some(99), &ids, &kids, &collapsed), None); - } - - #[test] - fn the_spring_dwell_is_long_enough_not_to_trigger_in_passing() { - // A tree that flaps open under every passing pointer is worse than one - // that never opens. This pins the intent rather than the number: a - // reflex-speed value here would be a regression, not a tuning choice. - // Asserted in a const item rather than at runtime: the condition is - // constant either way, and clippy is right that a runtime assert on it - // is theatre. This form fails the build instead of a test run, which is - // strictly earlier, and keeps the bound where a reader of the constant - // will look for it. - const _: () = assert!( - SPRING_DELAY_MS >= 300, - "a pointer crossing a parent must not open it" - ); - const _: () = assert!( - SPRING_DELAY_MS <= 900, - "and a deliberate dwell must not feel like a hang" - ); - } - - #[test] - fn new_collections_do_not_share_a_name_with_a_sibling() { - // Two identically-named collections in one parent are - // indistinguishable in the sidebar, which is how images land in the - // wrong one. - let cat = Catalog::in_memory().unwrap(); - let c = cat.connection(); - - let first = unique_name(c, None); - coll::create(c, &first, None, CollectionKind::Manual).unwrap(); - let second = unique_name(c, None); - coll::create(c, &second, None, CollectionKind::Manual).unwrap(); - - assert_ne!(first, second); - assert_eq!(first, "New collection"); - assert_eq!(second, "New collection 2"); - } - - /// TRACES: FR-NC-6a | FR-NC-6c - /// A catalog with `n` images, for the offline roll-up tests. - fn catalog_with_images(n: usize) -> (Catalog, Vec) { - let cat = Catalog::in_memory().unwrap(); - cat.connection() - .execute( - "INSERT INTO roots (id, kind, label) VALUES (1, 'remote', 'test')", - [], - ) - .unwrap(); - let mut ids = Vec::new(); - for i in 0..n { - cat.connection() - .execute( - "INSERT INTO images (root_id, source_ref, added_at) - VALUES (1, ?1, 0)", - rusqlite::params![format!("Photos/img{i:03}.CR2")], - ) - .unwrap(); - ids.push(ImageId(cat.connection().last_insert_rowid() as u64)); - } - (cat, ids) - } - - /// Record an image as held on this device, as a finished download would. - fn mark_held(cat: &Catalog, image: ImageId) { - cat.connection() - .execute( - "INSERT INTO image_cache (image_id, tier_actual, tier_desired, bytes, pinned) - VALUES (?1, ?2, ?2, 100, 1)", - rusqlite::params![image.0 as i64, dr_types::Tier::Original.stored()], - ) - .unwrap(); - } - - #[test] - fn a_parent_reads_as_kept_only_when_its_children_are_too() { - // The tray on a parent answers for the subtree, because that is what - // keeping a parent downloads. A parent that showed a full tray over a - // child with nothing downloaded would be a promise the aeroplane - // breaks. - let (cat, images) = catalog_with_images(4); - let c = cat.connection(); - let parent = coll::create(c, "Trip", None, CollectionKind::Manual).unwrap(); - let child = coll::create(c, "Day one", Some(parent), CollectionKind::Manual).unwrap(); - coll::add_images(c, parent, &images[0..2]).unwrap(); - coll::add_images(c, child, &images[2..4]).unwrap(); - - // Everything the parent holds directly, and nothing of the child's. - mark_held(&cat, images[0]); - mark_held(&cat, images[1]); - - let rows = coll::tree(c).unwrap(); - let state = offline_state(&cat, &rows); - - assert_eq!( - state[&parent], - OfflineCount { - total: 4, - pinned: 2 - } - ); - assert_eq!( - state[&child], - OfflineCount { - total: 2, - pinned: 0 - } - ); - - mark_held(&cat, images[2]); - mark_held(&cat, images[3]); - let state = offline_state(&cat, &coll::tree(c).unwrap()); - assert_eq!( - state[&parent], - OfflineCount { - total: 4, - pinned: 4 - } - ); - } - - #[test] - fn a_grandchild_rolls_all_the_way_up() { - // The reverse pass has to survive depth: a photograph three levels down - // is still part of what the top-level collection would download. - let (cat, images) = catalog_with_images(1); - let c = cat.connection(); - let top = coll::create(c, "2024", None, CollectionKind::Manual).unwrap(); - let mid = coll::create(c, "Corsica", Some(top), CollectionKind::Manual).unwrap(); - let leaf = coll::create(c, "Bonifacio", Some(mid), CollectionKind::Manual).unwrap(); - coll::add_images(c, leaf, &images).unwrap(); - - let state = offline_state(&cat, &coll::tree(c).unwrap()); - - assert_eq!( - state[&top], - OfflineCount { - total: 1, - pinned: 0 - } - ); - assert_eq!( - state[&mid], - OfflineCount { - total: 1, - pinned: 0 - } - ); - } - - #[test] - fn an_empty_collection_has_nothing_to_keep() { - // Guards the reading that would put a full tray beside an empty - // collection: every one of its zero photographs is here, which is true - // and useless. `refresh_tree` requires a non-zero total before drawing - // one, and this is the fact it relies on. - let (cat, _) = catalog_with_images(0); - let c = cat.connection(); - let empty = coll::create(c, "Nothing yet", None, CollectionKind::Manual).unwrap(); - - let state = offline_state(&cat, &coll::tree(c).unwrap()); - assert_eq!(state.get(&empty).copied().unwrap_or_default().total, 0); - } - - /// A catalog holding one top-level collection, and its id. - fn with_one(name: &str) -> (Catalog, CollectionId) { - let cat = Catalog::in_memory().unwrap(); - let id = coll::create(cat.connection(), name, None, CollectionKind::Manual).unwrap(); - (cat, id) - } - - fn name_of(cat: &Catalog, id: CollectionId) -> String { - cat.connection() - .query_row( - "SELECT name FROM collections WHERE id = ?1", - [id.0 as i64], - |r| r.get(0), - ) - .unwrap() - } - - #[test] - fn a_rename_stores_the_new_name() { - let (cat, id) = with_one("Untitled"); - - let out = apply_rename(cat.connection(), id, "Iceland").unwrap(); - assert_eq!(out, Rename::Applied("Iceland".into())); - assert_eq!(name_of(&cat, id), "Iceland"); - } - - #[test] - fn surrounding_whitespace_is_trimmed_rather_than_stored() { - // A trailing space is invisible in the sidebar and makes two - // collections look identical while sorting them apart. - let (cat, id) = with_one("Untitled"); - - assert_eq!( - apply_rename(cat.connection(), id, " Iceland ").unwrap(), - Rename::Applied("Iceland".into()) - ); - assert_eq!(name_of(&cat, id), "Iceland"); - } - - #[test] - fn a_blank_name_is_refused() { - // A nameless row cannot be read or aimed at, and the schema would take - // one happily. - let (cat, id) = with_one("Iceland"); - - assert!(matches!( - apply_rename(cat.connection(), id, " "), - Err(dr_catalog::CatalogError::BadName(_)) - )); - assert_eq!(name_of(&cat, id), "Iceland", "the old name stands"); - } - - #[test] - fn renaming_to_the_current_name_writes_nothing() { - // Every write bumps the revision, and a no-op rename would let an idle - // device beat one that did real work at the next merge. - let (cat, id) = with_one("Iceland"); - let rev = |c: &Catalog| -> i64 { - c.connection() - .query_row( - "SELECT revision FROM collections WHERE id = ?1", - [id.0 as i64], - |r| r.get(0), - ) - .unwrap() - }; - - let before = rev(&cat); - assert_eq!( - apply_rename(cat.connection(), id, "Iceland").unwrap(), - Rename::Unchanged - ); - assert_eq!(rev(&cat), before); - } - - #[test] - fn a_siblings_name_is_refused() { - // Two identically-named collections in one parent are - // indistinguishable in the sidebar, which is how images land in the - // wrong one. - let cat = Catalog::in_memory().unwrap(); - let c = cat.connection(); - coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap(); - let japan = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap(); - - assert!(matches!( - apply_rename(c, japan, "Iceland"), - Err(dr_catalog::CatalogError::BadName(_)) - )); - assert_eq!(name_of(&cat, japan), "Japan"); - } - - #[test] - fn a_siblings_name_is_refused_in_a_different_case_too() { - // The sidebar sorts case-insensitively, so "iceland" beside "Iceland" - // is the same trap as an exact duplicate. - let cat = Catalog::in_memory().unwrap(); - let c = cat.connection(); - coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap(); - let japan = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap(); - - assert!(matches!( - apply_rename(c, japan, "iceland"), - Err(dr_catalog::CatalogError::BadName(_)) - )); - } - - #[test] - fn changing_only_the_case_of_a_name_is_allowed() { - // The clash check excludes the collection itself, or fixing the - // capitalisation of a name would be refused as a clash with itself. - let (cat, id) = with_one("iceland"); - - assert_eq!( - apply_rename(cat.connection(), id, "Iceland").unwrap(), - Rename::Applied("Iceland".into()) - ); - assert_eq!(name_of(&cat, id), "Iceland"); - } - - #[test] - fn a_name_used_under_a_different_parent_is_free() { - // Uniqueness is per parent: "Selects" inside two different trips is - // unambiguous and normal. - let cat = Catalog::in_memory().unwrap(); - let c = cat.connection(); - let trips = coll::create(c, "Trips", None, CollectionKind::Manual).unwrap(); - coll::create(c, "Selects", Some(trips), CollectionKind::Manual).unwrap(); - let top = coll::create(c, "Loose", None, CollectionKind::Manual).unwrap(); - - assert_eq!( - apply_rename(c, top, "Selects").unwrap(), - Rename::Applied("Selects".into()) - ); - } - - #[test] - fn two_top_level_collections_still_clash_despite_a_null_parent() { - // `parent_id IS NULL` never matches with `=`, so a naive clash query - // would silently allow every duplicate at the top level — which is - // where most collections live. - let cat = Catalog::in_memory().unwrap(); - let c = cat.connection(); - coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap(); - let other = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap(); - - assert!( - apply_rename(c, other, "Iceland").is_err(), - "two top-level collections must not share a name" - ); - } - - #[test] - fn renaming_a_deleted_collection_fails_rather_than_resurrecting_it() { - let (cat, id) = with_one("Gone"); - coll::delete(cat.connection(), id).unwrap(); - - assert!(apply_rename(cat.connection(), id, "Back").is_err()); - } - - #[test] - fn a_saved_filter_can_be_renamed() { - // Its *membership* is computed; its name is not, and a smart - // collection the user cannot label is worse than no smart collection. - let cat = Catalog::in_memory().unwrap(); - let c = cat.connection(); - let s = coll::create(c, "Untitled", None, CollectionKind::Smart).unwrap(); - - assert_eq!( - apply_rename(c, s, "Five star").unwrap(), - Rename::Applied("Five star".into()) - ); - } - - #[test] - fn the_same_name_is_free_again_under_a_different_parent() { - // Uniqueness is per parent, not global: "Selects" inside two different - // trips is unambiguous and normal. - let cat = Catalog::in_memory().unwrap(); - let c = cat.connection(); - - let top = coll::create(c, "New collection", None, CollectionKind::Manual).unwrap(); - assert_eq!(unique_name(c, Some(top)), "New collection"); - } - - #[test] - fn an_empty_collection_is_deleted_without_being_asked_about() { - // There is nothing to warn about, and a dialogue asking "delete this - // empty thing?" is the kind that teaches people to dismiss dialogues - // without reading them — including the one that mattered. - assert_eq!(decide_delete(0, 0, false), DeleteStep::Now); - } - - #[test] - fn a_collection_holding_anything_is_asked_about_once() { - assert_eq!(decide_delete(12, 0, false), DeleteStep::Confirm); - assert_eq!(decide_delete(0, 1, false), DeleteStep::Confirm); - // And once asked, it goes. - assert_eq!(decide_delete(12, 3, true), DeleteStep::Now); - } - - #[test] - fn a_collection_holding_only_children_still_asks() { - // The old gesture refused on `deep_count > 0`, which counted - // descendants' photographs — so an empty parent of empty children was - // deletable in one press and an empty parent of a *full* child was not - // deletable at all. Both are now the same question, asked once. - assert_eq!(decide_delete(0, 2, false), DeleteStep::Confirm); - } - - #[test] - fn the_menu_line_counts_what_delete_acts_on() { - assert_eq!(menu_detail(0, 0), "no photographs"); - assert_eq!(menu_detail(1, 0), "1 photograph"); - assert_eq!(menu_detail(12, 1), "12 photographs · 1 collection inside"); - assert_eq!(menu_detail(0, 3), "no photographs · 3 collections inside"); - } - - #[test] - fn the_warning_says_the_photographs_survive() { - // The single most likely misreading of "Delete" here. If this clause - // ever goes, a user deletes a collection expecting to lose the - // pictures — and either panics, or does not do it at all. - let w = delete_warning("Iceland", 12, 0); - assert!(w.contains("12 photographs"), "{w}"); - assert!(w.contains("stay in your library"), "{w}"); - } - - #[test] - fn the_warning_says_nested_collections_are_promoted_not_taken() { - // `dr_catalog::collections::delete` promotes children to the deleted - // collection's parent rather than cascading. A warning that did not - // say so would describe a data loss that does not happen. - let w = delete_warning("Trips", 0, 2); - assert!(w.contains("2 collections inside it move up"), "{w}"); - // And nothing about photographs surviving, because none were lost. - assert!(!w.contains("stay in your library"), "{w}"); - } - #[test] - fn a_hold_that_did_not_move_opens_the_menu() { - assert_eq!( - decide_release(Some(CollectionId(4)), false), - Release::OpenMenu(CollectionId(4)) - ); - } - - #[test] - fn a_hold_that_became_a_drag_opens_nothing() { - // The drop has already done the work. A menu here would put a sheet - // over the tree the user has just finished rearranging — and over the - // row they would have to look at to see whether it worked. - assert_eq!( - decide_release(Some(CollectionId(4)), true), - Release::Nothing - ); - } - - #[test] - fn an_ordinary_tap_opens_nothing() { - // Nothing was ever picked up, so this is the press that selects a - // collection and scopes the grid. A menu on every tap would make the - // sidebar unusable. - assert_eq!(decide_release(None, false), Release::Nothing); - } -} diff --git a/ui/dr-ui/src/collections_ui/controller.rs b/ui/dr-ui/src/collections_ui/controller.rs new file mode 100644 index 0000000..1f197cd --- /dev/null +++ b/ui/dr-ui/src/collections_ui/controller.rs @@ -0,0 +1,567 @@ +//! Selection and tree state (`CollectionsController`), and the pure +//! decisions a drop, a delete, or a hold-release comes down to. +//! +//! `decide_drop`, `decide_delete` and `decide_release` are kept apart from +//! the callbacks that call them for the reason the crate-level doc gives: +//! the gesture cannot be driven from a test, but the decision can. + +use std::cell::RefCell; +use std::collections::BTreeSet; +use std::rc::Rc; + +use dr_types::{CollectionId, ImageId}; + +use super::press::PressUndo; + +/// TRACES: FR-CAT-5 +/// How a run of grid ordinals is turned into the ids it names. +/// +/// See [`CollectionsController::span_source`] for why this is supplied rather +/// than reached for. +type SpanIds = Rc Vec>; + +/// Selection, drag, and tree state for the running window. +/// +/// Everything is `RefCell` because Slint callbacks are `Fn`, not `FnMut`, and +/// they all run on the one event-loop thread — the same shape +/// [`crate::library_ui::LibraryController`] uses. +#[derive(Default)] +pub struct CollectionsController { + /// Selected images, by catalog id. A `BTreeSet` rather than a `Vec` so + /// membership tests are cheap during a rubber-band and the order a drop + /// applies in is stable across runs. + pub(super) selection: RefCell>, + /// Where a shift-click extends from. The last cell *clicked*, not the last + /// added — extending from the far end of a previous range is not what the + /// gesture means anywhere else. + /// + /// An **image ordinal in the library**, not a row of the loaded window. + /// The grid is a window over the catalog, so row 7 names a different + /// photograph after every scroll: held as a row, an anchor set before a + /// scroll described a range from wherever that row had since drifted to. + /// Selection is by id for the same reason (see the preamble); this is the + /// same argument applied to the one index that has to survive a move. + pub(super) anchor: RefCell>, + /// TRACES: FR-UI-2 | FR-UI-4 + /// TRACES: FR-CAT-7 | FR-UI-4 + /// A photograph the most recent press asked to take *out* of the + /// selection, held until the release says the press was a tap. + /// + /// See [`Press::Deferred`] for why removal cannot happen on the press: + /// this is the whole of what stops a drag of forty photographs carrying + /// one. Overwritten by the next press and dropped by the drag that + /// consumes it, so at most one is ever pending. + pub(super) pending_toggle: std::cell::Cell>, + /// TRACES: FR-UI-4 + /// What the selection was immediately before the most recent press, so a + /// gesture that turns out not to have been a press can put it back. + /// + /// A press has to act immediately — the drag that may follow reads the + /// selection to build its payload, so deciding on release is too late. + /// That is right for a drag and wrong for a pinch, which begins as an + /// ordinary one-finger press and only becomes a pinch when the second + /// finger lands. By then a cell has been selected that the user never + /// meant to touch; they were reaching for the grid with two fingers. + /// + /// Cheap to keep: a few hundred ids at most, cloned once per press. + pub(super) press_undo: RefCell>, + /// Where the keyboard is, as an image ordinal. + /// + /// Distinct from the anchor, and it has to be: shift+arrow grows a range + /// *from* the anchor *to* the cursor, so the two are the two ends and + /// cannot be one field. `None` until the user has taken hold of the grid, + /// so the first arrow press starts from what is on screen rather than + /// jumping to the top of the library. + pub(super) cursor: std::cell::Cell>, + /// Whether the press that began the current gesture carried ctrl or shift. + /// + /// A modified click is a *selection* gesture and must not also open the + /// image: building a selection would otherwise throw the user into the + /// develop view on the second ctrl-click. Slint does not report modifiers on + /// `clicked`, so the press records them and the click consults this. + pub(super) modified_press: std::cell::Cell, + /// TRACES: FR-UI-2 | FR-UI-4 + /// Whether a tap in the grid selects rather than opens. + /// + /// Touch has no ctrl and no shift, so without a mode there is no way to + /// select a second photograph: the first tap would open the first one. In + /// this mode a plain press is reported as a ctrl-press and goes through the + /// same [`apply_press`] as everything else — a separate touch policy would + /// be a second copy of these rules to keep in step. + pub(super) select_mode: std::cell::Cell, + /// The timer that turns a held cell into a selection. + /// + /// Here rather than in `.slint` because Slint has no long-press gesture and + /// a hand-rolled one would need a `Timer` element per visible cell — a + /// hundred timers to answer a question about one finger. Held so that + /// dropping it cancels: a press that ends, or is taken by the Flickable + /// when the finger travels, must not arrive as a selection a moment later. + pub(super) hold_timer: RefCell>, + /// Collection ids parallel to the sidebar's rows, so a hovered row index + /// resolves to an id without another query. + pub(super) row_ids: RefCell>, + /// Which rows are saved filters, so a drop onto one is refused *before* the + /// release rather than after. + pub(super) row_smart: RefCell>, + /// Which rows have children, so the spring knows there is anything to open. + pub(super) row_has_children: RefCell>, + /// Collapsed collections, by id. Collapse is a view preference and + /// deliberately not persisted to the catalog — it is not something to sync + /// between devices. + pub(super) collapsed: RefCell>, + /// Which collection scopes the grid. `None` is the whole library. + pub(super) scope: RefCell>, + /// The collection whose name is being edited in the sidebar, if any. + /// + /// Held here rather than in Slint because a rename can also be *started* + /// from Rust — creating a collection opens its field — and because a + /// commit that the catalog refuses has to leave the field open on the name + /// the user typed rather than silently closing over a rejected edit. + pub(super) renaming: RefCell>, + /// Whether the grid is showing the trash rather than the library. + /// + /// Separate from `scope` because the trash is not a collection: its contents + /// come from `trashed_at`, not from membership, and every other query in the + /// library *excludes* exactly what this view exists to show. Folding it into + /// `scope` as a sentinel id would put that inversion inside a type that + /// means "a collection". + pub(super) viewing_trash: std::cell::Cell, + /// The live drag: what it carries. Empty means no drag. + pub(super) dragging: RefCell>, + /// The collection being dragged, when the drag is a tree rearrangement + /// rather than a filing of photographs. + /// + /// Set when the drag starts and read by the drop, the same way `dragging` + /// works — the drop callback carries only the target's id, so what is being + /// dropped has to be remembered rather than inspected. + pub(super) dragging_collection: RefCell>, + /// TRACES: FR-UI-3 | FR-UI-4 + /// The collection a hold has picked up, and the timer that picks it up. + /// + /// The tree is inside a Flickable, which claims any drag beginning inside + /// it — so with a finger, a drag on a row is a scroll unless something says + /// otherwise first. The hold is that something. It arms the drag *and* + /// opens the row menu, and which of the two the user gets is decided by + /// whether they then moved: the same fork the grid already uses to tell a + /// hold-to-select from a drag-to-file. + pub(super) lifted: RefCell>, + /// Whether the lifted row's drag actually began. Reset by the press that + /// arms the next one, so a release can tell a rearrangement from a menu. + pub(super) drag_began: std::cell::Cell, + /// The hold timer for a sidebar row. One, replaced per press, so a press + /// that became a scroll leaves nothing queued to fire over the list the + /// user is now scrolling. + pub(super) row_hold_timer: RefCell>, + /// Whether each visible row has a parent, in `row_ids` order — what decides + /// whether "All photographs" lights up as a drop target. Kept beside the + /// rows it indexes rather than queried per drag: `refresh_tree` already has + /// the parent in hand, and a query would answer for a tree that may have + /// been rebuilt since. + pub(super) row_nested: RefCell>, + /// The collection the row menu is open on. + /// + /// Held here as well as in the window's title property because the two say + /// different things: the property is what the sheet *draws*, and this is + /// what every action *acts on*. A rename committed from the menu changes + /// the first and must not change the second. + pub(super) menu_for: RefCell>, + /// Whether the menu's delete has been asked once and is waiting to be + /// confirmed. + /// + /// A `Cell` rather than a window property alone so the decision is made in + /// Rust: the sheet must not be able to reach the destructive branch by + /// flipping a bit of its own. + pub(super) menu_confirming: std::cell::Cell, + /// The collection a drag is currently over, by id. + /// + /// Only the spring needs this — the *drop* is hit-tested by Slint and + /// arrives with its own id, so nothing here has to remember where the + /// pointer was. + pub(super) hover_id: RefCell>, + /// Spring-loaded expansion: the timer that opens a collapsed parent the + /// pointer has been dwelling on mid-drag. + /// + /// One timer, restarted per row, so moving on cancels the pending + /// expansion rather than leaving a queue of them to fire later. + pub(super) spring_timer: RefCell>, + /// Collections the spring opened during *this* drag, so they can be closed + /// again if the drag ends elsewhere. Without this, dragging across a deep + /// tree leaves every parent it passed over hanging open. + pub(super) spring_opened: RefCell>, + /// Images dropped on the trash, recorded by `dropped-on-trash` and acted on + /// in `drag-finished` — the same deferral, for the same reason. + pub(super) trash_requested: RefCell>>, + /// Drains a trash worker. Held so a second operation replaces the first + /// rather than two timers fighting over the same model. + pub(super) trash_timer: RefCell>, + /// Which collection a drop landed on, recorded by `dropped` and acted on in + /// `drag-finished`. + /// + /// Deferred because every consequence of a drop replaces a Slint model — + /// the tree, the grid cells — and doing that from inside the `dropped` + /// handler destroys the elements Slint is still using to deliver the event. + pub(super) dropped_on: RefCell>, + /// TRACES: FR-CAT-5 + /// How a run of grid ordinals is turned into ids. + /// + /// Supplied by [`crate::library_ui`] at wiring time, because the catalog, + /// the scope and the rating filter — everything the run depends on — belong + /// to the grid's controller, not to this one. Held as a closure rather than + /// reached for through a handle so the selection rules stay testable with + /// no library open: the tests pass a run that reads a plain slice. + /// + /// `None` before wiring, which the caller reads as "nothing to ask" and + /// falls back to the loaded window. + pub(super) span_source: RefCell>, + /// Where the trash workers report what they are doing. + /// + /// Shared with [`crate::library_ui`]: a delete and a scan are two jobs in + /// one list, and the user asking what the application is busy with does not + /// care which module started them. + pub(super) activity: Rc, +} + +/// What a release over a collection row means. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Drop { + /// File these photographs in the target. + FileImages(Vec), + /// Move this collection under the target. + Reparent(CollectionId), + /// Nothing to do — an empty drag, or a row dropped on itself. + Nothing, +} + +/// Decide what a drop on `target` should do. +/// +/// Separated from the callback because the gesture cannot be driven from a +/// test — Slint owns it — while this decision is where it can actually go +/// wrong. The order matters: an image drag always fills `carried`, so +/// photographs can never be mistaken for a rearrangement, and only an empty +/// payload consults the row remembered from the press. +pub fn decide_drop( + carried: &[ImageId], + pressed_row: Option, + target: CollectionId, +) -> Drop { + if !carried.is_empty() { + return Drop::FileImages(carried.to_vec()); + } + match pressed_row { + // A collection cannot go inside itself. Caught here as well as in the + // catalog so the common case is a no-op rather than an error message. + Some(source) if source != target => Drop::Reparent(source), + _ => Drop::Nothing, + } +} + +/// TRACES: FR-CAT-7 +/// What the row menu's Delete should do on this press. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeleteStep { + /// Delete it now. Nothing is lost that the user cannot see is nothing. + Now, + /// Say what goes, and wait to be asked again. + Confirm, +} + +/// TRACES: FR-CAT-7 +/// Whether deleting this collection needs to be confirmed first. +/// +/// The gesture this replaced refused outright whenever the collection held +/// anything, which made a full collection undeletable without emptying it by +/// hand — child by child, since a parent counts its descendants. Refusing is +/// not a safety property; it is the absence of one, because the user goes and +/// does the same thing the long way round. +/// +/// So: an empty collection goes on the first press, because there is nothing +/// to warn about and a dialogue asking "delete this empty thing?" is the kind +/// that teaches people to dismiss dialogues. Anything else is asked once. +/// +/// Split from the handler because this is the whole of the safety rule, and a +/// handler needs a window to run. +pub fn decide_delete(holds: usize, children: usize, confirmed: bool) -> DeleteStep { + if confirmed || (holds == 0 && children == 0) { + DeleteStep::Now + } else { + DeleteStep::Confirm + } +} + +/// TRACES: FR-CAT-7 +/// The line under the menu's title: what this collection directly holds. +/// +/// Direct members and direct children, not the sidebar's deep count, because +/// this line sits above a Delete — and delete drops *this* collection's member +/// rows while promoting its children rather than taking them. A number here +/// that counted descendants' photographs would be describing something the +/// button below it does not do. +pub fn menu_detail(holds: usize, children: usize) -> String { + let photos = match holds { + 0 => "no photographs".to_string(), + 1 => "1 photograph".to_string(), + n => format!("{n} photographs"), + }; + match children { + 0 => photos, + 1 => format!("{photos} · 1 collection inside"), + n => format!("{photos} · {n} collections inside"), + } +} + +/// TRACES: FR-CAT-7 +/// What deleting this collection actually does, in full. +/// +/// Every clause here is one a user has got wrong about a collections feature +/// before: that deleting a collection deletes the photographs (it does not — +/// membership is a join table), and that deleting a parent takes the +/// collections nested in it (it does not — [`dr_catalog::collections::delete`] +/// promotes them, because losing a subtree because its container was tidied +/// away is not recoverable). +pub fn delete_warning(name: &str, holds: usize, children: usize) -> String { + let mut out = format!("Deleting “{name}” "); + match (holds, children) { + (0, _) => out.push_str("removes it from the sidebar."), + (1, _) => out.push_str("takes 1 photograph out of it."), + (n, _) => out.push_str(&format!("takes {n} photographs out of it.")), + } + if holds > 0 { + out.push_str(" They stay in your library and in every other collection they are in."); + } + match children { + 0 => {} + 1 => { + out.push_str(" The collection inside it moves up one level rather than going with it.") + } + n => out.push_str(&format!( + " The {n} collections inside it move up one level rather than going with it." + )), + } + out +} + +/// TRACES: FR-UI-3 | FR-UI-4 +/// What letting go of a held collection row means. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Release { + /// The hold fired and nothing moved: the user is asking what can be done + /// to this collection. + OpenMenu(CollectionId), + /// Either the hold never fired — an ordinary tap, which selects — or it + /// did and the row was then dragged, in which case the drop has already + /// done the work. + Nothing, +} + +/// TRACES: FR-UI-3 | FR-UI-4 +/// Decide what a release does, from what the press turned into. +/// +/// The whole of the fork that lets one gesture mean two things, and the reason +/// it is worth a test: get it wrong in the direction of `OpenMenu` and every +/// rearrangement ends with a sheet over the tree the user just tidied; get it +/// wrong the other way and the menu is unreachable with a finger. +pub fn decide_release(held: Option, dragged: bool) -> Release { + match held { + Some(id) if !dragged => Release::OpenMenu(id), + _ => Release::Nothing, + } +} + +impl CollectionsController { + pub fn new(activity: Rc) -> Rc { + Rc::new(Self { + activity, + ..Default::default() + }) + } + + /// Remember which row was pressed, so a drag that follows knows what it is + /// carrying. + /// + /// Recorded on the press rather than at the drag's start because Slint + /// builds the payload through a `pure` binding, which must not have side + /// effects — and the drop callback is handed only the *target's* id, so the + /// source has to be remembered somewhere. + pub fn note_row_press(&self, id: CollectionId) { + *self.dragging_collection.borrow_mut() = Some(id); + } + + /// Which collection the grid is scoped to, for [`crate::library_ui`] to + /// build its query from. + pub fn scope(&self) -> Option { + *self.scope.borrow() + } + + /// Whether the grid should be showing the trash. + pub fn viewing_trash(&self) -> bool { + self.viewing_trash.get() + } + + /// Whether the gesture in progress began with ctrl or shift held. + /// + /// A modified click selects and nothing more — opening the image as well + /// would eject the user from the grid they are selecting in. + pub fn press_was_modified(&self) -> bool { + self.modified_press.get() + } + + /// Selected image ids, in a stable order. + pub fn selected(&self) -> Vec { + self.selection.borrow().iter().copied().collect() + } + + /// TRACES: FR-CAT-5 + /// The ids of grid rows `first..=last`, in the order the grid shows them. + /// + /// Empty when nothing has been wired in — see [`Self::span_source`]. + pub(super) fn span(&self, first: usize, last: usize) -> Vec { + let source = self.span_source.borrow().clone(); + source.map(|f| f(first, last)).unwrap_or_default() + } + + /// Drop the selection — after a scrub, or when the scope changes. + /// + /// Selection is by id and survives a window change, but a selection the + /// user cannot see is a selection they will act on by accident. Clearing on + /// a deliberate navigation is the safer of the two behaviours. + pub fn clear_selection(&self) { + self.selection.borrow_mut().clear(); + *self.anchor.borrow_mut() = None; + self.cursor.set(None); + } + + /// Where the keyboard cursor is, as an image ordinal. + pub fn cursor(&self) -> Option { + self.cursor.get() + } + + pub fn set_cursor(&self, at: Option) { + self.cursor.set(at); + } +} + +#[cfg(test)] +mod tests { + use crate::collections_ui::test_support::*; + + use super::*; + + #[test] + fn dragging_a_collection_onto_another_moves_it() { + // The gesture the tree rearrangement exists for: no images carried, a + // row remembered from the press, dropped somewhere else. + assert_eq!( + decide_drop(&[], Some(CollectionId(7)), CollectionId(9)), + Drop::Reparent(CollectionId(7)) + ); + } + + #[test] + fn photographs_are_filed_even_when_a_row_was_pressed_first() { + // Clicking a collection and then dragging photographs into another must + // file them, not move the collection that happens to be remembered. + // This is the ordering the whole discrimination rests on. + assert_eq!( + decide_drop(&ids(3), Some(CollectionId(7)), CollectionId(9)), + Drop::FileImages(ids(3)) + ); + } + + #[test] + fn a_collection_dropped_on_itself_does_nothing() { + // The catalog would refuse it as a cycle; catching it here makes the + // commonest slip a no-op rather than an error the user has to read. + assert_eq!( + decide_drop(&[], Some(CollectionId(7)), CollectionId(7)), + Drop::Nothing + ); + } + + #[test] + fn an_empty_drag_with_nothing_remembered_moves_nothing() { + // A file dragged in from another application lands here with no images + // and no pressed row. It must not disturb the tree. + assert_eq!(decide_drop(&[], None, CollectionId(9)), Drop::Nothing); + } + + #[test] + fn an_empty_collection_is_deleted_without_being_asked_about() { + // There is nothing to warn about, and a dialogue asking "delete this + // empty thing?" is the kind that teaches people to dismiss dialogues + // without reading them — including the one that mattered. + assert_eq!(decide_delete(0, 0, false), DeleteStep::Now); + } + + #[test] + fn a_collection_holding_anything_is_asked_about_once() { + assert_eq!(decide_delete(12, 0, false), DeleteStep::Confirm); + assert_eq!(decide_delete(0, 1, false), DeleteStep::Confirm); + // And once asked, it goes. + assert_eq!(decide_delete(12, 3, true), DeleteStep::Now); + } + + #[test] + fn a_collection_holding_only_children_still_asks() { + // The old gesture refused on `deep_count > 0`, which counted + // descendants' photographs — so an empty parent of empty children was + // deletable in one press and an empty parent of a *full* child was not + // deletable at all. Both are now the same question, asked once. + assert_eq!(decide_delete(0, 2, false), DeleteStep::Confirm); + } + + #[test] + fn the_menu_line_counts_what_delete_acts_on() { + assert_eq!(menu_detail(0, 0), "no photographs"); + assert_eq!(menu_detail(1, 0), "1 photograph"); + assert_eq!(menu_detail(12, 1), "12 photographs · 1 collection inside"); + assert_eq!(menu_detail(0, 3), "no photographs · 3 collections inside"); + } + + #[test] + fn the_warning_says_the_photographs_survive() { + // The single most likely misreading of "Delete" here. If this clause + // ever goes, a user deletes a collection expecting to lose the + // pictures — and either panics, or does not do it at all. + let w = delete_warning("Iceland", 12, 0); + assert!(w.contains("12 photographs"), "{w}"); + assert!(w.contains("stay in your library"), "{w}"); + } + + #[test] + fn the_warning_says_nested_collections_are_promoted_not_taken() { + // `dr_catalog::collections::delete` promotes children to the deleted + // collection's parent rather than cascading. A warning that did not + // say so would describe a data loss that does not happen. + let w = delete_warning("Trips", 0, 2); + assert!(w.contains("2 collections inside it move up"), "{w}"); + // And nothing about photographs surviving, because none were lost. + assert!(!w.contains("stay in your library"), "{w}"); + } + + #[test] + fn a_hold_that_did_not_move_opens_the_menu() { + assert_eq!( + decide_release(Some(CollectionId(4)), false), + Release::OpenMenu(CollectionId(4)) + ); + } + + #[test] + fn a_hold_that_became_a_drag_opens_nothing() { + // The drop has already done the work. A menu here would put a sheet + // over the tree the user has just finished rearranging — and over the + // row they would have to look at to see whether it worked. + assert_eq!( + decide_release(Some(CollectionId(4)), true), + Release::Nothing + ); + } + + #[test] + fn an_ordinary_tap_opens_nothing() { + // Nothing was ever picked up, so this is the press that selects a + // collection and scopes the grid. A menu on every tap would make the + // sidebar unusable. + assert_eq!(decide_release(None, false), Release::Nothing); + } +} diff --git a/ui/dr-ui/src/collections_ui/drag.rs b/ui/dr-ui/src/collections_ui/drag.rs new file mode 100644 index 0000000..03f38dd --- /dev/null +++ b/ui/dr-ui/src/collections_ui/drag.rs @@ -0,0 +1,495 @@ +//! The drag: the bitmap under the cursor, the press-to-selection-mode +//! hold, and the spring that opens a collapsed collection a drag is +//! dwelling over. + +use std::cell::RefCell; +use std::rc::Rc; + +use dr_catalog::collections::{self as coll}; +use dr_catalog::Catalog; +use dr_types::CollectionId; +use slint::ComponentHandle; + +use crate::AppWindow; + +use super::controller::CollectionsController; +use super::tree_sync::refresh_tree; + +/// Build the bitmap that travels under the cursor. +/// +/// One image is drawn as itself. Several are **fanned**, back to front with the +/// topmost last, so the cursor carries a visibly thicker stack the more is being +/// dragged — the count is legible from the shape rather than needing a number. +/// +/// Composited here rather than in Slint because `DragArea.drag-image` takes a +/// single bitmap, and Slint cannot render a pile of thumbnails into one. +/// +/// Only the top few are drawn. A forty-image drag would otherwise be forty +/// composites for a stack whose lower layers are hidden by the ones above. +pub(super) fn compose_drag_image(thumbs: &[slint::Image]) -> slint::Image { + /// Layers drawn, at most. Past this the stack looks no thicker. + const MAX_LAYERS: usize = 4; + /// Pixel step between layers, in the composite's own space. + const FAN: u32 = 10; + /// Long edge of the composed bitmap. + const EDGE: u32 = 160; + + let layers: Vec<&slint::Image> = thumbs.iter().rev().take(MAX_LAYERS).collect(); + let Some(top) = layers.first() else { + return slint::Image::default(); + }; + + // The whole composite is the top image's box plus room for the fan. + let offset = FAN * (layers.len().saturating_sub(1)) as u32; + let size = top.size(); + if size.width == 0 || size.height == 0 { + return slint::Image::default(); + } + // Scale the top thumbnail so its long edge is EDGE, then add the fan. + let scale = EDGE as f32 / size.width.max(size.height) as f32; + let tw = ((size.width as f32 * scale) as u32).max(1); + let th = ((size.height as f32 * scale) as u32).max(1); + + let mut canvas = slint::SharedPixelBuffer::::new(tw + offset, th + offset); + let cw = canvas.width(); + let stride = cw as usize; + let pixels = canvas.make_mut_slice(); + + // Back to front: `layers` is already reversed, so the last drawn is the + // image the user grabbed and it lands on top. + for (n, layer) in layers.iter().enumerate().rev() { + // The furthest-back layer sits at the largest offset, so the stack fans + // down and right from the top image at (0, 0). + let dx = FAN * n as u32; + let dy = FAN * n as u32; + + // Each layer is fitted to the *top* image's box rather than stretched to + // it: a portrait frame behind a landscape one would otherwise be visibly + // distorted, and the stack stops reading as a pile of photographs. + let s = layer.size(); + let (lw, lh) = if s.width == 0 || s.height == 0 { + (tw, th) + } else { + let fit = (tw as f32 / s.width as f32).min(th as f32 / s.height as f32); + ( + ((s.width as f32 * fit) as u32).max(1), + ((s.height as f32 * fit) as u32).max(1), + ) + }; + // Centred in the slot, so a narrower frame is not pinned to one edge. + let cx = dx + (tw - lw.min(tw)) / 2; + let cy = dy + (th - lh.min(th)) / 2; + + blit_scaled(layer, pixels, stride, cx, cy, lw, lh, n > 0); + } + + slint::Image::from_rgba8_premultiplied(canvas) +} + +/// Draw one thumbnail into the composite, scaled to `tw`×`th` at `dx`,`dy`. +/// +/// Nearest-neighbour: this is a transient 160px cursor bitmap, and a filtered +/// resample would cost more than it could visibly buy. `dim` darkens the layers +/// beneath the top one so the stack reads as depth rather than as a smear. +/// +/// The buffer is premultiplied, so the alpha applied here is baked into the +/// colour channels as well. +#[allow(clippy::too_many_arguments)] +fn blit_scaled( + src: &slint::Image, + dst: &mut [slint::Rgba8Pixel], + stride: usize, + dx: u32, + dy: u32, + tw: u32, + th: u32, + dim: bool, +) { + let Some(buf) = src.to_rgba8() else { return }; + let (sw, sh) = (buf.width(), buf.height()); + if sw == 0 || sh == 0 { + return; + } + let src_px = buf.as_slice(); + + for y in 0..th { + let sy = (y * sh / th).min(sh - 1); + for x in 0..tw { + let sx = (x * sw / tw).min(sw - 1); + let s = src_px[(sy * sw + sx) as usize]; + + // Clipped per pixel on both axes. A row-major index alone would let + // an overhanging right edge wrap onto the next line, which draws as + // a smear rather than as an out-of-bounds panic. + let (px, py) = (dx + x, dy + y); + if px as usize >= stride { + continue; + } + let out = py as usize * stride + px as usize; + if out >= dst.len() { + continue; + } + // Layers below the top are darkened, not made transparent: the + // composite sits over whatever is on screen, and translucency there + // would show the desktop through the stack. + let f = if dim { 0.55 } else { 1.0 }; + dst[out] = slint::Rgba8Pixel { + r: (s.r as f32 * f) as u8, + g: (s.g as f32 * f) as u8, + b: (s.b as f32 * f) as u8, + a: s.a, + }; + } + } +} + +/// How long the pointer must dwell on a collapsed parent before it springs +/// open, mid-drag. +/// +/// Long enough that crossing a parent on the way somewhere else does not open +/// it — a tree that flaps open under every passing pointer is worse than one +/// that never opens. Short enough to feel like a response rather than a wait; +/// this is the range file managers have settled on for the same gesture. +const SPRING_DELAY_MS: u64 = 500; + +/// TRACES: FR-UI-2 | FR-UI-4 +/// How long a cell must be held before the grid enters selection mode. +/// +/// The platform convention, and the reason to match it rather than pick: every +/// gallery on the device this exists for opens a selection on a hold of about +/// this length, so a user who has never read a word about DarkRoom already +/// knows the gesture. Shorter and a slow tap becomes a selection; longer and +/// the hand lets go first, having concluded nothing was going to happen. +pub(crate) const HOLD_DELAY_MS: u64 = 450; + +/// TRACES: FR-UI-2 | FR-UI-4 +/// Start the timer that turns a held cell into a selection. +/// +/// Restarted per press, and cancelled by the release — see `hold_timer`. The +/// press that armed it has *already* selected the cell under the finger, so +/// what firing adds is the mode: from here taps toggle rather than open, and +/// the header's buttons appear to act on what has been gathered. +pub(super) fn arm_hold(window: &AppWindow, ctl: &Rc, row: i32) { + let timer = slint::Timer::default(); + let weak = window.as_weak(); + let ctl_cb = ctl.clone(); + + timer.start( + slint::TimerMode::SingleShot, + std::time::Duration::from_millis(HOLD_DELAY_MS), + move || { + let Some(w) = weak.upgrade() else { return }; + + // TRACES: FR-CAT-7 + // The photograph is in the user's hand: the grid draws a ring + // opening around it and stops scrolling underneath it, so the drag + // that may follow cannot be lost to a flick. See `held-row` in + // `library.slint`. + // + // This half happens whether or not selection mode was already on — + // it is the half a *drag* needs, and a drag out of a selection of + // forty starts in a mode that is already on. + w.set_library_held_row(row); + + if !ctl_cb.select_mode.get() { + ctl_cb.select_mode.set(true); + w.set_library_select_mode(true); + } + + // The release that follows this hold must not also open the image: + // the user asked for a selection and would land in develop instead. + // Reusing `modified_press` rather than adding a second flag — the + // click already consults it, and it means exactly this: "the press + // was a selection gesture". + ctl_cb.modified_press.set(true); + }, + ); + + *ctl.hold_timer.borrow_mut() = Some(timer); +} + +/// Whether hovering this row should schedule a spring expansion. +/// +/// Pure so the rule is testable: only a *collapsed parent* has anything to +/// open. A leaf would flash a pointless rebuild, and one already expanded is +/// where the user can already see the children. +fn should_spring( + row: Option, + row_ids: &[CollectionId], + row_has_children: &[bool], + collapsed: &std::collections::HashSet, +) -> Option { + let row = row?; + let &id = row_ids.get(row)?; + let has_children = row_has_children.get(row).copied().unwrap_or(false); + (has_children && collapsed.contains(&id)).then_some(id) +} + +/// Start (or restart) the dwell timer that opens a collapsed collection. +/// +/// Called on every hover change during a drag. Restarting on each change is +/// what makes the dwell a dwell: moving to another row cancels the pending +/// expansion instead of queueing a second one. +pub(super) fn arm_spring( + window: &AppWindow, + ctl: &Rc, + catalog: &Rc>>, + row: Option, +) { + // Dropping the old timer cancels it. Anything already scheduled for the row + // the pointer has just left must not fire. + *ctl.spring_timer.borrow_mut() = None; + + let Some(row) = row else { return }; + + // Only a collapsed parent has anything to spring. A leaf, or one already + // open, is left alone rather than being pointlessly "expanded". + let target = should_spring( + Some(row), + &ctl.row_ids.borrow(), + &ctl.row_has_children.borrow(), + &ctl.collapsed.borrow(), + ); + let Some(id) = target else { return }; + + let timer = slint::Timer::default(); + let weak = window.as_weak(); + let ctl_cb = ctl.clone(); + let catalog = catalog.clone(); + + timer.start( + slint::TimerMode::SingleShot, + std::time::Duration::from_millis(SPRING_DELAY_MS), + move || { + let Some(w) = weak.upgrade() else { return }; + // The drag may have ended, or moved on, during the dwell. + // Expanding then would rearrange the sidebar for no reason the user + // can connect to what they did. `dragging` being non-empty *is* the + // "a drag is live" test — Slint owns the gesture now, so there is no + // window flag to consult. + if ctl_cb.dragging.borrow().is_empty() || *ctl_cb.hover_id.borrow() != Some(id) { + return; + } + + ctl_cb.collapsed.borrow_mut().remove(&id); + // Remembered so it can be closed again if the drag ends elsewhere. + ctl_cb.spring_opened.borrow_mut().push(id); + + let borrow = catalog.borrow(); + if let Some(cat) = borrow.as_ref() { + // The rebuild inserts the children below this row. The pointer + // is still over this same collection, and its own `DropArea` + // re-establishes the highlight — there is no index to re-point, + // which is the second thing the native drag API removed. + refresh_tree(&w, &ctl_cb, cat); + } + }, + ); + + *ctl.spring_timer.borrow_mut() = Some(timer); +} + +/// Close whatever the spring opened during a drag that did not land in it. +/// +/// A collection the user dropped into stays open — they are working in it. One +/// merely passed over is put back, so a drag across a deep tree does not leave +/// it unfolded. +pub(super) fn collapse_spring_opened( + window: &AppWindow, + ctl: &Rc, + catalog: &Rc>>, + keep: Option, +) { + *ctl.spring_timer.borrow_mut() = None; + let opened = std::mem::take(&mut *ctl.spring_opened.borrow_mut()); + if opened.is_empty() { + return; + } + + { + let mut collapsed = ctl.collapsed.borrow_mut(); + for id in opened { + // The collection dropped into stays open, and so does every + // ancestor of it — closing a parent would hide the very row that + // just received the images. + let keep_this = keep.is_some_and(|k| { + k == id + || catalog + .borrow() + .as_ref() + .and_then(|cat| coll::descendants(cat.connection(), id).ok()) + .is_some_and(|d| d.contains(&k)) + }); + if !keep_this { + collapsed.insert(id); + } + } + } + + let borrow = catalog.borrow(); + if let Some(cat) = borrow.as_ref() { + refresh_tree(window, ctl, cat); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A solid test thumbnail. + fn thumb(w: u32, h: u32) -> slint::Image { + let mut buf = slint::SharedPixelBuffer::::new(w, h); + for p in buf.make_mut_slice() { + *p = slint::Rgba8Pixel { + r: 200, + g: 120, + b: 60, + a: 255, + }; + } + slint::Image::from_rgba8(buf) + } + + #[test] + fn one_dragged_image_composites_to_a_single_frame() { + let img = compose_drag_image(&[thumb(64, 64)]); + let size = img.size(); + // No fan for one image: the bitmap is just the thumbnail's own box. + assert_eq!(size.width, size.height, "a square thumbnail stays square"); + assert!(size.width > 0); + } + + #[test] + fn a_stack_is_wider_than_a_single_image() { + // The fan is what makes the count legible from the shape rather than + // needing a number drawn on it. + let one = compose_drag_image(&[thumb(64, 64)]); + let many = compose_drag_image(&[thumb(64, 64), thumb(64, 64), thumb(64, 64)]); + + assert!( + many.size().width > one.size().width, + "three images fan wider than one" + ); + assert!(many.size().height > one.size().height); + } + + #[test] + fn the_stack_stops_growing_past_the_layer_cap() { + // A forty-image drag must not composite forty thumbnails for a pile + // whose lower layers are hidden anyway. + let five: Vec = (0..5).map(|_| thumb(64, 64)).collect(); + let forty: Vec = (0..40).map(|_| thumb(64, 64)).collect(); + + assert_eq!( + compose_drag_image(&five).size().width, + compose_drag_image(&forty).size().width, + "past the cap the stack looks no thicker" + ); + } + + #[test] + fn an_empty_drag_composites_to_nothing() { + // Every cell in the selection may still be waiting for its preview. + assert_eq!(compose_drag_image(&[]).size().width, 0); + } + + #[test] + fn a_portrait_thumbnail_keeps_its_proportions() { + // Fitted, not stretched: a distorted frame stops the stack reading as + // photographs. + let img = compose_drag_image(&[thumb(60, 120)]); + let size = img.size(); + assert!( + size.height > size.width, + "a tall thumbnail composites tall, {}x{}", + size.width, + size.height + ); + } + + #[test] + fn mixed_orientations_do_not_panic_or_wrap() { + // The layers below the top are fitted into its box and clipped. Getting + // that wrong draws as a smear across the next row, or panics. + let img = compose_drag_image(&[thumb(120, 60), thumb(60, 120), thumb(90, 90)]); + assert!(img.size().width > 0 && img.size().height > 0); + } + + #[test] + fn a_zero_sized_thumbnail_is_not_composited() { + // A decode that produced nothing must not become a zero-divide. + assert_eq!(compose_drag_image(&[thumb(0, 0)]).size().width, 0); + } + + /// The spring's inputs: rows, which have children, and which are collapsed. + fn spring_fixture() -> ( + Vec, + Vec, + std::collections::HashSet, + ) { + let ids = vec![CollectionId(1), CollectionId(2), CollectionId(3)]; + // 1 is a collapsed parent, 2 an expanded parent, 3 a leaf. + let has_children = vec![true, true, false]; + let collapsed = [CollectionId(1)].into_iter().collect(); + (ids, has_children, collapsed) + } + + #[test] + fn hovering_a_collapsed_parent_springs_it_open() { + // The point of the gesture: reaching a child of something closed. + let (ids, kids, collapsed) = spring_fixture(); + assert_eq!( + should_spring(Some(0), &ids, &kids, &collapsed), + Some(CollectionId(1)) + ); + } + + #[test] + fn hovering_an_already_open_parent_springs_nothing() { + // Its children are already reachable; rebuilding the tree would move + // rows under the pointer for no gain. + let (ids, kids, collapsed) = spring_fixture(); + assert_eq!(should_spring(Some(1), &ids, &kids, &collapsed), None); + } + + #[test] + fn hovering_a_leaf_springs_nothing() { + // A collection with no children has nothing to open, and flashing a + // rebuild would just shift the row the user is aiming at. + let (ids, kids, collapsed) = spring_fixture(); + assert_eq!(should_spring(Some(2), &ids, &kids, &collapsed), None); + } + + #[test] + fn hovering_nothing_springs_nothing() { + let (ids, kids, collapsed) = spring_fixture(); + assert_eq!(should_spring(None, &ids, &kids, &collapsed), None); + } + + #[test] + fn a_stale_row_index_springs_nothing() { + // The hover can outlive the row model it referred to. + let (ids, kids, collapsed) = spring_fixture(); + assert_eq!(should_spring(Some(99), &ids, &kids, &collapsed), None); + } + + #[test] + fn the_spring_dwell_is_long_enough_not_to_trigger_in_passing() { + // A tree that flaps open under every passing pointer is worse than one + // that never opens. This pins the intent rather than the number: a + // reflex-speed value here would be a regression, not a tuning choice. + // Asserted in a const item rather than at runtime: the condition is + // constant either way, and clippy is right that a runtime assert on it + // is theatre. This form fails the build instead of a test run, which is + // strictly earlier, and keeps the bound where a reader of the constant + // will look for it. + const _: () = assert!( + SPRING_DELAY_MS >= 300, + "a pointer crossing a parent must not open it" + ); + const _: () = assert!( + SPRING_DELAY_MS <= 900, + "and a deliberate dwell must not feel like a hang" + ); + } +} diff --git a/ui/dr-ui/src/collections_ui/mod.rs b/ui/dr-ui/src/collections_ui/mod.rs new file mode 100644 index 0000000..d74da4f --- /dev/null +++ b/ui/dr-ui/src/collections_ui/mod.rs @@ -0,0 +1,74 @@ +//! TRACES: FR-CAT-7 | FR-UI-5 | NFR-P9 +//! The collections sidebar, grid selection, and the drag between them. +//! +//! `dr_catalog::collections` owns the data rules — hierarchy, membership, +//! revisions, cycles. This module owns the *interaction*: what is selected, +//! what a drag is carrying, and where a release lands. +//! +//! # What this module does and does not own +//! +//! Slint's `DragArea`/`DropArea` own the *gesture* — pointer capture, the +//! threshold separating a click from a drag, arbitration against the grid's +//! Flickable, the image under the cursor, and hit-testing the release. So the +//! drop arrives already addressed to a collection, and nothing here tracks +//! pointer positions or guesses a target. +//! +//! What is left here is what Slint cannot know: +//! +//! - **the payload** — which images the drag carries, built from the selection +//! at the moment the drag starts; +//! - **the spring** — a dwell timer that opens a collapsed collection so a +//! nested child can be reached mid-drag, and closes again what the drag only +//! passed over. +//! +//! An earlier version hand-rolled the whole gesture on `TouchArea` and did not +//! work, for a reason worth keeping: an interactive `Flickable` claims any drag +//! starting inside it for scrolling and cancels the child TouchArea's press, so +//! the drag could never leave the grid. +//! +//! # Selection +//! +//! Selection is by **catalog image id**, never by row index. The grid is a +//! window over the catalog (FR-CAT-4) and scrubbing replaces every row, so an +//! index-based selection would silently come to mean forty different +//! photographs after a scrub. Ids survive that; they also survive a rescan. +//! +//! # Split into one module per area (docs/dev/code-health.md CH-1) +//! +//! `controller` holds the running state and the pure drop/delete/release +//! decisions; `press` the click-and-release selection policy; `tree_sync` +//! rebuilding the sidebar and pushing catalog state into the grid; `drag` the +//! cursor bitmap and the spring; `trash` the soft delete; `wiring_grid` and +//! `wiring_tree` the callbacks, split in two because together they were the +//! largest single piece; and `rename_menu` creating, naming and the row's +//! context menu. `mod.rs` re-exports what the rest of the crate calls by +//! `collections_ui::` path, so nothing outside this module needs to change. + +mod controller; +mod drag; +mod press; +mod rename_menu; +mod trash; +mod tree_sync; +mod wiring_grid; +mod wiring_tree; + +pub use controller::CollectionsController; +pub use press::{cancel_press, commit_press, select_row}; +pub use tree_sync::{refresh_tree, sync_badges, sync_selection}; +pub use wiring_grid::wire; + +/// Test-only helpers shared by more than one of this module's submodules. +/// +/// `ids` was defined once in the pre-split file and called from tests all +/// over it — both the pure `decide_drop` tests in `controller` and the press +/// tests in `press`. Splitting the tests with the code they exercise left it +/// needed in both, so it lives here once instead of being copied. +#[cfg(test)] +pub(super) mod test_support { + use dr_types::ImageId; + + pub(super) fn ids(n: u64) -> Vec { + (1..=n).map(ImageId).collect() + } +} diff --git a/ui/dr-ui/src/collections_ui/press.rs b/ui/dr-ui/src/collections_ui/press.rs new file mode 100644 index 0000000..528f568 --- /dev/null +++ b/ui/dr-ui/src/collections_ui/press.rs @@ -0,0 +1,763 @@ +//! A press, and the release that follows it: what selects, what waits for +//! the tap that confirms it, and how a gesture that turns out not to have +//! been a press is undone. + +use std::collections::BTreeSet; +use std::rc::Rc; + +use dr_types::ImageId; + +use crate::AppWindow; + +use super::controller::CollectionsController; +use super::tree_sync::sync_selection; + +/// The selection as it stood before a press, for [`cancel_press`]. +pub(super) struct PressUndo { + selection: BTreeSet, + anchor: Option, + cursor: Option, +} + +impl PressUndo { + /// Everything [`select_row`] is about to change. + /// + /// The three are the whole of what a press touches, which is the property + /// the restore depends on and the reason it is worth a test of its own: a + /// press that grew a fourth piece of state would leave that piece behind + /// after a cancel, silently. + fn capture( + selection: &BTreeSet, + anchor: Option, + cursor: Option, + ) -> Self { + Self { + selection: selection.clone(), + anchor, + cursor, + } + } + + /// Put it all back. Returns the one the caller holds in a `Cell`. + fn restore( + self, + selection: &mut BTreeSet, + anchor: &mut Option, + ) -> Option { + *selection = self.selection; + *anchor = self.anchor; + self.cursor + } +} + +/// TRACES: FR-CAT-7 | FR-UI-4 +/// What a press did, and what is left for the release to do. +/// +/// Only one press has anything left over, and it is the one every multi-image +/// drag depends on — see [`Press::Deferred`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[must_use] +pub enum Press { + /// The selection is already what this press means. Nothing to finish. + Applied, + /// **Taking a photograph out of the selection waits for the release.** + /// + /// Putting one *in* has to happen on the press: the drag that may follow + /// reads the selection to decide what it carries, and by the time the + /// finger lifts it is over the sidebar. Taking one out is the opposite — + /// nothing between the press and the release needs the image gone, and one + /// thing very much needs it to stay. + /// + /// A plain press has said so since selection was written: pressing an + /// already-selected cell leaves the selection alone. Ctrl did not, and on a + /// tablet *every* press is a ctrl-press — that is what selection mode is. + /// So grabbing one of forty selected photographs deselected it on the way + /// down; the drag that followed found the cell under the finger no longer + /// in the selection, took that to mean an unselected image was being + /// dragged, and carried it alone. Forty photographs became one, and the + /// only clue was the grabbed cell's ring blinking out. + /// + /// So the removal is handed back for the *click* to apply, and a click + /// fires only for a press that stayed put — never for one that became a + /// drag. See `tap-slop` in `library.slint`. + Deferred(ImageId), +} + +/// Apply a press to the selection. +/// +/// Split from the callback so the policy is testable without a window: this is +/// the part a user notices being wrong. +/// +/// - **plain** — replace the selection with this one image +/// - **ctrl** — add this image to the selection, keeping the rest, and move the +/// anchor here; taking one *out* is [deferred](Press::Deferred) to the release +/// - **shift** — select the range from the anchor to here, *replacing* what was +/// selected; the anchor stays put, so an overshoot is corrected by +/// shift-clicking the right cell rather than starting again +/// - **ctrl+shift** — the same range, *added* to the selection, for picking up a +/// second run without losing the first +/// +/// A press on an image that is *already* selected never removes it here — +/// plainly or with ctrl. That is what makes dragging a multi-selection possible +/// at all: the press that begins the drag would otherwise take the grabbed +/// photograph out from under it. +/// +/// `ids` is the **loaded window**, `offset` where it starts in the library and +/// `row` a position within it; the anchor is kept as `offset + row`, an +/// ordinal that still names the same photograph after the window has moved. +/// +/// `span` turns a run of ordinals into the ids it names — the catalog's job, +/// since the run is mostly not loaded. Passed in rather than reached for so +/// this stays a pure function of what it is given. +#[allow(clippy::too_many_arguments)] +pub fn apply_press( + selection: &mut BTreeSet, + anchor: &mut Option, + ids: &[ImageId], + offset: usize, + row: usize, + ctrl: bool, + shift: bool, + span: &dyn Fn(usize, usize) -> Vec, +) -> Press { + let Some(&id) = ids.get(row) else { + return Press::Applied; + }; + let here = offset + row; + + if shift { + let Some(from) = *anchor else { + // No anchor to extend from: behave like a plain click and become + // the anchor, so the *next* shift-click has a range to describe. + selection.clear(); + selection.insert(id); + *anchor = Some(here); + return Press::Applied; + }; + + // The anchor deliberately does **not** move. Shift-clicking again + // re-describes the range from the same origin, so a user who overshoots + // corrects by shift-clicking the right cell rather than starting over. + // That also means the previous range must be cleared first — extending + // without clearing turns a correction into a union, and the user ends up + // dragging cells they thought they had deselected. + // + // Ctrl+shift is the exception: it *adds* a range to what is already + // selected, which is how a second run is picked up without losing the + // first. + if !ctrl { + selection.clear(); + } + + let (lo, hi) = if from <= here { + (from, here) + } else { + (here, from) + }; + + // The run is described in ordinals and resolved by the catalog, which + // is the only thing that knows what lies between them. Selection is by + // id, and an image outside the loaded window has no id here — so an + // earlier version truncated the run to what was on screen, and + // shift-clicking two ends of a morning selected the dozen cells that + // happened to be loaded. The user cannot see that they did not get + // what they asked for until the drop files a dozen photographs + // instead of two hundred. + let mut run = span(lo, hi); + if run.is_empty() { + // Nothing to ask — no library open, or a query that failed. The + // loaded window is a poorer answer than the catalog's and a far + // better one than selecting nothing. + // + // The slice is always in range and needs no guard: `ids.get(row)` + // succeeded, so the window is non-empty and `here` is inside it, + // and `here` is one of the two bounds. + let last = ids.len() - 1; + let lo_row = lo.saturating_sub(offset); + let hi_row = hi.saturating_sub(offset).min(last); + run = ids[lo_row..=hi_row].to_vec(); + } + selection.extend(run); + return Press::Applied; + } + + if ctrl { + *anchor = Some(here); + + // Taking one out waits for the release — see [`Press::Deferred`]. + if selection.contains(&id) { + return Press::Deferred(id); + } + + selection.insert(id); + return Press::Applied; + } + + // Plain press on something already selected: leave it. The drag that may + // follow carries the whole selection, and collapsing it here would make a + // multi-image drag impossible to start. + if selection.contains(&id) { + *anchor = Some(here); + return Press::Applied; + } + + selection.clear(); + selection.insert(id); + *anchor = Some(here); + Press::Applied +} + +/// Apply a press and push the result into the grid — the whole of what a +/// click, or an arrow key, does to the selection. +/// +/// Shared so the keyboard and the pointer cannot drift: they are the same +/// gesture reached two ways, and the moment one of them grew its own copy of +/// the selection policy, "click here, shift+down twice" would stop meaning +/// what "click here, shift-click there" means. +pub fn select_row( + window: &AppWindow, + ctl: &Rc, + ids: &[ImageId], + offset: usize, + row: usize, + ctrl: bool, + shift: bool, +) { + // Before anything moves — see `press_undo`. A press that turns out to be + // the opening finger of a pinch is undone from here. + *ctl.press_undo.borrow_mut() = Some(PressUndo::capture( + &ctl.selection.borrow(), + *ctl.anchor.borrow(), + ctl.cursor(), + )); + + // Whatever the last press left pending is answered by this one: two + // presses without a click in between means the first never was a tap. + ctl.pending_toggle.set(None); + + if let Press::Deferred(id) = apply_press( + &mut ctl.selection.borrow_mut(), + &mut ctl.anchor.borrow_mut(), + ids, + offset, + row, + ctrl, + shift, + &|first, last| ctl.span(first, last), + ) { + ctl.pending_toggle.set(Some(id)); + } + // The cursor follows the press, so an arrow key after a click continues + // from the cell that was clicked rather than from wherever the keyboard + // was last. + ctl.set_cursor(Some(offset + row)); + sync_selection(window, ctl, ids); +} + +/// TRACES: FR-CAT-7 | FR-UI-4 +/// Finish a press that turned out to be a tap. +/// +/// The other half of [`Press::Deferred`]: a ctrl-press on an already-selected +/// photograph leaves it in, because the drag that may follow has to be able to +/// carry it, and this takes it out again once the release has proved there was +/// no drag. +/// +/// Called from the click, which Slint reports only for a press that stayed +/// within `tap-slop` of where it landed — so a press that became a drag never +/// reaches here, and a press taken over by the Flickable never reaches here +/// either. Both leave the pending removal to be dropped by the next press. +pub fn commit_press(window: &AppWindow, ctl: &Rc, ids: &[ImageId]) { + let Some(id) = ctl.pending_toggle.take() else { + return; + }; + ctl.selection.borrow_mut().remove(&id); + sync_selection(window, ctl, ids); +} + +/// TRACES: FR-UI-4 +/// Put the selection back as it was before the most recent press. +/// +/// For the gesture that begins as a press and turns out to be something else. +/// A pinch is the case that matters: it starts as one finger on a cell, which +/// selects it, and only becomes a pinch when the second finger lands — by +/// which point the user has selected a photograph they were not reaching for. +/// +/// Idempotent, and a no-op when there is nothing to undo, so it is safe to +/// call on every gesture start rather than only on the ones that need it. +pub fn cancel_press(window: &AppWindow, ctl: &Rc, ids: &[ImageId]) { + // The press is being unmade, so what it left for the release to finish is + // unmade with it. Cleared before the early return: a press that changed + // nothing to undo can still have deferred a removal — and a finger that + // held long enough to pick a photograph up before the second one landed + // has to put it back down, or the ring stays open around a cell nobody is + // touching and the grid stays frozen with it. + ctl.pending_toggle.set(None); + window.set_library_held_row(-1); + + let Some(undo) = ctl.press_undo.borrow_mut().take() else { + return; + }; + let cursor = undo.restore( + &mut ctl.selection.borrow_mut(), + &mut ctl.anchor.borrow_mut(), + ); + ctl.set_cursor(cursor); + sync_selection(window, ctl, ids); +} + +#[cfg(test)] +mod tests { + use crate::collections_ui::test_support::*; + + use super::*; + + /// A press and the release that follows it — which is what a click is. + /// + /// These tests are about what a *user* sees, and a user only ever presses + /// and lets go. Calling [`apply_press`] alone would drop the half of the + /// policy that waits for the release ([`Press::Deferred`]) and quietly + /// assert the wrong thing about ctrl. + #[allow(clippy::too_many_arguments)] + fn click( + selection: &mut BTreeSet, + anchor: &mut Option, + ids: &[ImageId], + offset: usize, + row: usize, + ctrl: bool, + shift: bool, + span: &dyn Fn(usize, usize) -> Vec, + ) { + if let Press::Deferred(id) = + apply_press(selection, anchor, ids, offset, row, ctrl, shift, span) + { + selection.remove(&id); + } + } + + /// The catalog's answer to "what is between these two ordinals". + /// + /// Stands in for [`crate::library_ui::LibraryController::ids_in_span`], + /// which does the same thing with a `LIMIT`/`OFFSET`: the whole library is + /// available to the query whatever the grid happens to have loaded, and an + /// ordinal indexes it directly. + fn library(all: &[ImageId]) -> impl Fn(usize, usize) -> Vec + '_ { + move |first, last| { + all.iter() + .copied() + .skip(first) + .take((last + 1).saturating_sub(first)) + .collect() + } + } + + /// No catalog to ask — what a press sees before a library is open, and if + /// the query fails. + fn unloaded(_first: usize, _last: usize) -> Vec { + Vec::new() + } + + #[test] + fn a_plain_press_replaces_the_selection() { + let all = ids(5); + let span = library(&all); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span); + click(&mut sel, &mut anchor, &all, 0, 2, false, false, &span); + + assert_eq!(sel.iter().copied().collect::>(), vec![ImageId(3)]); + } + + #[test] + fn ctrl_press_adds_and_then_removes() { + let all = ids(5); + let span = library(&all); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span); + click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span); + assert_eq!(sel.len(), 2); + + // Toggling: a second ctrl-press on the same cell takes it out again. + click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span); + assert_eq!(sel.iter().copied().collect::>(), vec![ImageId(1)]); + } + + #[test] + fn shift_press_extends_a_contiguous_range() { + let all = ids(10); + let span = library(&all); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + click(&mut sel, &mut anchor, &all, 0, 2, false, false, &span); + click(&mut sel, &mut anchor, &all, 0, 6, false, true, &span); + + assert_eq!(sel.len(), 5, "rows 2..=6 inclusive"); + assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(7))); + } + + #[test] + fn shift_extends_backwards_too() { + let all = ids(10); + let span = library(&all); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + click(&mut sel, &mut anchor, &all, 0, 6, false, false, &span); + click(&mut sel, &mut anchor, &all, 0, 2, false, true, &span); + assert_eq!(sel.len(), 5); + } + + #[test] + fn a_second_shift_click_re_describes_the_range_rather_than_adding_to_it() { + // Overshooting and correcting is the common case. Extending without + // clearing would turn the correction into a union, and the user would + // drag cells they believed they had just deselected. + let all = ids(20); + let span = library(&all); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + click(&mut sel, &mut anchor, &all, 0, 5, false, false, &span); + click(&mut sel, &mut anchor, &all, 0, 15, false, true, &span); + assert_eq!(sel.len(), 11, "rows 5..=15"); + + // Corrected to a shorter range from the same anchor. + click(&mut sel, &mut anchor, &all, 0, 8, false, true, &span); + assert_eq!(sel.len(), 4, "rows 5..=8, and nothing from the first range"); + assert!(!sel.contains(&ImageId(16)), "row 15 is no longer selected"); + } + + #[test] + fn the_anchor_stays_put_across_shift_clicks() { + // If the anchor moved to each shift-click, a range could only ever be + // grown, never corrected inward. + let all = ids(20); + let span = library(&all); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + click(&mut sel, &mut anchor, &all, 0, 10, false, false, &span); + click(&mut sel, &mut anchor, &all, 0, 14, false, true, &span); + click(&mut sel, &mut anchor, &all, 0, 12, false, true, &span); + + assert_eq!(anchor, Some(10)); + assert_eq!(sel.len(), 3, "rows 10..=12"); + } + + /// TRACES: FR-UI-2 | FR-UI-4 + /// Two taps on one cell put it back where it started, and take nothing else. + /// + /// This is the behaviour that replaced the double-tap range. That gesture + /// selected everything between the cell and wherever the selection began — + /// silently, with no visible state, from a thing a hand does by accident. + /// "Select to…" does the same job and says so first, so a double tap is now + /// two toggles and nothing more: the only outcome a user can predict from + /// what is on the screen. + #[test] + fn two_taps_on_one_cell_cancel_out_and_take_no_range() { + let all = ids(30); + let span = library(&all); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + // Selecting began somewhere else, so a range gesture would have had an + // anchor to sweep from. + click(&mut sel, &mut anchor, &all, 0, 4, false, false, &span); + assert_eq!(sel.len(), 1); + + tap(&mut sel, &mut anchor, &all, 11); + tap(&mut sel, &mut anchor, &all, 11); + + assert_eq!( + sel.iter().copied().collect::>(), + vec![ImageId(5)], + "a double tap took a run instead of toggling one cell twice" + ); + } + + /// One tap in selection mode: a press reported as ctrl-held, which is what + /// `library.slint` sends while the mode is on. + fn tap(sel: &mut BTreeSet, anchor: &mut Option, all: &[ImageId], row: usize) { + click(sel, anchor, all, 0, row, true, false, &library(all)); + } + + /// TRACES: FR-CAT-7 | FR-UI-4 + /// The bug that made a forty-image drag file one photograph. + /// + /// In selection mode every press arrives as a ctrl-press, so grabbing one + /// of the selected cells to drag them all used to *deselect* the cell being + /// grabbed. `drag-started` then saw a press on an image that was not in the + /// selection, concluded that was the gesture, and carried it alone. + #[test] + fn grabbing_a_selected_cell_leaves_the_whole_selection_to_drag() { + let all = ids(20); + let span = library(&all); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + // Three photographs picked in selection mode, which reports ctrl. + for row in [2, 5, 9] { + click(&mut sel, &mut anchor, &all, 0, row, true, false, &span); + } + assert_eq!(sel.len(), 3); + + // The press that begins the drag, on one of the three. + let outcome = apply_press(&mut sel, &mut anchor, &all, 0, 5, true, false, &span); + + assert_eq!( + outcome, + Press::Deferred(ImageId(6)), + "the removal has to be handed back, not performed" + ); + assert_eq!( + sel.len(), + 3, + "the drag reads the selection next, and all three have to still be in it" + ); + assert!(sel.contains(&ImageId(6)), "least of all the one being held"); + } + + /// And the other half: with no drag, the release still takes it out. + /// + /// A tap in selection mode has to toggle, or there is no way to correct a + /// mis-tap short of leaving the mode. + #[test] + fn a_tap_on_a_selected_cell_still_takes_it_out() { + let all = ids(20); + let span = library(&all); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + for row in [2, 5, 9] { + click(&mut sel, &mut anchor, &all, 0, row, true, false, &span); + } + + click(&mut sel, &mut anchor, &all, 0, 5, true, false, &span); + + assert_eq!( + sel.iter().copied().collect::>(), + vec![ImageId(3), ImageId(10)], + "the tapped photograph is out and the other two stayed" + ); + } + + /// The anchor moves on the press whether or not the removal does. + /// + /// "Select to…" measures from the anchor, and a run taken after tapping a + /// selected cell has to start where the user last touched — otherwise the + /// gesture sweeps from wherever the anchor happened to be left. + #[test] + fn a_deferred_press_still_moves_the_anchor() { + let all = ids(20); + let span = library(&all); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span); + let _ = apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false, &span); + + assert_eq!(anchor, Some(3)); + } + + #[test] + fn ctrl_shift_adds_a_second_range_to_the_selection() { + // Picking up a second run without losing the first: the one case where + // a shift-click must not clear. + let all = ids(20); + let span = library(&all); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span); + click(&mut sel, &mut anchor, &all, 0, 2, false, true, &span); + assert_eq!(sel.len(), 3); + + // A new anchor by ctrl-click, then a ctrl+shift range from it. + click(&mut sel, &mut anchor, &all, 0, 10, true, false, &span); + click(&mut sel, &mut anchor, &all, 0, 12, true, true, &span); + + assert_eq!(sel.len(), 6, "rows 0..=2 and 10..=12"); + assert!(sel.contains(&ImageId(1)) && sel.contains(&ImageId(13))); + } + + #[test] + fn a_plain_press_on_a_selected_cell_keeps_the_selection() { + // This is what makes a multi-image drag possible: the press that starts + // the drag must not collapse what it is about to carry. + let all = ids(5); + let span = library(&all); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span); + click(&mut sel, &mut anchor, &all, 0, 1, true, false, &span); + click(&mut sel, &mut anchor, &all, 0, 2, true, false, &span); + assert_eq!(sel.len(), 3); + + // Pressing one of the three to begin a drag. + click(&mut sel, &mut anchor, &all, 0, 1, false, false, &span); + assert_eq!(sel.len(), 3, "the selection survived the press"); + } + + #[test] + fn a_press_undone_leaves_the_selection_exactly_as_it_was() { + // A pinch opens as one finger on a cell, so by the time it is known to + // be a pinch a photograph has been selected that the user was only + // reaching past. Undoing it has to be *exact*: a cancel that restored + // the selection but left the anchor moved would make the next + // shift-click select a run from a cell nobody pointed at. + let all = ids(6); + let span = library(&all); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + // A selection built the ordinary way, and the state it leaves behind. + click(&mut sel, &mut anchor, &all, 0, 1, false, false, &span); + click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span); + let before = (sel.clone(), anchor); + + // The finger that opens a pinch. + let undo = PressUndo::capture(&sel, anchor, Some(3)); + click(&mut sel, &mut anchor, &all, 0, 5, false, false, &span); + assert_ne!( + (sel.clone(), anchor), + before, + "the press has to change something, or this proves nothing" + ); + + let cursor = undo.restore(&mut sel, &mut anchor); + + assert_eq!((sel, anchor), before, "selection and anchor are back"); + assert_eq!(cursor, Some(3), "and the keyboard cursor"); + } + + #[test] + fn a_press_past_the_end_of_the_window_is_ignored() { + // The grid is windowed and a stale row index can arrive after a scrub. + let all = ids(3); + let span = library(&all); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + click(&mut sel, &mut anchor, &all, 0, 99, false, false, &span); + assert!(sel.is_empty()); + } + + #[test] + fn an_anchor_survives_the_window_moving_under_it() { + // The grid is a window over the catalog, and the window moves whenever + // the user scrolls. Held as a row, an anchor set before a scroll + // described a range from whatever photograph had since drifted into + // that row — so shift-clicking after scrolling selected a run the user + // never pointed at, silently and with no way to tell. + let all = ids(20); + let span = library(&all); + let first: Vec<_> = all[0..10].to_vec(); + let later: Vec<_> = all[4..14].to_vec(); + + let mut sel = BTreeSet::new(); + let mut anchor = None; + + // Anchor on the sixth image, in a window starting at the beginning. + click(&mut sel, &mut anchor, &first, 0, 5, false, false, &span); + assert_eq!(anchor, Some(5), "the anchor is an ordinal, not a row"); + + // The user scrolls — the window now starts four images in — and + // shift-clicks the image at ordinal 10. + click(&mut sel, &mut anchor, &later, 4, 6, false, true, &span); + + assert_eq!(sel.len(), 6, "ordinals 5..=10"); + assert!(sel.contains(&ImageId(6)), "the anchored image is still in"); + assert!(sel.contains(&ImageId(11)), "up to the one shift-clicked"); + assert!(!sel.contains(&ImageId(5)), "and nothing before the anchor"); + } + + #[test] + fn a_range_reaching_outside_the_window_selects_the_whole_run() { + // The bug this exists for: the anchor is far above the loaded window, + // so all but four of the photographs in the range are off screen. They + // are still what the user asked for, and the catalog is what knows + // their ids — reading the range from the window selected the four that + // happened to be loaded and looked as though it had worked. + let all = ids(30); + let span = library(&all); + let window: Vec<_> = all[10..20].to_vec(); + + let mut sel = BTreeSet::new(); + let mut anchor = Some(0); + + click(&mut sel, &mut anchor, &window, 10, 3, false, true, &span); + + assert_eq!(sel.len(), 14, "ordinals 0..=13, loaded or not"); + assert!( + sel.contains(&ImageId(1)), + "the anchored image, never loaded" + ); + assert!(sel.contains(&ImageId(14)), "up to the cell pressed"); + assert!(!sel.contains(&ImageId(15)), "and no further"); + } + + #[test] + fn a_range_running_off_the_far_end_is_whole_too() { + // The mirror of the case above, with the anchor *ahead* of the press + // instead of behind it. Neither direction may stop at the window, and + // neither may wrap round to the other end of it. + let all = ids(30); + let span = library(&all); + let window: Vec<_> = all[0..5].to_vec(); + + let mut sel = BTreeSet::new(); + let mut anchor = Some(25); + + click(&mut sel, &mut anchor, &window, 0, 2, false, true, &span); + + assert_eq!(sel.len(), 24, "ordinals 2..=25"); + assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(26))); + assert!( + !sel.contains(&ImageId(1)), + "nothing before the pressed cell" + ); + } + + #[test] + fn a_range_with_no_catalog_to_ask_falls_back_to_the_window() { + // Before a library is open, and if the query fails. What is loaded is + // a poorer answer than the catalog's and a far better one than a + // gesture that appears to do nothing. + let all = ids(30); + let window: Vec<_> = all[10..20].to_vec(); + + let mut sel = BTreeSet::new(); + let mut anchor = Some(0); + + click( + &mut sel, + &mut anchor, + &window, + 10, + 3, + false, + true, + &unloaded, + ); + + assert_eq!(sel.len(), 4, "ordinals 10..=13, the loaded part of 0..=13"); + assert!(sel.contains(&ImageId(11)) && sel.contains(&ImageId(14))); + } + + #[test] + fn shift_without_an_anchor_selects_just_the_one() { + let all = ids(5); + let span = library(&all); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + click(&mut sel, &mut anchor, &all, 0, 3, false, true, &span); + assert_eq!(sel.iter().copied().collect::>(), vec![ImageId(4)]); + } +} diff --git a/ui/dr-ui/src/collections_ui/rename_menu.rs b/ui/dr-ui/src/collections_ui/rename_menu.rs new file mode 100644 index 0000000..77157e7 --- /dev/null +++ b/ui/dr-ui/src/collections_ui/rename_menu.rs @@ -0,0 +1,461 @@ +//! Creating, renaming and naming collections, and the row context menu +//! that reaches most of it. +//! +//! `apply_rename` and `unique_name` are kept apart from the callbacks that +//! call them because they are the part a user notices being wrong, and the +//! part that can be tested without a window. + +use std::rc::Rc; + +use dr_catalog::collections::{self as coll, CollectionKind}; +use dr_catalog::Catalog; +use dr_types::CollectionId; +use rusqlite::OptionalExtension as _; +use slint::Model as _; + +use crate::AppWindow; + +use super::controller::{delete_warning, menu_detail, CollectionsController}; +use super::tree_sync::{direct_holdings, refresh_tree}; + +/// TRACES: FR-CAT-7 +/// Open the row menu on `id`, or leave it shut if the collection is gone. +/// +/// Everything the sheet draws is computed here, in one place, so the menu +/// cannot offer "Move to top level" on a collection already at the top, or a +/// delete warning about children it no longer has. Reopened from scratch after +/// every action for the same reason. +pub(super) fn open_row_menu( + window: &AppWindow, + ctl: &Rc, + catalog: &Catalog, + id: CollectionId, +) { + let conn = catalog.connection(); + let row: Option<(String, Option, i64)> = conn + .query_row( + "SELECT name, parent_id, kind FROM collections WHERE id = ?1 AND deleted = 0", + [id.0 as i64], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .optional() + .unwrap_or(None); + let Some((name, parent, kind)) = row else { + // Deleted underneath us — by a merge, or by the action that just ran. + close_row_menu(window, ctl); + return; + }; + + let (holds, children) = direct_holdings(conn, id).unwrap_or((0, 0)); + + *ctl.menu_for.borrow_mut() = Some(id); + ctl.menu_confirming.set(false); + window.set_collection_menu_confirming(false); + window.set_collection_menu_title(name.as_str().into()); + window.set_collection_menu_detail(menu_detail(holds, children).into()); + window.set_collection_menu_nested(parent.is_some()); + window.set_collection_menu_smart(kind == CollectionKind::Smart as i64); + window.set_collection_menu_confirm_detail(delete_warning(&name, holds, children).into()); + window.set_collection_menu_delete_label("Delete".into()); + + // TRACES: FR-NC-6a + // Read off the row the sidebar already drew rather than recomputed: the + // roll-up is one pass over the whole tree, and a menu that computed its own + // could disagree with the tray beside the name it is titled with. + let pinned = ctl + .row_ids + .borrow() + .iter() + .position(|&c| c == id) + .and_then(|row| window.get_collection_rows().row_data(row)) + .is_some_and(|r| r.pinned); + window.set_collection_menu_pinned(pinned); +} + +/// Shut the row menu, both halves. +/// +/// The controller's copy is what the handlers act on and the window property is +/// what draws the sheet; clearing one without the other leaves either an +/// invisible menu that still answers, or a visible one that acts on nothing. +pub(super) fn close_row_menu(window: &AppWindow, ctl: &Rc) { + *ctl.menu_for.borrow_mut() = None; + ctl.menu_confirming.set(false); + window.set_collection_menu_title(slint::SharedString::new()); + window.set_collection_menu_confirming(false); +} + +/// Close the rename field, whatever the outcome. +/// +/// Both halves together, always: the controller's copy is what a stray second +/// commit is tested against, and the window property is what draws the field. +/// Clearing one without the other either leaves a field open that nothing will +/// close, or closes one that Rust still believes is open. +pub(super) fn close_rename(window: &AppWindow, ctl: &Rc) { + *ctl.renaming.borrow_mut() = None; + window.set_collection_renaming(0); +} + +/// What a committed rename did. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum Rename { + /// Written, with the name as stored — trimmed. + Applied(String), + /// The name was the one it already had, so nothing was written. + /// + /// Distinguished from `Applied` because every write bumps the revision, and + /// a rename to the same name would let a device that changed nothing win a + /// merge against one that did real work. + Unchanged, +} + +/// Validate a typed name and store it. +/// +/// Split out so the rules are testable without a window — they are the part a +/// user runs into: +/// +/// - **blank is refused.** A nameless row is unclickable and unfindable, and +/// the schema is happy to store one. +/// - **a sibling's name is refused.** Two identically-named collections in one +/// parent are indistinguishable in the sidebar, which is how images end up in +/// the wrong one. The same reasoning as [`unique_name`], enforced here rather +/// than silently suffixing: the user typed a specific name and quietly +/// storing a different one is worse than saying no. +/// +/// Case-insensitive against siblings, because the sidebar sorts that way and +/// "Iceland" beside "iceland" is the same trap as an exact duplicate. +pub(super) fn apply_rename( + conn: &rusqlite::Connection, + id: CollectionId, + typed: &str, +) -> Result { + let name = typed.trim(); + if name.is_empty() { + return Err(dr_catalog::CatalogError::BadName( + "a collection needs a name".into(), + )); + } + + // The current name, which also proves the collection is still there. + let current: String = conn.query_row( + "SELECT name FROM collections WHERE id = ?1 AND deleted = 0", + [id.0 as i64], + |r| r.get(0), + )?; + if current == name { + return Ok(Rename::Unchanged); + } + + // Siblings, excluding this collection: a rename that only changes case is a + // real rename, and must not be refused as a clash with itself. + let clash: Option = conn + .query_row( + "SELECT 1 FROM collections + WHERE deleted = 0 + AND id != ?1 + AND name = ?2 COLLATE NOCASE + AND parent_id IS (SELECT parent_id FROM collections WHERE id = ?1)", + rusqlite::params![id.0 as i64, name], + |r| r.get(0), + ) + .optional()?; + if clash.is_some() { + return Err(dr_catalog::CatalogError::BadName(format!( + "there is already a “{name}” here" + ))); + } + + coll::rename(conn, id, name)?; + Ok(Rename::Applied(name.to_string())) +} + +/// TRACES: FR-CAT-7 +/// Make a collection inside `parent` and open its name field. +/// +/// Shared by the header's `+` and the row menu's "New collection inside", +/// which differ only in where the parent comes from — and must not differ in +/// anything else, or building a tree from the tree would behave unlike +/// building one from the header. +pub(super) fn create_child( + window: &AppWindow, + ctl: &Rc, + catalog: &Catalog, + parent: Option, +) { + let name = unique_name(catalog.connection(), parent); + + match coll::create(catalog.connection(), &name, parent, CollectionKind::Manual) { + Ok(id) => { + window.set_collection_error(slint::SharedString::new()); + // A new child is useless if its parent is collapsed. + if let Some(p) = parent { + ctl.collapsed.borrow_mut().remove(&p); + } + // Straight into the name field, with "New collection" selected. + // The name is a placeholder nobody wants to keep, so making the + // user find the rename gesture afterwards is asking them to finish + // a job we started. + // + // Opened *after* the rebuild, and the order is load-bearing: + // `refresh_tree` replaces the row model, which destroys and + // recreates every row. A field opened before it would be torn down + // along with the `init` that focuses it, leaving an edit box + // nothing had typed into. Setting the property afterwards puts the + // field on a row that already exists. + refresh_tree(window, ctl, catalog); + *ctl.renaming.borrow_mut() = Some(id); + window.set_collection_renaming(id.0 as i32); + log::info!("created collection {} ({name})", id.0); + } + Err(e) => window.set_collection_error(format!("creating collection: {e}").into()), + } +} + +/// A name no sibling is already using. +/// +/// Duplicate names are legal in the schema, and two identically-named +/// collections in one parent are indistinguishable in the sidebar — which is +/// how images end up in the wrong one. +pub(super) fn unique_name(conn: &rusqlite::Connection, parent: Option) -> String { + let taken: Vec = { + let sql = match parent { + Some(_) => "SELECT name FROM collections WHERE deleted = 0 AND parent_id = ?1", + None => "SELECT name FROM collections WHERE deleted = 0 AND parent_id IS NULL", + }; + let Ok(mut stmt) = conn.prepare(sql) else { + return "New collection".into(); + }; + // Collected inside each arm: the two `query_map` calls bind different + // parameter types, so their iterators are different types and cannot + // be the arms of one `match`. + match parent { + Some(p) => stmt + .query_map([p.0 as i64], |r| r.get::<_, String>(0)) + .map(|rows| rows.flatten().collect()) + .unwrap_or_default(), + None => stmt + .query_map([], |r| r.get::<_, String>(0)) + .map(|rows| rows.flatten().collect()) + .unwrap_or_default(), + } + }; + + let base = "New collection"; + if !taken.iter().any(|t| t == base) { + return base.into(); + } + for n in 2..1000 { + let candidate = format!("{base} {n}"); + if !taken.contains(&candidate) { + return candidate; + } + } + base.into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_collections_do_not_share_a_name_with_a_sibling() { + // Two identically-named collections in one parent are + // indistinguishable in the sidebar, which is how images land in the + // wrong one. + let cat = Catalog::in_memory().unwrap(); + let c = cat.connection(); + + let first = unique_name(c, None); + coll::create(c, &first, None, CollectionKind::Manual).unwrap(); + let second = unique_name(c, None); + coll::create(c, &second, None, CollectionKind::Manual).unwrap(); + + assert_ne!(first, second); + assert_eq!(first, "New collection"); + assert_eq!(second, "New collection 2"); + } + + /// A catalog holding one top-level collection, and its id. + fn with_one(name: &str) -> (Catalog, CollectionId) { + let cat = Catalog::in_memory().unwrap(); + let id = coll::create(cat.connection(), name, None, CollectionKind::Manual).unwrap(); + (cat, id) + } + + fn name_of(cat: &Catalog, id: CollectionId) -> String { + cat.connection() + .query_row( + "SELECT name FROM collections WHERE id = ?1", + [id.0 as i64], + |r| r.get(0), + ) + .unwrap() + } + + #[test] + fn a_rename_stores_the_new_name() { + let (cat, id) = with_one("Untitled"); + + let out = apply_rename(cat.connection(), id, "Iceland").unwrap(); + assert_eq!(out, Rename::Applied("Iceland".into())); + assert_eq!(name_of(&cat, id), "Iceland"); + } + + #[test] + fn surrounding_whitespace_is_trimmed_rather_than_stored() { + // A trailing space is invisible in the sidebar and makes two + // collections look identical while sorting them apart. + let (cat, id) = with_one("Untitled"); + + assert_eq!( + apply_rename(cat.connection(), id, " Iceland ").unwrap(), + Rename::Applied("Iceland".into()) + ); + assert_eq!(name_of(&cat, id), "Iceland"); + } + + #[test] + fn a_blank_name_is_refused() { + // A nameless row cannot be read or aimed at, and the schema would take + // one happily. + let (cat, id) = with_one("Iceland"); + + assert!(matches!( + apply_rename(cat.connection(), id, " "), + Err(dr_catalog::CatalogError::BadName(_)) + )); + assert_eq!(name_of(&cat, id), "Iceland", "the old name stands"); + } + + #[test] + fn renaming_to_the_current_name_writes_nothing() { + // Every write bumps the revision, and a no-op rename would let an idle + // device beat one that did real work at the next merge. + let (cat, id) = with_one("Iceland"); + let rev = |c: &Catalog| -> i64 { + c.connection() + .query_row( + "SELECT revision FROM collections WHERE id = ?1", + [id.0 as i64], + |r| r.get(0), + ) + .unwrap() + }; + + let before = rev(&cat); + assert_eq!( + apply_rename(cat.connection(), id, "Iceland").unwrap(), + Rename::Unchanged + ); + assert_eq!(rev(&cat), before); + } + + #[test] + fn a_siblings_name_is_refused() { + // Two identically-named collections in one parent are + // indistinguishable in the sidebar, which is how images land in the + // wrong one. + let cat = Catalog::in_memory().unwrap(); + let c = cat.connection(); + coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap(); + let japan = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap(); + + assert!(matches!( + apply_rename(c, japan, "Iceland"), + Err(dr_catalog::CatalogError::BadName(_)) + )); + assert_eq!(name_of(&cat, japan), "Japan"); + } + + #[test] + fn a_siblings_name_is_refused_in_a_different_case_too() { + // The sidebar sorts case-insensitively, so "iceland" beside "Iceland" + // is the same trap as an exact duplicate. + let cat = Catalog::in_memory().unwrap(); + let c = cat.connection(); + coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap(); + let japan = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap(); + + assert!(matches!( + apply_rename(c, japan, "iceland"), + Err(dr_catalog::CatalogError::BadName(_)) + )); + } + + #[test] + fn changing_only_the_case_of_a_name_is_allowed() { + // The clash check excludes the collection itself, or fixing the + // capitalisation of a name would be refused as a clash with itself. + let (cat, id) = with_one("iceland"); + + assert_eq!( + apply_rename(cat.connection(), id, "Iceland").unwrap(), + Rename::Applied("Iceland".into()) + ); + assert_eq!(name_of(&cat, id), "Iceland"); + } + + #[test] + fn a_name_used_under_a_different_parent_is_free() { + // Uniqueness is per parent: "Selects" inside two different trips is + // unambiguous and normal. + let cat = Catalog::in_memory().unwrap(); + let c = cat.connection(); + let trips = coll::create(c, "Trips", None, CollectionKind::Manual).unwrap(); + coll::create(c, "Selects", Some(trips), CollectionKind::Manual).unwrap(); + let top = coll::create(c, "Loose", None, CollectionKind::Manual).unwrap(); + + assert_eq!( + apply_rename(c, top, "Selects").unwrap(), + Rename::Applied("Selects".into()) + ); + } + + #[test] + fn two_top_level_collections_still_clash_despite_a_null_parent() { + // `parent_id IS NULL` never matches with `=`, so a naive clash query + // would silently allow every duplicate at the top level — which is + // where most collections live. + let cat = Catalog::in_memory().unwrap(); + let c = cat.connection(); + coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap(); + let other = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap(); + + assert!( + apply_rename(c, other, "Iceland").is_err(), + "two top-level collections must not share a name" + ); + } + + #[test] + fn renaming_a_deleted_collection_fails_rather_than_resurrecting_it() { + let (cat, id) = with_one("Gone"); + coll::delete(cat.connection(), id).unwrap(); + + assert!(apply_rename(cat.connection(), id, "Back").is_err()); + } + + #[test] + fn a_saved_filter_can_be_renamed() { + // Its *membership* is computed; its name is not, and a smart + // collection the user cannot label is worse than no smart collection. + let cat = Catalog::in_memory().unwrap(); + let c = cat.connection(); + let s = coll::create(c, "Untitled", None, CollectionKind::Smart).unwrap(); + + assert_eq!( + apply_rename(c, s, "Five star").unwrap(), + Rename::Applied("Five star".into()) + ); + } + + #[test] + fn the_same_name_is_free_again_under_a_different_parent() { + // Uniqueness is per parent, not global: "Selects" inside two different + // trips is unambiguous and normal. + let cat = Catalog::in_memory().unwrap(); + let c = cat.connection(); + + let top = coll::create(c, "New collection", None, CollectionKind::Manual).unwrap(); + assert_eq!(unique_name(c, Some(top)), "New collection"); + } +} diff --git a/ui/dr-ui/src/collections_ui/trash.rs b/ui/dr-ui/src/collections_ui/trash.rs new file mode 100644 index 0000000..0881a33 --- /dev/null +++ b/ui/dr-ui/src/collections_ui/trash.rs @@ -0,0 +1,296 @@ +//! Soft delete: moving images to the trash on the server, restoring them, +//! and the worker drain both share. +//! +//! A drop is a *soft delete*: the file moves to a trash folder on the +//! server and the catalog records where it came from. Nothing is +//! destroyed until the user empties it, a separate, deliberate action. + +use std::cell::RefCell; +use std::rc::Rc; + +use dr_catalog::Catalog; +use dr_types::ImageId; +use slint::ComponentHandle; + +use crate::AppWindow; + +use super::controller::CollectionsController; +use super::tree_sync::refresh_tree; + +/// Begin a soft delete: plan the moves, then hand them to a worker. +/// +/// The plan is built here because it reads the catalog, which is not `Send`; the +/// worker gets paths and ids and needs no catalog to do its half. +#[allow(clippy::too_many_arguments)] +pub(super) fn start_trash( + window: &AppWindow, + ctl: &Rc, + catalog: &Rc>>, + session: &Rc Option>, + images: &[ImageId], + reload: &Rc, +) { + let Some(conn) = session() else { + window.set_collection_error("Open a library first.".into()); + return; + }; + + let moves = { + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + match crate::trash::plan_trash(cat, &conn.account.root, images) { + Ok(m) => m, + Err(e) => { + window.set_collection_error(format!("planning delete: {e}").into()); + return; + } + } + }; + + if moves.is_empty() { + return; + } + + log::info!("moving {} image(s) to the trash", moves.len()); + window.set_library_status(format!("Moving {} to the trash…", moves.len()).into()); + // The images are leaving the grid; a selection pointing at them would + // survive as a set of ids the user can no longer see. + ctl.clear_selection(); + + let count = moves.len(); + let rx = crate::trash::spawn_move( + conn.clone(), + moves, + crate::trash::Direction::ToTrash, + crate::library::catalog_path(&conn.account), + ); + + drain_trash( + window.as_weak(), + ctl.clone(), + catalog.clone(), + rx, + reload.clone(), + format!("Moving {count} photograph(s) to the trash"), + ); +} + +/// TRACES: FR-CAT-15 +/// Put trashed images back where they came from. +/// +/// The mirror of [`start_trash`], and separate from it rather than a `direction` +/// parameter on one function: the two differ in what they plan, what they report +/// and what they say when the plan comes back empty, and the shared part is the +/// three lines that spawn the worker. +/// +/// An image whose origin was never recorded is skipped by +/// [`crate::trash::plan_restore`] rather than guessed at. That can make the plan +/// shorter than the selection, which is why an empty plan is reported here +/// instead of returning silently — the user pressed a button and is owed an +/// answer either way. +pub(super) fn start_restore( + window: &AppWindow, + ctl: &Rc, + catalog: &Rc>>, + session: &Rc Option>, + images: &[ImageId], + reload: &Rc, +) { + let Some(conn) = session() else { + window.set_collection_error("Open a library first.".into()); + return; + }; + + let moves = { + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + match crate::trash::plan_restore(cat, images) { + Ok(m) => m, + Err(e) => { + window.set_collection_error(format!("planning restore: {e}").into()); + return; + } + } + }; + + if moves.is_empty() { + // Said out loud rather than passed over in silence: a button that does + // nothing visible reads as broken, and the reason here is specific. + window.set_collection_error( + "Nothing to restore — no record of where these came from.".into(), + ); + return; + } + + log::info!("restoring {} image(s) from the trash", moves.len()); + window.set_library_status(format!("Restoring {}…", moves.len()).into()); + // The images are leaving the trash view, so a selection pointing at them + // would survive as ids the user can no longer see. + ctl.clear_selection(); + + let count = moves.len(); + let rx = crate::trash::spawn_move( + conn.clone(), + moves, + crate::trash::Direction::Restore, + crate::library::catalog_path(&conn.account), + ); + + drain_trash( + window.as_weak(), + ctl.clone(), + catalog.clone(), + rx, + reload.clone(), + format!("Restoring {count} photograph(s)"), + ); +} + +/// Drain a trash worker on the UI thread. +/// +/// Same shape as the scan and thumbnail drains: an mpsc channel polled by a +/// Slint timer, so nothing blocks the event loop (NFR-P9). +pub(super) fn drain_trash( + weak: slint::Weak, + ctl: Rc, + catalog: Rc>>, + rx: std::sync::mpsc::Receiver, + reload: Rc, + // What the register calls this operation. Passed in rather than derived + // here: the three callers move files to the trash, back out of it, and + // delete them outright, and "Deleting 40 photographs" is the one word of + // the three that must not appear over a restore. + title: String, +) { + use crate::trash::TrashMessage; + + let timer = slint::Timer::default(); + let ctl_cb = ctl.clone(); + + // A server-side MOVE per file, so it is a transfer in the sense that + // matters: it takes as long as the connection is slow, and it can fail + // halfway with the library in two states at once. + let job = ctl.activity.begin(crate::activity::Kind::Trash, title); + + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(120), + 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) => { + // A worker that died without reporting must not leave the + // status line mid-sentence. + job.fail("stopped without finishing"); + stop_trash(&ctl_cb); + return; + } + }; + + match msg { + TrashMessage::Progress { + done, + total, + failed, + } => { + let status = if failed > 0 { + format!("{done} / {total} · {failed} failed") + } else { + format!("{done} / {total}") + }; + job.progress(done, total); + if failed > 0 { + job.detail(format!("{failed} failed")); + } + w.set_library_status(status.into()); + } + TrashMessage::Done { moved, failed } => { + // Reported honestly, including the partial case: "38 of + // 40" is the truth when two files could not be moved, + // and claiming 40 would hide a real problem. + let status = if failed.is_empty() { + format!("{moved} image(s) done") + } else { + format!("{moved} done · {} failed", failed.len()) + }; + // A partial failure is a failure in the register: the + // library is now in two states at once, which is + // exactly the thing worth keeping on the list. + if failed.is_empty() { + job.finish(status.clone()); + } else { + job.fail(status.clone()); + } + w.set_library_status(status.into()); + if let Some(first) = failed.first() { + w.set_collection_error(first.as_str().into()); + } + + let borrow = catalog.borrow(); + if let Some(cat) = borrow.as_ref() { + refresh_trash(&w, cat); + refresh_tree(&w, &ctl_cb, cat); + } + drop(borrow); + + // The grid changed: images left the library, or came + // back into it. + reload(); + stop_trash(&ctl_cb); + return; + } + } + } + }, + ); + + *ctl.trash_timer.borrow_mut() = Some(timer); +} + +fn stop_trash(ctl: &Rc) { + if let Some(t) = ctl.trash_timer.borrow().as_ref() { + t.stop(); + } +} + +/// Refresh the sidebar's trash count and size. +/// +/// The size is formatted here rather than in Slint, which has no byte-size +/// formatting — and the number is what tells the user whether emptying is worth +/// it. +pub fn refresh_trash(window: &AppWindow, catalog: &Catalog) { + let (n, bytes) = dr_catalog::trash::summary(catalog.connection()).unwrap_or((0, 0)); + window.set_trash_count(n as i32); + window.set_trash_label(if n == 0 { + slint::SharedString::new() + } else { + format!("{n} · {}", format_bytes(bytes)).into() + }); +} + +/// Bytes as a human-readable size. +/// +/// Binary units, one decimal place past a kilobyte: a RAW library is measured in +/// gigabytes and "3.4 GB" is the figure a photographer reasons about, where +/// 3_650_722_201 is not. +fn format_bytes(bytes: u64) -> String { + const KB: f64 = 1024.0; + let b = bytes as f64; + if bytes < 1024 { + return format!("{bytes} B"); + } + for (limit, unit) in [ + (KB * KB, "kB"), + (KB * KB * KB, "MB"), + (KB * KB * KB * KB, "GB"), + ] { + if b < limit { + return format!("{:.1} {unit}", b / (limit / KB)); + } + } + format!("{:.1} TB", b / (KB * KB * KB * KB)) +} diff --git a/ui/dr-ui/src/collections_ui/tree_sync.rs b/ui/dr-ui/src/collections_ui/tree_sync.rs new file mode 100644 index 0000000..2786d87 --- /dev/null +++ b/ui/dr-ui/src/collections_ui/tree_sync.rs @@ -0,0 +1,501 @@ +//! Rebuilding the sidebar from the catalog, and pushing catalog-derived +//! state into the grid: the selection flags, the lifted cells, the +//! per-cell collection badges, and the membership sheet's own rows. +//! +//! `offline_state` and `direct_holdings` live here rather than beside the +//! menu or the tray that reads them, because both are catalog reads over +//! the tree — the same shape as `refresh_tree` itself, just narrower. + +use std::rc::Rc; + +use dr_catalog::collections::{self as coll, CollectionKind}; +use dr_catalog::Catalog; +use dr_types::{CollectionId, ImageId}; +use slint::Model as _; + +use crate::{AppWindow, CollectionRow}; + +use super::controller::CollectionsController; + +/// Rebuild the sidebar from the catalog. +/// +/// Called after every edit. The whole tree rather than a patch: a rename can +/// reorder siblings, a delete promotes children, and a drop changes counts on +/// every ancestor — diffing that against the model would be more code than the +/// query costs, and the tree is tens of rows, not thousands. +pub fn refresh_tree(window: &AppWindow, ctl: &Rc, catalog: &Catalog) { + let rows = match coll::tree(catalog.connection()) { + Ok(r) => r, + Err(e) => { + window.set_collection_error(format!("reading collections: {e}").into()); + return; + } + }; + + let collapsed = ctl.collapsed.borrow(); + // TRACES: FR-NC-6a | FR-NC-6c + // How much of each collection is already on the device, rolled up the tree + // — computed once for the whole sidebar rather than per row. + let held = offline_state(catalog, &rows); + + // A row is hidden when any ancestor is collapsed. The tree arrives + // depth-first, so tracking the shallowest collapsed depth seen is enough — + // no ancestor lookup per row. + let mut hide_below: Option = None; + let mut ids = Vec::new(); + let mut smart = Vec::new(); + let mut has_kids = Vec::new(); + let mut nested = Vec::new(); + let mut out = Vec::new(); + + for row in &rows { + if let Some(depth) = hide_below { + if row.depth > depth { + continue; + } + hide_below = None; + } + + let id = row.collection.id; + let expanded = !collapsed.contains(&id); + if row.has_children && !expanded { + hide_below = Some(row.depth); + } + + // Deep counts are one query per row. That is fine at sidebar scale and + // wrong at grid scale, which is why the grid does not do this. + let deep = + coll::deep_count(catalog.connection(), id).unwrap_or(row.collection.direct_count); + + let offline = held.get(&id).copied().unwrap_or_default(); + + ids.push(id); + smart.push(row.collection.kind == CollectionKind::Smart); + has_kids.push(row.has_children); + nested.push(row.collection.parent.is_some()); + out.push(CollectionRow { + id: id.0 as i32, + name: row.collection.name.as_str().into(), + depth: row.depth as i32, + direct_count: row.collection.direct_count as i32, + deep_count: deep as i32, + has_children: row.has_children, + expanded, + smart: row.collection.kind == CollectionKind::Smart, + // An empty collection is never "kept": every one of its zero + // photographs being here is true and says nothing, and a full tray + // beside an empty collection is a lie about what a trip would cost + // to take. + pinned: offline.total > 0 && offline.pinned == offline.total, + partly_pinned: offline.pinned > 0 && offline.pinned < offline.total, + }); + } + + *ctl.row_ids.borrow_mut() = ids; + *ctl.row_smart.borrow_mut() = smart; + *ctl.row_has_children.borrow_mut() = has_kids; + *ctl.row_nested.borrow_mut() = nested; + window.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out))); + + // The rows are what `sync_reorderable` reads, so it has to be told they + // changed: a collection that gains a child, or one that is deleted out from + // under the scope, changes whether the grid can be reordered without the + // scope itself moving. + sync_reorderable(window); +} + +/// TRACES: FR-NC-6a | FR-NC-6c +/// How many of a collection's photographs are pinned, and how many there are. +/// +/// Counted over *members*, not distinct images: an image filed in both a parent +/// and a child is counted in each, which is what makes the roll-up below a sum +/// rather than a set union. That inflates the totals of a tree that files the +/// same photograph twice, and it does not matter here — the only questions +/// asked of these numbers are "all of them?" and "none of them?", and both +/// survive the double count. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct OfflineCount { + total: usize, + pinned: usize, +} + +/// The offline state of every collection in the tree, descendants included. +/// +/// One query for the direct membership, then a single reverse pass to fold each +/// row into its parent. The alternative — `descendants` plus a count per row — +/// is two queries per collection, and the sidebar rebuilds after every drop, +/// rename and rating change. +/// +/// The reverse pass is correct because `tree` emits parents before children: +/// walking it backwards means every child has already been folded in by the +/// time its parent is reached, however deep the nesting goes. +fn offline_state( + catalog: &Catalog, + rows: &[coll::TreeRow], +) -> std::collections::HashMap { + let mut counts: std::collections::HashMap = + std::collections::HashMap::new(); + + // `tier_actual`, not `pinned`: the question a user is asking of this icon is + // "will these open on the aeroplane", and a pin whose download has not run + // yet answers no (see the note on `tier_actual` in `dr_catalog::cache`). + let sql = "SELECT cm.collection_id, + count(*), + coalesce(sum(CASE WHEN ic.tier_actual >= ?1 THEN 1 ELSE 0 END), 0) + FROM collection_members cm + LEFT JOIN image_cache ic ON ic.image_id = cm.image_id + GROUP BY cm.collection_id"; + + match catalog.connection().prepare(sql).and_then(|mut stmt| { + let rows = stmt.query_map(rusqlite::params![dr_types::Tier::Original.stored()], |r| { + Ok(( + CollectionId(r.get::<_, i64>(0)? as u64), + r.get::<_, i64>(1)? as usize, + r.get::<_, i64>(2)? as usize, + )) + })?; + rows.collect::, _>>() + }) { + Ok(direct) => { + for (id, total, pinned) in direct { + counts.insert(id, OfflineCount { total, pinned }); + } + } + Err(e) => { + // Not fatal: the tray then reads "nothing kept", which is the safe + // direction — it offers a download rather than claiming a trip is + // already on the device. + log::debug!("reading offline state: {e}"); + return counts; + } + } + + for row in rows.iter().rev() { + let Some(parent) = row.collection.parent else { + continue; + }; + let child = counts.get(&row.collection.id).copied().unwrap_or_default(); + if child.total == 0 { + continue; + } + let entry = counts.entry(parent).or_default(); + entry.total += child.total; + entry.pinned += child.pinned; + } + + counts +} + +/// Mark which cells are currently lifted out by a drag. +/// +/// Separate from [`sync_selection`] because the two differ: the selection +/// persists after the drag, the lift lasts only while it is in flight. +pub fn sync_lifted(window: &AppWindow, lifted: &[ImageId], ids: &[ImageId]) { + let model = window.get_library_cells(); + for (row, id) in ids.iter().enumerate() { + let want = lifted.contains(id); + if let Some(mut cell) = model.row_data(row) { + if cell.lifted != want { + cell.lifted = want; + model.set_row_data(row, cell); + } + } + } +} + +/// Push the current selection into the grid model's `selected` flags. +/// +/// The model carries the flag per cell so Slint can style without a lookup; +/// this is what keeps the two in step after a scrub, a drop, or a click. +pub fn sync_selection(window: &AppWindow, ctl: &Rc, ids: &[ImageId]) { + let selection = ctl.selection.borrow(); + let model = window.get_library_cells(); + + // The anchor is deliberately **not** pushed to the grid. + // + // It used to be, and it was drawn as a thin ring inside the cell. Two + // things were wrong with that. It was the clearest mark on the cell, so it + // read as *the* selection to anyone who had not written it; and an anchor + // outlives a deselection, so the ring sat around the last photograph + // touched while nothing was selected at all — indistinguishable from a cell + // that had stayed behind. + // + // The ordinal still lives in the controller and still decides where a range + // extends from. What is gone is the claim that the user needs to see it: + // the selection bar says "Tap the last photograph" while a range is armed, + // which answers the question the ring was there to answer. + for (row, id) in ids.iter().enumerate() { + let want = selection.contains(id); + if let Some(mut cell) = model.row_data(row) { + if cell.selected != want { + cell.selected = want; + model.set_row_data(row, cell); + } + } + } + + window.set_library_selected_count(selection.len() as i32); +} + +/// TRACES: FR-CAT-7 +/// Whether what the grid is showing has an order the user can change. +/// +/// A single manual collection: not the whole library, not a saved filter whose +/// membership is a rule, and not a set — a set draws its descendants' images +/// too, and two children's `position` columns are unrelated integers that would +/// interleave arbitrarily. +/// +/// Read off the tree rows the sidebar already draws rather than asked of the +/// catalog. `smart` and `has_children` are the only two facts it needs and both +/// are in the model already, so this cannot disagree with what the user is +/// looking at, and a scope change costs no extra query. +pub(super) fn sync_reorderable(window: &AppWindow) { + let id = window.get_collection_selected(); + let rows = window.get_collection_rows(); + let manual = id > 0 + && (0..rows.row_count()) + .filter_map(|i| rows.row_data(i)) + .find(|r| r.id == id) + .is_some_and(|r| !r.smart && !r.has_children); + window.set_library_reorderable(manual); +} + +/// Refresh the per-cell "in this many collections" badges. +/// +/// One query for the whole window rather than one per cell: 120 cells is 120 +/// round trips otherwise, on every drop. +pub fn sync_badges(window: &AppWindow, catalog: &Catalog, ids: &[ImageId]) { + if ids.is_empty() { + return; + } + + let placeholders = std::iter::repeat_n("?", ids.len()) + .collect::>() + .join(","); + let sql = format!( + "SELECT m.image_id, count(*) + FROM collection_members m + JOIN collections c ON c.id = m.collection_id + WHERE m.image_id IN ({placeholders}) AND c.deleted = 0 + GROUP BY m.image_id" + ); + + let params: Vec = ids + .iter() + .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) + .collect(); + + let mut counts = std::collections::HashMap::new(); + if let Ok(mut stmt) = catalog.connection().prepare(&sql) { + if let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)) + }) { + for (id, n) in rows.flatten() { + counts.insert(id, n as i32); + } + } + } + + let model = window.get_library_cells(); + for (row, id) in ids.iter().enumerate() { + let want = counts.get(&(id.0 as i64)).copied().unwrap_or(0); + if let Some(mut cell) = model.row_data(row) { + if cell.collection_count != want { + cell.collection_count = want; + model.set_row_data(row, cell); + } + } + } +} + +/// TRACES: FR-CAT-7 +/// What a collection directly holds: its own member rows, and its own children. +/// +/// One query each rather than reusing `deep_count`, which counts descendants' +/// photographs — see [`menu_detail`] for why the menu needs the direct numbers. +pub(super) fn direct_holdings( + conn: &rusqlite::Connection, + id: CollectionId, +) -> Result<(usize, usize), dr_catalog::CatalogError> { + let holds: i64 = conn.query_row( + "SELECT count(*) FROM collection_members WHERE collection_id = ?1", + [id.0 as i64], + |r| r.get(0), + )?; + let children: i64 = conn.query_row( + "SELECT count(*) FROM collections WHERE parent_id = ?1 AND deleted = 0", + [id.0 as i64], + |r| r.get(0), + )?; + Ok((holds as usize, children as usize)) +} + +/// TRACES: FR-CAT-7 | FR-UI-4 +/// Rebuild the membership sheet from the selection as it stands. +/// +/// Called on open and after every removal rather than patched: a removal can +/// take the last selected photograph out of a collection, and that row then has +/// to go — a patched model would leave a "Remove" that removes nothing. +pub(super) fn refresh_membership( + window: &AppWindow, + ctl: &Rc, + catalog: &Catalog, +) { + let chosen = ctl.selected(); + let total = chosen.len(); + let rows = match coll::membership_of(catalog.connection(), &chosen) { + Ok(r) => r, + Err(e) => { + window.set_collection_error(format!("reading collections: {e}").into()); + Vec::new() + } + }; + + let out: Vec = rows + .into_iter() + .map(|m| crate::MembershipRow { + id: m.id.0 as i32, + name: m.name.as_str().into(), + holding: m.holding as i32, + // "1 of 1" is arithmetic nobody asked for; with one photograph + // selected the name already says everything true about it. + detail: if total <= 1 { + slint::SharedString::new() + } else { + format!("{} of {total}", m.holding).into() + }, + }) + .collect(); + + window.set_membership_rows(slint::ModelRc::new(slint::VecModel::from(out))); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// TRACES: FR-NC-6a | FR-NC-6c + /// A catalog with `n` images, for the offline roll-up tests. + fn catalog_with_images(n: usize) -> (Catalog, Vec) { + let cat = Catalog::in_memory().unwrap(); + cat.connection() + .execute( + "INSERT INTO roots (id, kind, label) VALUES (1, 'remote', 'test')", + [], + ) + .unwrap(); + let mut ids = Vec::new(); + for i in 0..n { + cat.connection() + .execute( + "INSERT INTO images (root_id, source_ref, added_at) + VALUES (1, ?1, 0)", + rusqlite::params![format!("Photos/img{i:03}.CR2")], + ) + .unwrap(); + ids.push(ImageId(cat.connection().last_insert_rowid() as u64)); + } + (cat, ids) + } + + /// Record an image as held on this device, as a finished download would. + fn mark_held(cat: &Catalog, image: ImageId) { + cat.connection() + .execute( + "INSERT INTO image_cache (image_id, tier_actual, tier_desired, bytes, pinned) + VALUES (?1, ?2, ?2, 100, 1)", + rusqlite::params![image.0 as i64, dr_types::Tier::Original.stored()], + ) + .unwrap(); + } + + #[test] + fn a_parent_reads_as_kept_only_when_its_children_are_too() { + // The tray on a parent answers for the subtree, because that is what + // keeping a parent downloads. A parent that showed a full tray over a + // child with nothing downloaded would be a promise the aeroplane + // breaks. + let (cat, images) = catalog_with_images(4); + let c = cat.connection(); + let parent = coll::create(c, "Trip", None, CollectionKind::Manual).unwrap(); + let child = coll::create(c, "Day one", Some(parent), CollectionKind::Manual).unwrap(); + coll::add_images(c, parent, &images[0..2]).unwrap(); + coll::add_images(c, child, &images[2..4]).unwrap(); + + // Everything the parent holds directly, and nothing of the child's. + mark_held(&cat, images[0]); + mark_held(&cat, images[1]); + + let rows = coll::tree(c).unwrap(); + let state = offline_state(&cat, &rows); + + assert_eq!( + state[&parent], + OfflineCount { + total: 4, + pinned: 2 + } + ); + assert_eq!( + state[&child], + OfflineCount { + total: 2, + pinned: 0 + } + ); + + mark_held(&cat, images[2]); + mark_held(&cat, images[3]); + let state = offline_state(&cat, &coll::tree(c).unwrap()); + assert_eq!( + state[&parent], + OfflineCount { + total: 4, + pinned: 4 + } + ); + } + + #[test] + fn a_grandchild_rolls_all_the_way_up() { + // The reverse pass has to survive depth: a photograph three levels down + // is still part of what the top-level collection would download. + let (cat, images) = catalog_with_images(1); + let c = cat.connection(); + let top = coll::create(c, "2024", None, CollectionKind::Manual).unwrap(); + let mid = coll::create(c, "Corsica", Some(top), CollectionKind::Manual).unwrap(); + let leaf = coll::create(c, "Bonifacio", Some(mid), CollectionKind::Manual).unwrap(); + coll::add_images(c, leaf, &images).unwrap(); + + let state = offline_state(&cat, &coll::tree(c).unwrap()); + + assert_eq!( + state[&top], + OfflineCount { + total: 1, + pinned: 0 + } + ); + assert_eq!( + state[&mid], + OfflineCount { + total: 1, + pinned: 0 + } + ); + } + + #[test] + fn an_empty_collection_has_nothing_to_keep() { + // Guards the reading that would put a full tray beside an empty + // collection: every one of its zero photographs is here, which is true + // and useless. `refresh_tree` requires a non-zero total before drawing + // one, and this is the fact it relies on. + let (cat, _) = catalog_with_images(0); + let c = cat.connection(); + let empty = coll::create(c, "Nothing yet", None, CollectionKind::Manual).unwrap(); + + let state = offline_state(&cat, &coll::tree(c).unwrap()); + assert_eq!(state.get(&empty).copied().unwrap_or_default().total, 0); + } +} diff --git a/ui/dr-ui/src/collections_ui/wiring_grid.rs b/ui/dr-ui/src/collections_ui/wiring_grid.rs new file mode 100644 index 0000000..24d23b2 --- /dev/null +++ b/ui/dr-ui/src/collections_ui/wiring_grid.rs @@ -0,0 +1,943 @@ +//! Grid-facing wiring: selection, filing the selection into a collection, +//! the drag out of the grid, and the trash reached from the grid or the +//! sidebar row. +//! +//! [`wire`] is the module's single entry point, called once when the +//! window is built; it and everything it calls here that has no test of +//! its own are exercised through the running application (see +//! `identity_bench` and the manual recording rig in the developer docs). + +use std::cell::RefCell; +use std::rc::Rc; + +use dr_catalog::collections::{self as coll, CollectionKind}; +use dr_catalog::Catalog; +use dr_types::{CollectionId, ImageId}; +use slint::{ComponentHandle, Model as _}; + +use crate::library; +use crate::AppWindow; + +use super::controller::{decide_drop, CollectionsController, Drop}; +use super::drag::{arm_hold, arm_spring, collapse_spring_opened, compose_drag_image}; +use super::press::select_row; +use super::rename_menu::unique_name; +use super::trash::{drain_trash, start_restore, start_trash}; +use super::tree_sync::{refresh_tree, sync_badges, sync_lifted, sync_selection}; +use super::wiring_tree::{ + wire_create, wire_membership, wire_remove, wire_rename, wire_row_drag, wire_row_menu, + wire_tree_navigation, +}; + +/// Connect the sidebar and drag callbacks. +/// +/// `on_scope_changed` reloads the grid — that lives in [`crate::library_ui`], +/// which owns the window and the thumbnail workers, so it is passed in rather +/// than reached for. +#[allow(clippy::too_many_arguments)] +pub fn wire( + window: &AppWindow, + ctl: Rc, + catalog: Rc>>, + on_scope_changed: S, + visible_ids: R, + span_ids: P, + session: C, +) where + S: Fn() + 'static, + R: Fn() -> Vec + 'static, + P: Fn(usize, usize) -> Vec + 'static, + C: Fn() -> Option + 'static, +{ + // Coerced to trait objects here rather than at each use: each is shared by + // several of the functions below, and a generic parameter would make every + // one of them a separate instantiation for no gain. + let on_scope_changed: Rc = Rc::new(on_scope_changed); + let visible_ids: Rc Vec> = Rc::new(visible_ids); + // A shift-click asks the catalog what lies between its two ends, and the + // catalog belongs to the grid's controller — see `span_source`. + *ctl.span_source.borrow_mut() = Some(Rc::new(span_ids)); + let session: Rc Option> = Rc::new(session); + + wire_selection(window, &ctl, &catalog, &visible_ids, &on_scope_changed); + wire_selection_filing(window, &ctl, &catalog, &visible_ids, &on_scope_changed); + wire_drag( + window, + &ctl, + &catalog, + &visible_ids, + &on_scope_changed, + &session, + ); + wire_trash(window, &ctl, &catalog, &session, &on_scope_changed); + wire_trash_from_grid( + window, + &ctl, + &catalog, + &session, + &on_scope_changed, + &visible_ids, + ); + wire_tree_navigation(window, &ctl, &catalog, &on_scope_changed); + wire_create(window, &ctl, &catalog); + wire_rename(window, &ctl, &catalog); + wire_remove(window, &ctl, &catalog, &visible_ids, &on_scope_changed); + wire_row_drag(window, &ctl, &catalog); + wire_row_menu(window, &ctl, &catalog, &on_scope_changed, &visible_ids); + wire_membership(window, &ctl, &catalog, &visible_ids, &on_scope_changed); +} + +/// Selection. +fn wire_selection( + window: &AppWindow, + ctl: &Rc, + catalog: &Rc>>, + visible_ids: &Rc Vec>, + on_scope_changed: &Rc, +) { + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let visible = visible_ids.clone(); + window.on_library_cell_pressed(move |row, ctrl_held, shift_held| { + let Some(w) = weak.upgrade() else { return }; + let ids = visible(); + + // Consulted by the click that follows: a modified press is building + // a selection and must not also navigate to develop. + ctl.modified_press.set(ctrl_held || shift_held); + + // Where the window starts, so the press is recorded as the ordinal + // it is rather than as a row that stops meaning this photograph on + // the next scroll. + let offset = w.get_library_offset().max(0) as usize; + select_row(&w, &ctl, &ids, offset, row as usize, ctrl_held, shift_held); + + // TRACES: FR-UI-2 | FR-UI-4 | FR-CAT-7 + // And start counting, in case this press is a hold. The press has + // already selected this one cell; what the hold adds is the *mode*, + // so the taps that follow go on selecting instead of opening the + // next photograph the user touches — and the *pick-up*, which is + // what makes the drag reliable. + // + // Armed even when the mode is already on, which it did not used to + // be: there was nothing left for the hold to switch on, so it was + // skipped. But the mode being on is exactly the state a + // multi-image drag starts from, and skipping the hold left that + // drag with no pick-up and no cue — the one gesture that most + // needed both. See `arm_hold`. + arm_hold(&w, &ctl, row); + }); + } + + // TRACES: FR-UI-2 | FR-UI-4 + // The press ended — lifted, or taken by the Flickable when the finger + // travelled. Either way the hold is off. + { + let ctl = ctl.clone(); + window.on_library_cell_press_ended(move || { + *ctl.hold_timer.borrow_mut() = None; + }); + } + + // TRACES: FR-CAT-5 + // Put the selection down without leaving select mode. + // + // Distinct from "Done", which does both: after picking forty photographs + // and filing them, the next forty start with the mode already on, and + // making the user re-enter it is asking them to undo a step they did not + // take. It is also the only way back from a mis-tap that is not tapping + // every one of them again. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let visible = visible_ids.clone(); + window.on_library_clear_selection(move || { + let Some(w) = weak.upgrade() else { return }; + ctl.clear_selection(); + sync_selection(&w, &ctl, &visible()); + }); + } + + // TRACES: FR-CAT-7 + // Drag a photograph, or a whole selection of them, to a new place in the + // collection being shown. + // + // `collection_members.position` and `Sort::CollectionPosition` have been in + // the catalog since collections were, and until now nothing above it ever + // wrote or read them — the grid ordered by capture time whatever it was + // scoped to. `library::grid_order_for` reads them; this writes them. + // + // The membership is rewritten whole rather than patched. `set_order` sets + // the positions it is given and leaves the rest, so a partial write would + // interleave the moved run with rows whose positions nobody touched — and + // it is read unfiltered for the same reason: the user reorders what they + // can see, and what the filter is hiding keeps its place relative to it. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + let visible = visible_ids.clone(); + let reload = on_scope_changed.clone(); + window.on_library_reorder_to(move |row, after| { + let Some(w) = weak.upgrade() else { return }; + // Both guards belong here rather than only in `library.slint`: the + // scope can change between the drag starting and the drop landing. + if !w.get_library_reorderable() { + return; + } + let Some(scope) = *ctl.scope.borrow() else { + return; + }; + + let moving = ctl.selected(); + if moving.is_empty() { + return; + } + + let ids = visible(); + let Some(&target) = usize::try_from(row).ok().and_then(|r| ids.get(r)) else { + return; + }; + + // Dropped on one of its own. There is no gap between a run and + // itself to land in, and rewriting the whole membership to say so + // would be a revision bump for no change. + if moving.contains(&target) { + return; + } + + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + + let current = match library::read_member_order(cat, scope) { + Ok(current) => current, + Err(e) => { + w.set_collection_error(format!("reading the collection's order: {e}").into()); + return; + } + }; + let wanted = library::reordered(¤t, &moving, target, after); + + match coll::set_order(cat.connection(), scope, &wanted) { + Ok(()) => { + w.set_collection_error(slint::SharedString::new()); + w.set_library_status( + format!( + "{} photograph{} moved", + moving.len(), + if moving.len() == 1 { "" } else { "s" } + ) + .into(), + ); + drop(borrow); + + // The selection survives. It is what was just moved, and + // dropping it would make a second nudge — which is how a + // drag-to-reorder is usually corrected — start over. + reload(); + sync_selection(&w, &ctl, &visible()); + } + Err(e) => { + drop(borrow); + w.set_collection_error(format!("reordering: {e}").into()); + } + } + }); + } + + // TRACES: FR-CAT-5 | FR-UI-4 + // Everything the grid is showing. + // + // Asked of the catalog through `span`, not read off the loaded window, for + // the reason spelled out in `apply_press`: the window is a hundred cells + // over a library of thousands, and a "select all" that quietly meant + // "select the hundred that happen to be loaded" is a lie the user cannot + // see until the export runs. `library_total` is the count the same scope + // and filter produced, so the run is the whole of what the grid claims. + // + // Replaces rather than adds. "All" is a statement about the result, not an + // increment, and a user who wanted the rest kept would not have reached for + // this. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let visible = visible_ids.clone(); + window.on_library_select_all(move || { + let Some(w) = weak.upgrade() else { return }; + let ids = visible(); + let total = w.get_library_total().max(0) as usize; + if total == 0 { + return; + } + + // The loaded window is the fallback, not the answer — the same + // trade the shift-click path makes. An empty span means no library + // or a failed query, and selecting what is on screen beats + // selecting nothing. + let all = match ctl.span(0, total - 1) { + run if run.is_empty() => ids.clone(), + run => run, + }; + + { + let mut selection = ctl.selection.borrow_mut(); + selection.clear(); + selection.extend(all); + } + + // The first frame becomes the anchor, so a "Select to…" straight + // afterwards describes a range from the top rather than from + // wherever the last individual tap left it — which, after taking + // everything, is not a place the user is still thinking about. + *ctl.anchor.borrow_mut() = Some(0); + sync_selection(&w, &ctl, &ids); + }); + } +} + +/// Selection, continued: turning it into a collection, toggling the +/// mode, and filing it into one that already exists. +fn wire_selection_filing( + window: &AppWindow, + ctl: &Rc, + catalog: &Rc>>, + visible_ids: &Rc Vec>, + on_scope_changed: &Rc, +) { + // TRACES: FR-CAT-5 + // A collection holding exactly what is selected. + // + // This was four steps — make a collection, find it in the tree, select the + // photographs again because creating one changed the scope, then add them + // — and the selection is usually made *because* it is going somewhere. One + // press instead. + // + // Created at the top level rather than inside the current scope, unlike + // the tree's "+". A selection can be gathered from anywhere, including + // across collections, so filing it under whichever one happens to be open + // would put it somewhere its contents did not come from. + // + // The name arrives already chosen. It used to be a placeholder, with the + // sidebar's rename field opened straight afterwards to correct it — which + // on a tablet meant opening a field inside a panel that is instantiated but + // not drawn, so it took the on-screen keyboard and could never give it + // back. `library.slint`'s naming sheet asks first, and nothing reaches the + // catalog until it is answered. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + let visible = visible_ids.clone(); + let reload = on_scope_changed.clone(); + window.on_library_collection_from_selection(move |name| { + let Some(w) = weak.upgrade() else { return }; + let images = ctl.selected(); + if images.is_empty() { + return; + } + + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { + w.set_collection_error("Open a library first.".into()); + return; + }; + + // The sheet refuses an empty name in two places, so this is + // belt-and-braces rather than a path the UI can reach — but a + // collection with no name at all is unfindable in the tree, and + // falling back to the placeholder is recoverable where an empty + // row is not. + let name = match name.trim() { + "" => unique_name(cat.connection(), None), + typed => typed.to_string(), + }; + + // The id is not carried out. It used to be, to open the rename + // field on the row it names; now the name is already right, and + // `refresh_tree` redraws the tree from the catalog either way. + let made = coll::create(cat.connection(), &name, None, CollectionKind::Manual) + .and_then(|id| coll::add_images(cat.connection(), id, &images)); + + match made { + Ok(added) => { + w.set_collection_error(slint::SharedString::new()); + w.set_library_status( + format!( + "{added} photograph{} in {name}", + if added == 1 { "" } else { "s" } + ) + .into(), + ); + refresh_tree(&w, &ctl, cat); + drop(borrow); + + // The selection has been filed; holding on to it invites + // the next press acting on photographs the user considers + // dealt with. + ctl.clear_selection(); + sync_selection(&w, &ctl, &visible()); + + // No rename opened here. The name was chosen before the + // collection existed, so there is nothing left to correct — + // and opening the sidebar's field is what stranded the + // keyboard on a tablet. + reload(); + } + Err(e) => { + drop(borrow); + w.set_collection_error(format!("making a collection: {e}").into()); + } + } + }); + } + + // TRACES: FR-UI-2 | FR-UI-4 + // The button half of selection mode. The long press is faster and this is + // the one that can be found — a gesture with no visible counterpart is a + // feature only its author knows about. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let visible = visible_ids.clone(); + window.on_library_toggle_select_mode(move || { + let Some(w) = weak.upgrade() else { return }; + let on = !ctl.select_mode.get(); + ctl.select_mode.set(on); + w.set_library_select_mode(on); + *ctl.hold_timer.borrow_mut() = None; + + // Leaving the mode drops the selection. "Done" reads as finishing + // with these photographs, and a selection that outlived the mode + // would still be acted on by the buttons in the header — which is + // how forty images get exported by a user who thought they had put + // them down. + if !on { + ctl.clear_selection(); + sync_selection(&w, &ctl, &visible()); + } + }); + } + + // TRACES: FR-CAT-7 | FR-UI-4 + // File the selection in a collection without dragging it there. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + let visible = visible_ids.clone(); + let reload = on_scope_changed.clone(); + window.on_library_file_in_collection(move |id, moves| { + let Some(w) = weak.upgrade() else { return }; + let target = CollectionId(id as u64); + let chosen = ctl.selected(); + if chosen.is_empty() { + return; + } + + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + + // Moving is only a move when there is somewhere to move *from*. + // Unscoped, the sheet does not offer it; this guards the callback + // anyway, and degrades to filing rather than refusing. + let from = if moves { *ctl.scope.borrow() } else { None }; + let result = match from { + Some(from) => coll::move_images(cat.connection(), from, target, &chosen), + None => coll::add_images(cat.connection(), target, &chosen), + }; + + match result { + Ok(filed) => { + w.set_collection_error(slint::SharedString::new()); + // The honest count, as the drop reports it: claiming all + // twelve when nine were already there teaches the user to + // distrust every number the app shows them. + let msg = if from.is_some() { + format!("Moved {filed} of {}", chosen.len()) + } else if filed == chosen.len() { + format!("Added {filed} to collection") + } else { + format!( + "Added {filed} of {} — the rest were already there", + chosen.len() + ) + }; + w.set_library_status(msg.into()); + + refresh_tree(&w, &ctl, cat); + sync_badges(&w, cat, &visible()); + // A move changes what the collection on screen holds, so + // the grid has to be reread; filing elsewhere does not, but + // the badge count on every cell just changed and the reread + // is one query. + drop(borrow); + reload(); + } + Err(e) => w.set_collection_error(format!("filing: {e}").into()), + } + }); + } +} + +/// Drag. +/// +/// Slint owns the gesture (see the preamble). What is left here is the +/// payload — the image ids the drop will act on — and the spring. +fn wire_drag( + window: &AppWindow, + ctl: &Rc, + catalog: &Rc>>, + visible_ids: &Rc Vec>, + on_scope_changed: &Rc, + session: &Rc Option>, +) { + // TRACES: FR-CAT-7 + // **What arms the drag, not what it carries.** + // + // `DragArea` declines to start a drag while its `data` is empty, and it + // tests that on every pointer event that reaches it — the first one + // included, which arrives long before any drag. The binding in + // `library.slint` calls this callback, and a binding that calls a callback + // has nothing Slint can invalidate it on: it is evaluated once, when a + // finger first lands on that cell, and cached. `dragging` is empty at that + // moment and stays empty until `drag-started` fires. + // + // So this is answered at the wrong time, and always will be. That is + // harmless only because **`set_user_data` is called unconditionally**: an + // empty `Vec` is still user data, so the transfer is never `is_empty()` and + // the `DragArea` stays armed. Skipping the call for an empty selection — + // which looks like an obvious tidy-up — would disarm every cell a finger + // had ever touched outside a drag, and dragging would simply stop working + // with nothing to see. + // + // What the drop actually reads is `dragging`, set by `drag-started` and + // read back by `dropped-on`. `user_data` rather than plain text so that + // nothing outside the application can interpret it as a paste. + { + let ctl = ctl.clone(); + window.on_library_drag_payload(move || { + let carried = ctl.dragging.borrow().clone(); + let mut data = slint::DataTransfer::default(); + data.set_user_data(Rc::new(carried)); + data + }); + } + + // Dragging a collection row. The id travels in the payload for anything + // that cares to read it; the drop itself uses the remembered press, since + // it is handed only the target. + { + window.on_collection_drag_payload(move |id| { + let mut data = slint::DataTransfer::default(); + data.set_user_data(Rc::new(CollectionId(id as u64))); + data + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let visible = visible_ids.clone(); + window.on_library_drag_started(move |row| { + let Some(w) = weak.upgrade() else { return }; + let ids = visible(); + + // TRACES: FR-UI-4 + // A drag is not a hold, and the timer must not outlive the press + // that armed it. Grabbing a cell and moving inside 450 ms left the + // hold armed underneath the drag, so it fired mid-gesture and put + // the grid into selection mode the user had not asked for — the + // drag finished into a mode that changed what every later tap + // meant. The pinch already cancels for exactly this reason. + *ctl.hold_timer.borrow_mut() = None; + + // TRACES: FR-CAT-7 + // And this press was not a tap, so it never gets to take anything + // out of the selection — see [`Press::Deferred`]. Dropped here as + // well as on the next press because the drag reads the selection + // one line below, and a removal still pending would be a + // photograph the user can see is selected and the drop would not + // carry. + ctl.pending_toggle.set(None); + + // Dragging an *unselected* cell carries only that one, and makes it + // the selection — otherwise the images that travel are not the ones + // the user grabbed. Dragging a selected cell carries the whole + // selection, which is the multi-image gesture. + let carried: Vec = { + let mut selection = ctl.selection.borrow_mut(); + match ids.get(row as usize) { + Some(id) if !selection.contains(id) => { + selection.clear(); + selection.insert(*id); + vec![*id] + } + _ => selection.iter().copied().collect(), + } + }; + + // The bitmap under the cursor, built from the thumbnails already in + // the model — the drag carries what the user can see, and a cell + // whose preview has not landed yet contributes nothing rather than + // a placeholder. + let thumbs: Vec = { + let model = w.get_library_cells(); + carried + .iter() + .filter_map(|id| ids.iter().position(|v| v == id)) + .filter_map(|row| model.row_data(row)) + .filter(|c| c.has_thumb) + .map(|c| c.thumbnail) + .collect() + }; + w.set_library_drag_image(compose_drag_image(&thumbs)); + + *ctl.dragging.borrow_mut() = carried.clone(); + sync_selection(&w, &ctl, &ids); + // The lift-out: these cells fade and shrink in place, so the grid + // shows where the photographs came from while the cursor shows them + // in full colour. + sync_lifted(&w, &carried, &ids); + }); + } + + // A drag dwelling over a collapsed collection springs it open, so a nested + // child can be reached without putting the images down first. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog_for_spring = catalog.clone(); + window.on_collection_drag_over(move |id, over| { + let Some(w) = weak.upgrade() else { return }; + let id = CollectionId(id as u64); + + if !over { + // Left this row. Cancel its pending expansion rather than + // letting it fire over whatever the pointer moved on to. + if *ctl.hover_id.borrow() == Some(id) { + *ctl.hover_id.borrow_mut() = None; + *ctl.spring_timer.borrow_mut() = None; + } + return; + } + + *ctl.hover_id.borrow_mut() = Some(id); + let row = ctl.row_ids.borrow().iter().position(|&c| c == id); + arm_spring(&w, &ctl, &catalog_for_spring, row); + }); + } + + // A drop. Slint hit-tested the release and `can-drop` already refused a + // saved filter, so reaching here means this collection accepted. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + // No model refresh or reload here on purpose — see the note at the end + // of this handler. `drag-finished` owns those. + window.on_collection_dropped(move |id| { + let Some(w) = weak.upgrade() else { return }; + let id = CollectionId(id as u64); + let carried = ctl.dragging.borrow().clone(); + // Taken, not read: a remembered press must be spent by the drop it + // belongs to, or a later empty drop — a file dragged in from + // outside, say — would move a collection nobody touched. + let pressed = ctl.dragging_collection.borrow_mut().take(); + + let carried = match decide_drop(&carried, pressed, id) { + Drop::Nothing => return, + Drop::Reparent(source) => { + let result = { + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + coll::set_parent(cat.connection(), source, Some(id)) + }; + match result { + Ok(()) => { + w.set_collection_error(slint::SharedString::new()); + w.set_library_status("Moved collection".into()); + let borrow = catalog.borrow(); + if let Some(cat) = borrow.as_ref() { + refresh_tree(&w, &ctl, cat); + } + } + // The catalog refuses a cycle rather than letting the + // tree walk spin. Saying so is better than a drop that + // silently does nothing. + Err(e) => w.set_collection_error(format!("moving collection: {e}").into()), + } + return; + } + Drop::FileImages(images) => images, + }; + + // Scoped so the borrow is released before the spring cleanup below, + // which needs the catalog itself. + let result = { + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + coll::add_images(cat.connection(), id, &carried) + }; + + match result { + Ok(added) => { + w.set_collection_error(slint::SharedString::new()); + // "Added 3 of 12" is the honest report when nine were + // already there; claiming 12 would teach the user to + // distrust the count. + let msg = if added == carried.len() { + format!("Added {added} to collection") + } else { + format!( + "Added {added} of {} — the rest were already there", + carried.len() + ) + }; + w.set_library_status(msg.into()); + } + Err(e) => w.set_collection_error(format!("adding to collection: {e}").into()), + } + + // Recorded, not acted on. Every visible consequence — rebuilding + // the tree, refreshing the badges, rereading the grid — happens in + // `drag-finished`, because all three replace models that Slint is + // *currently walking* to deliver this very event. Tearing down a + // live `DropArea` from inside its own `dropped` handler is the same + // hazard `sync_rows` in lib.rs documents for the adjust panel. + *ctl.dropped_on.borrow_mut() = Some(id); + }); + } + + // The drag ended: dropped, or abandoned. This is where the consequences of + // a drop land, once Slint has finished with the elements involved. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + let visible = visible_ids.clone(); + let reload = on_scope_changed.clone(); + let session = session.clone(); + window.on_library_drag_finished(move || { + let Some(w) = weak.upgrade() else { return }; + + let landed = ctl.dropped_on.borrow_mut().take(); + let to_trash = ctl.trash_requested.borrow_mut().take(); + ctl.dragging.borrow_mut().clear(); + // The grid clears this itself on the cancel that starts a drag; + // this is for the endings that reach no cell — a drop, or a drag + // abandoned over nothing. + w.set_library_held_row(-1); + *ctl.hover_id.borrow_mut() = None; + *ctl.spring_timer.borrow_mut() = None; + + // A soft delete, deferred out of the drop handler so the models it + // replaces are no longer being walked. + if let Some(images) = to_trash { + start_trash(&w, &ctl, &catalog, &session, &images, &reload); + } + + // The cells settle back into the grid, and the cursor bitmap is + // released — it holds a copy of every thumbnail it composited. + sync_lifted(&w, &[], &visible()); + w.set_library_drag_image(slint::Image::default()); + + if landed.is_some() { + let borrow = catalog.borrow(); + if let Some(cat) = borrow.as_ref() { + // Counts changed on the target and every ancestor, and the + // dropped images now carry one more collection badge. + refresh_tree(&w, &ctl, cat); + sync_badges(&w, cat, &visible()); + } + } + + // Put back whatever the spring opened on the way. The collection + // that received the images — and its ancestors — stay open, since + // that is where the user is now working; on an abandoned drag + // `landed` is `None` and everything closes. + collapse_spring_opened(&w, &ctl, &catalog, landed); + + // A drop into the collection currently being shown changes what that + // collection holds, so the grid has to be reread. + if landed.is_some() && landed == *ctl.scope.borrow() { + reload(); + } + }); + } +} + +/// Trash. +/// +/// TRACES: FR-CAT-15 +/// A drop here is a *soft delete*: the file moves to a trash folder on the +/// server and the catalog records where it came from. Nothing is destroyed +/// until the user empties it, which is a separate, deliberate action. +fn wire_trash( + window: &AppWindow, + ctl: &Rc, + catalog: &Rc>>, + session: &Rc Option>, + on_scope_changed: &Rc, +) { + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let session = session.clone(); + window.on_trash_dropped(move || { + let Some(w) = weak.upgrade() else { return }; + let carried = ctl.dragging.borrow().clone(); + if carried.is_empty() { + return; + } + // Recorded like a collection drop, and acted on in `drag-finished` + // for the same reason: the work replaces Slint models that are + // still being walked to deliver this event. + *ctl.trash_requested.borrow_mut() = Some(carried); + let _ = session; + w.set_collection_error(slint::SharedString::new()); + }); + } + + // Restore. Only reachable while the trash is being looked at, and it acts + // on the selection rather than on everything — the trash is where a user + // goes to recover *one* mistake, not usually to undo the lot. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + let session = session.clone(); + let reload = on_scope_changed.clone(); + window.on_trash_restore(move || { + let Some(w) = weak.upgrade() else { return }; + let chosen = ctl.selected(); + if chosen.is_empty() { + return; + } + start_restore(&w, &ctl, &catalog, &session, &chosen, &reload); + }); + } +} + +/// Trash from the grid. +/// +/// Until now the only route to the trash was dragging onto the sidebar row. +/// These two are the direct gestures: the trash target on a cell's rating +/// strip, and the `Delete` key. +/// +/// Both land here rather than in `library_ui` because everything the +/// operation needs — the selection, the session closure, `start_trash` and +/// its drain — already lives in this module. Reaching them from the grid +/// side would mean either duplicating the worker plumbing or moving it, and +/// trash is one feature whichever component happens to trigger it. +fn wire_trash_from_grid( + window: &AppWindow, + ctl: &Rc, + catalog: &Rc>>, + session: &Rc Option>, + on_scope_changed: &Rc, + visible_ids: &Rc Vec>, +) { + // The trash glyph on one cell. Acts on that photograph alone: the pointer + // named it, and a click that silently trashed an entire selection would be + // exactly the trap the strip's other targets are laid out to avoid. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + let session = session.clone(); + let reload = on_scope_changed.clone(); + let visible = visible_ids.clone(); + window.on_library_cell_trashed(move |row| { + let Some(w) = weak.upgrade() else { return }; + // Resolved through the visible ids rather than the row index alone: + // the grid is a window over the catalog, so a stale index from + // before a scroll would name a different photograph — and here that + // would move the wrong file. + let Some(&id) = visible().get(row as usize) else { + return; + }; + start_trash(&w, &ctl, &catalog, &session, &[id], &reload); + }); + } + + // `Delete` on the selection — the bulk gesture. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + let session = session.clone(); + let reload = on_scope_changed.clone(); + window.on_library_trash_selection(move || { + let Some(w) = weak.upgrade() else { return }; + let chosen = ctl.selected(); + if chosen.is_empty() { + // A keystroke that does nothing reads as a broken key, so it + // says why rather than failing silently. + w.set_library_status("Select an image first".into()); + return; + } + start_trash(&w, &ctl, &catalog, &session, &chosen, &reload); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + let session = session.clone(); + let reload = on_scope_changed.clone(); + window.on_trash_empty(move || { + let Some(w) = weak.upgrade() else { return }; + + let conn = match session() { + Some(c) => c, + None => return, + }; + + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + + // Everything in the trash, with the path and stable id each delete + // needs. Read here rather than in the worker: the catalog is not + // `Send`, and the worker opens its own connection only to write back. + let listed = match dr_catalog::trash::list(cat.connection(), usize::MAX) { + Ok(l) => l, + Err(e) => { + w.set_collection_error(format!("reading trash: {e}").into()); + return; + } + }; + if listed.is_empty() { + return; + } + + let paths: Vec<(ImageId, Option, String)> = listed + .iter() + .map(|t| (t.image_id, t.file_id, t.source_ref.clone())) + .collect(); + let ids: Vec = listed.iter().map(|t| t.image_id).collect(); + + log::info!("emptying trash: {} image(s)", ids.len()); + w.set_library_status(format!("Deleting {} image(s)…", ids.len()).into()); + let count = ids.len(); + + let rx = crate::trash::spawn_purge( + conn.clone(), + ids, + paths, + crate::library::catalog_path(&conn.account), + crate::library::thumbs_dir(&conn.account), + ); + + drain_trash( + w.as_weak(), + ctl.clone(), + catalog.clone(), + rx, + reload.clone(), + format!("Deleting {count} photograph(s) permanently"), + ); + }); + } +} diff --git a/ui/dr-ui/src/collections_ui/wiring_tree.rs b/ui/dr-ui/src/collections_ui/wiring_tree.rs new file mode 100644 index 0000000..e416e4a --- /dev/null +++ b/ui/dr-ui/src/collections_ui/wiring_tree.rs @@ -0,0 +1,698 @@ +//! Sidebar-facing wiring: tree navigation, creating and renaming +//! collections, removing images from the one on screen, the row's own +//! hold-drag, its context menu, and the membership sheet. +//! +//! Split from [`super::wiring_grid`] because that half is about the grid +//! and this half is about the tree; [`super::wire`] calls into both. + +use std::cell::RefCell; +use std::rc::Rc; + +use dr_catalog::collections::{self as coll}; +use dr_catalog::Catalog; +use dr_types::{CollectionId, ImageId}; +use slint::{ComponentHandle, Model as _}; + +use crate::AppWindow; + +use super::controller::{ + decide_delete, decide_release, CollectionsController, DeleteStep, Release, +}; +use super::drag::HOLD_DELAY_MS; +use super::rename_menu::{ + apply_rename, close_rename, close_row_menu, create_child, open_row_menu, Rename, +}; +use super::tree_sync::{ + direct_holdings, refresh_membership, refresh_tree, sync_badges, sync_reorderable, +}; + +/// Tree navigation. +pub(super) fn wire_tree_navigation( + window: &AppWindow, + ctl: &Rc, + catalog: &Rc>>, + on_scope_changed: &Rc, +) { + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let reload = on_scope_changed.clone(); + window.on_collection_select(move |id| { + let Some(w) = weak.upgrade() else { return }; + + // -1 is the trash. It is not a collection, so it clears `scope` + // rather than setting it — see `viewing_trash`. + ctl.viewing_trash.set(id == -1); + *ctl.scope.borrow_mut() = if id <= 0 { + None + } else { + Some(CollectionId(id as u64)) + }; + + // A selection the user cannot see is one they will act on by + // accident, and the new scope shows different images. + ctl.clear_selection(); + + let label = if id == -1 { + "Trash".to_string() + } else if id == 0 { + String::new() + } else { + let rows = w.get_collection_rows(); + (0..rows.row_count()) + .filter_map(|i| rows.row_data(i)) + .find(|r| r.id == id) + .map(|r| r.name.to_string()) + .unwrap_or_default() + }; + w.set_collection_selected(id); + w.set_collection_scope_label(label.into()); + sync_reorderable(&w); + reload(); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + window.on_collection_toggle(move |id| { + let Some(w) = weak.upgrade() else { return }; + let id = CollectionId(id as u64); + { + let mut collapsed = ctl.collapsed.borrow_mut(); + if !collapsed.remove(&id) { + collapsed.insert(id); + } + } + let borrow = catalog.borrow(); + if let Some(cat) = borrow.as_ref() { + refresh_tree(&w, &ctl, cat); + } + }); + } +} + +/// Create. +pub(super) fn wire_create( + window: &AppWindow, + ctl: &Rc, + catalog: &Rc>>, +) { + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + window.on_collection_new(move || { + let Some(w) = weak.upgrade() else { return }; + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { + w.set_collection_error("Open a library first.".into()); + return; + }; + + // Created inside whatever is selected, which is how a hierarchy + // gets built without leaving the header: select the parent, press + // +. The row menu aims the same act at a row instead. + create_child(&w, &ctl, cat, *ctl.scope.borrow()); + }); + } +} + +/// Rename. +/// +/// Inline in the row, opened by a double-click or `F2`. The gesture is worth +/// the field rather than a dialog: renaming is how a hierarchy gets tidied, +/// and it is done in runs of several — a modal per collection would make +/// that a chore. +pub(super) fn wire_rename( + window: &AppWindow, + ctl: &Rc, + catalog: &Rc>>, +) { + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_collection_rename_start(move |id| { + let Some(w) = weak.upgrade() else { return }; + // `F2` arrives with whatever the sidebar has selected, which may be + // "All photographs" (0) or the trash (-1). Neither has a name to + // change, so the key does nothing rather than opening a field on a + // row that is not a collection. + if id <= 0 { + return; + } + let id = CollectionId(id as u64); + + // A saved filter is renameable like any other — its *membership* is + // computed, its name is not — so there is no kind check here. + *ctl.renaming.borrow_mut() = Some(id); + w.set_collection_renaming(id.0 as i32); + w.set_collection_error(slint::SharedString::new()); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + window.on_collection_rename_commit(move |id, name| { + let Some(w) = weak.upgrade() else { return }; + let id = CollectionId(id as u64); + + // The field reports a commit when it loses focus as well as on + // Enter, so a second one can arrive for a rename already closed — + // Enter commits, and the focus the field then gives up commits + // again. Ignored rather than reapplied: the second would bump the + // revision for no change and beat a real edit on another device. + if *ctl.renaming.borrow() != Some(id) { + return; + } + + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + + match apply_rename(cat.connection(), id, name.as_str()) { + Ok(Rename::Applied(name)) => { + close_rename(&w, &ctl); + w.set_collection_error(slint::SharedString::new()); + refresh_tree(&w, &ctl, cat); + // The header names the collection being shown, so a rename + // of the current scope has to reach it too. + if *ctl.scope.borrow() == Some(id) { + w.set_collection_scope_label(name.as_str().into()); + } + log::info!("renamed collection {} to {name}", id.0); + } + Ok(Rename::Unchanged) => close_rename(&w, &ctl), + Err(e) => { + // The field stays open on what the user typed. Closing it + // would drop their text and leave the old name showing, + // with only a line of red to explain where it went. + w.set_collection_error(format!("renaming: {e}").into()); + } + } + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_collection_rename_cancel(move || { + let Some(w) = weak.upgrade() else { return }; + close_rename(&w, &ctl); + w.set_collection_error(slint::SharedString::new()); + }); + } +} + +/// Remove from the collection being shown. +pub(super) fn wire_remove( + window: &AppWindow, + ctl: &Rc, + catalog: &Rc>>, + visible_ids: &Rc Vec>, + on_scope_changed: &Rc, +) { + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + let visible = visible_ids.clone(); + let reload = on_scope_changed.clone(); + window.on_library_remove_from_collection(move || { + let Some(w) = weak.upgrade() else { return }; + let Some(scope) = *ctl.scope.borrow() else { + // Unscoped, there is no collection to remove from. The button + // is hidden in that state; this guards the callback anyway. + return; + }; + let chosen = ctl.selected(); + if chosen.is_empty() { + return; + } + + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + + match coll::remove_images(cat.connection(), scope, &chosen) { + Ok(n) => { + w.set_collection_error(slint::SharedString::new()); + // Named explicitly as a membership change: the images are + // still in the library, and a user who reads this as a + // delete will not trust the feature again. + w.set_library_status( + format!("Removed {n} from this collection; still in the library").into(), + ); + ctl.clear_selection(); + refresh_tree(&w, &ctl, cat); + sync_badges(&w, cat, &visible()); + reload(); + } + Err(e) => w.set_collection_error(format!("removing: {e}").into()), + } + }); + } +} + +/// The tree row's own hold-drag: lift a row, drag it onto another to +/// reparent, or onto the root to un-nest it. +pub(super) fn wire_row_drag( + window: &AppWindow, + ctl: &Rc, + catalog: &Rc>>, +) { + // TRACES: FR-CAT-7 | FR-UI-2 | FR-UI-3 | FR-UI-4 + // The hold on a sidebar row: it arms the drag that rearranges the tree, + // and it opens the row menu. Which one the user gets is decided on release + // by whether they moved. + // + // Both from one gesture because there is only one to spend. A finger has + // no right button, and this tree lives in a Flickable that claims any drag + // beginning inside it — so without a hold, a touch drag on a row is a + // scroll, and arranging the tree is a pointer-only feature. The row lifts + // the moment the timer fires, which is what tells the user the next + // movement will carry the collection rather than scroll past it. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_collection_row_press(move |id, down| { + let Some(w) = weak.upgrade() else { return }; + let id = CollectionId(id as u64); + + if !down { + // Let go. A drag that began has already been dealt with by the + // drop; a hold that did not move is a request for the menu. + *ctl.row_hold_timer.borrow_mut() = None; + let held = ctl.lifted.borrow_mut().take(); + let dragged = ctl.drag_began.replace(false); + w.set_collection_lifted(0); + if let Release::OpenMenu(held) = decide_release(held, dragged) { + w.invoke_collection_menu(held.0 as i32); + } + return; + } + + // A drag of this row, if one follows, carries this collection. + ctl.note_row_press(id); + ctl.drag_began.set(false); + + let timer = slint::Timer::default(); + let weak = w.as_weak(); + let ctl_cb = ctl.clone(); + timer.start( + slint::TimerMode::SingleShot, + std::time::Duration::from_millis(HOLD_DELAY_MS), + move || { + let Some(w) = weak.upgrade() else { return }; + // Picked up, not yet acted on. The menu waits for the + // release, because opening it here would put a sheet over + // the tree the user may be about to drag this row across. + *ctl_cb.lifted.borrow_mut() = Some(id); + w.set_collection_lifted(id.0 as i32); + }, + ); + *ctl.row_hold_timer.borrow_mut() = Some(timer); + }); + } + + // TRACES: FR-CAT-7 | FR-UI-4 + // A row's drag crossed the threshold, or ended. + // + // This is what turns a hold into a rearrangement: once it has fired, the + // release that follows opens no menu. It also decides whether + // "All photographs" will accept the drop, which only makes sense for a + // collection that has a parent to be taken out of. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_collection_drag_active(move |id, active| { + let Some(w) = weak.upgrade() else { return }; + let id = CollectionId(id as u64); + + if !active { + w.set_collection_root_drop_allowed(false); + w.set_collection_lifted(0); + *ctl.lifted.borrow_mut() = None; + return; + } + + // The hold may not have fired — a pointer drag needs no hold — so + // the pick-up is recorded here as well as there, and the row lifts + // either way. + ctl.drag_began.set(true); + ctl.note_row_press(id); + *ctl.lifted.borrow_mut() = Some(id); + w.set_collection_lifted(id.0 as i32); + // The hold has been spent on a drag; nothing may fire behind it. + *ctl.row_hold_timer.borrow_mut() = None; + + // Only a nested collection has a top level to be returned to, and + // only a collection drag — never photographs — means anything + // there at all. + let carrying_images = !ctl.dragging.borrow().is_empty(); + let nested = ctl + .row_ids + .borrow() + .iter() + .position(|&c| c == id) + .and_then(|row| ctl.row_nested.borrow().get(row).copied()) + .unwrap_or(false); + w.set_collection_root_drop_allowed(nested && !carrying_images); + }); + } + + // TRACES: FR-CAT-7 + // Dropped on "All photographs": out to the top level. + // + // The gestural inverse of dropping one row onto another. `can-drop` has + // already refused everything but a nested collection, so reaching here + // means the drop is one — but the payload is taken rather than read, for + // the reason the row's own drop gives: a remembered press must be spent by + // the drop it belongs to, or a later empty drop moves a collection nobody + // touched. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + window.on_collection_dropped_on_root(move || { + let Some(w) = weak.upgrade() else { return }; + let source = ctl.dragging_collection.borrow_mut().take(); + let Some(source) = source else { return }; + // Photographs cannot land here. Guarded as well as refused in + // `can-drop`, so a payload that somehow arrives does nothing + // rather than reparenting whatever row was last pressed. + if !ctl.dragging.borrow().is_empty() { + return; + } + + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + let moved = coll::set_parent(cat.connection(), source, None); + match moved { + Ok(()) => { + w.set_collection_error(slint::SharedString::new()); + w.set_library_status("Moved to the top level".into()); + refresh_tree(&w, &ctl, cat); + log::info!("promoted collection {} by drag", source.0); + } + Err(e) => w.set_collection_error(format!("moving collection: {e}").into()), + } + }); + } +} + +/// The row menu. +/// +/// Right-click, or a long press on touch. This gesture used to *delete an +/// empty collection outright* and refuse with an error message otherwise, +/// which was wrong in both directions at once: the destructive half fired +/// with no confirmation and nothing on screen said it would, and the +/// refusing half meant a collection holding anything could not be deleted +/// at all — the user emptied it by hand and then did the same thing. +pub(super) fn wire_row_menu( + window: &AppWindow, + ctl: &Rc, + catalog: &Rc>>, + on_scope_changed: &Rc, + visible_ids: &Rc Vec>, +) { + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + window.on_collection_menu(move |id| { + let Some(w) = weak.upgrade() else { return }; + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + open_row_menu(&w, &ctl, cat, CollectionId(id as u64)); + }); + } + + // Rename, from the menu into the row's own inline field. + // + // The menu closes first and the field opens second, and the order is + // load-bearing for the same reason it is in `collection-new`: the field is + // focused by an `init` on a row, and a row torn down by a model rebuild + // takes the focus with it. Here the tear-down is the sheet's, and a field + // opened behind a sheet that is still up is a field the user cannot see + // they are typing into. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_collection_menu_rename(move || { + let Some(w) = weak.upgrade() else { return }; + let Some(id) = *ctl.menu_for.borrow() else { + return; + }; + close_row_menu(&w, &ctl); + *ctl.renaming.borrow_mut() = Some(id); + w.set_collection_renaming(id.0 as i32); + }); + } + + // A new collection nested inside this one. + // + // The `+` in the header already creates inside whatever the grid is scoped + // to, which builds a hierarchy only if you first go and look at the parent. + // This is the same act aimed at a row, so a tree can be built from the tree. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + window.on_collection_menu_new_child(move || { + let Some(w) = weak.upgrade() else { return }; + let Some(parent) = *ctl.menu_for.borrow() else { + return; + }; + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + close_row_menu(&w, &ctl); + create_child(&w, &ctl, cat, Some(parent)); + }); + } + + // TRACES: FR-CAT-7 + // Un-nesting: back out to the top level. + // + // Nesting has had a gesture since collections landed — drag a row onto + // another row — and its inverse had none, in either direction: "All + // photographs" ignores drops on purpose (an image is already in the + // library), and no menu existed to ask. So a collection dragged into + // another was in there permanently, and the only way out was to delete it + // and build it again. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + window.on_collection_menu_promote(move || { + let Some(w) = weak.upgrade() else { return }; + let Some(id) = *ctl.menu_for.borrow() else { + return; + }; + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + + match coll::set_parent(cat.connection(), id, None) { + Ok(()) => { + w.set_collection_error(slint::SharedString::new()); + w.set_library_status("Moved to the top level".into()); + close_row_menu(&w, &ctl); + refresh_tree(&w, &ctl, cat); + log::info!("promoted collection {} to the top level", id.0); + } + // Left open on failure, showing the collection it failed on: + // closing would leave a red line referring to a row the user + // can no longer tell was the one they aimed at. + Err(e) => w.set_collection_error(format!("moving collection: {e}").into()), + } + }); + } + + // TRACES: FR-NC-6a + // The offline question, handed to the handler the tray on the row already + // uses. Routed through the window's own callback rather than reaching into + // `library_ui`'s prompt directly: that module owns the transfer state the + // prompt reads, and a second entry point into it is a second place for the + // two to disagree about what is downloading. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_collection_menu_offline(move || { + let Some(w) = weak.upgrade() else { return }; + let Some(id) = *ctl.menu_for.borrow() else { + return; + }; + close_row_menu(&w, &ctl); + w.invoke_collection_offline_menu(id.0 as i32); + }); + } + + // TRACES: FR-CAT-7 + // Delete, in one press or two — see [`decide_delete`]. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + let reload = on_scope_changed.clone(); + let visible = visible_ids.clone(); + window.on_collection_menu_delete(move || { + let Some(w) = weak.upgrade() else { return }; + let Some(id) = *ctl.menu_for.borrow() else { + return; + }; + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + + let (holds, children) = direct_holdings(cat.connection(), id).unwrap_or((0, 0)); + if decide_delete(holds, children, ctl.menu_confirming.get()) == DeleteStep::Confirm { + // Asked once. The sheet swaps its other actions for the + // warning, so the second press cannot be a mis-aimed first one. + ctl.menu_confirming.set(true); + w.set_collection_menu_confirming(true); + w.set_collection_menu_delete_label("Delete anyway".into()); + return; + } + + // Hoisted out of the `match` rather than called in the scrutinee: + // the borrow `cat` comes from lives as long as the match does, and + // the arm below has to release it before rereading the grid. + let deleted = coll::delete(cat.connection(), id); + match deleted { + Ok(()) => { + w.set_collection_error(slint::SharedString::new()); + close_row_menu(&w, &ctl); + + // The grid was showing what no longer exists. + let was_scope = *ctl.scope.borrow() == Some(id); + if was_scope { + *ctl.scope.borrow_mut() = None; + w.set_collection_selected(0); + w.set_collection_scope_label(slint::SharedString::new()); + } + refresh_tree(&w, &ctl, cat); + // Named as a membership change, as the removal button is: + // a user who reads this as a delete of their photographs + // will not trust the feature again. + w.set_library_status(if holds > 0 { + format!( + "Deleted the collection; its {holds} photograph(s) stay in the library" + ) + .into() + } else { + slint::SharedString::from("Deleted the collection") + }); + log::info!("deleted collection {}", id.0); + + // The badge on every visible cell just lost a collection, + // and a scope that has gone needs the grid rereading. + let cells = visible(); + sync_badges(&w, cat, &cells); + drop(borrow); + if was_scope { + reload(); + } + } + Err(e) => w.set_collection_error(format!("deleting: {e}").into()), + } + }); + } + + // Dismiss. A pending confirmation is backed out of one step rather than + // closing the whole menu: Escape from "are you sure" means "no", and + // taking the sheet away with it would leave the user unsure whether the + // key had cancelled the delete or performed it. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_collection_menu_dismiss(move || { + let Some(w) = weak.upgrade() else { return }; + if ctl.menu_confirming.get() { + ctl.menu_confirming.set(false); + w.set_collection_menu_confirming(false); + w.set_collection_menu_delete_label("Delete".into()); + return; + } + close_row_menu(&w, &ctl); + }); + } +} + +/// Where the selection is filed. +pub(super) fn wire_membership( + window: &AppWindow, + ctl: &Rc, + catalog: &Rc>>, + visible_ids: &Rc Vec>, + on_scope_changed: &Rc, +) { + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + window.on_library_open_membership(move || { + let Some(w) = weak.upgrade() else { return }; + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + refresh_membership(&w, &ctl, cat); + w.set_membership_open(true); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + let visible = visible_ids.clone(); + let reload = on_scope_changed.clone(); + window.on_membership_remove(move |id| { + let Some(w) = weak.upgrade() else { return }; + let target = CollectionId(id as u64); + let chosen = ctl.selected(); + if chosen.is_empty() { + return; + } + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + + // Hoisted for the same reason the delete above is: the arm + // releases the catalog borrow before it rereads the grid. + let removed = coll::remove_images(cat.connection(), target, &chosen); + match removed { + Ok(n) => { + w.set_collection_error(slint::SharedString::new()); + // The same wording the scoped button uses, and for the same + // reason: this is a membership change, not a delete. + w.set_library_status( + format!("Removed {n} from that collection; still in the library").into(), + ); + // The sheet stays open. Taking photographs out of several + // collections is one job, and a sheet that closed after + // each would have to be reopened — with the selection + // still live — to finish it. + refresh_membership(&w, &ctl, cat); + refresh_tree(&w, &ctl, cat); + let cells = visible(); + sync_badges(&w, cat, &cells); + // Only the collection on screen changes what the grid + // holds. Removing from another one leaves the grid right, + // and rereading it would scroll the user's place away. + let showing = *ctl.scope.borrow() == Some(target); + drop(borrow); + if showing { + reload(); + } + } + Err(e) => w.set_collection_error(format!("removing: {e}").into()), + } + }); + } + + { + let weak = window.as_weak(); + window.on_membership_dismiss(move || { + let Some(w) = weak.upgrade() else { return }; + w.set_membership_open(false); + }); + } +}