Files
DarkRoom/ui/dr-ui/ui/duplicates.slint
T
dtourolle 220e9af222 Add the duplicate originals review, from the sidebar and from Settings
"Duplicate originals" appears under the trash in the collections
sidebar while the catalog holds any, and Settings says how many there
are beside the other whole-library passes. Both open one page: every
group with its picture and paths, the copy that stays (tap another path
to change it), a per-group Include box, what the survivor will gain and
any flag, label or face conflict, and why a group was skipped.

The summary is the dry run -- "N groups, M files to trash, K skipped" --
and nothing moves until "Check" has read the copies and "Move M copies
to trash" is pressed. Both run on workers with progress on the page, in
the activity register and, for the move, on the library status line;
Stop ends a job between groups. When it ends the grid, the sidebar and
the trash are refreshed and the survivors' judgements are written to
their sidecars and XMP the way a rating keystroke writes them.

The page is paginated at 30 groups, so a redraw decodes 30 thumbnails
and previews 30 merges whatever the size of the library. Back and
Escape leave it like its own Back button.
2026-09-26 07:19:13 -04:00

361 lines
12 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// TRACES: FR-CAT-11a | FR-CAT-15 | FR-UI-4
// The duplicate originals review: every group of copies of one file, which
// one stays, what the others bring to it, and the one button that moves the
// rest to the trash.
//
// Same shape as the merge page — a header with the one verb, a progress bar
// under it while a job runs, a scrolling body — because it is the same kind
// of page: a job proposes, the photographer confirms, and nothing moves
// before the button is pressed. Every row is a tap target the height of a
// finger, because the tablet uses this page too.
import { Theme } from "theme.slint";
import { Button, Caption, Value, ProgressBar } from "widgets.slint";
import { Check } from "controls.slint";
/// One copy's line in a group.
export struct DuplicateCopy {
path: string,
/// The copy that stays in the library.
survivor: bool,
}
/// One group of copies of one file.
export struct DuplicateRow {
/// "Canon EOS 6D · 24 Jun 2023 12:21 · 26.5 MB".
title: string,
thumb: image,
has-thumb: bool,
copies: [DuplicateCopy],
/// What the check found: "Same file", "Not checked yet", or why it was
/// left out.
status: string,
/// What the survivor gains, and any conflict.
detail: string,
/// Left out by the check, or failed.
warn: bool,
/// In the plan.
include: bool,
/// Whether the include box means anything: a group the check dropped
/// cannot be put back by hand.
can-include: bool,
/// Consolidated in this session.
done: bool,
}
export global Duplicates {
/// The current page of groups.
in property <[DuplicateRow]> rows;
/// The dry run: "1,836 groups · 3,379 copies to move to the trash · 2
/// skipped".
in property <string> summary;
/// "Groups 1–30 of 1,836".
in property <string> page-label;
in property <bool> has-prev: false;
in property <bool> has-next: false;
/// A check or a consolidation is running.
in property <bool> running: false;
in property <float> fraction: 0;
in property <string> progress-label;
/// The check's button and what it costs.
in property <string> check-label;
in property <string> check-cost;
in property <bool> can-check: false;
/// "Move 3,379 copies to trash".
in property <string> confirm-label;
in property <bool> can-confirm: false;
in property <string> error;
/// How many groups the library holds, for the entries into this page.
in property <int> group-count: 0;
callback open();
callback close();
callback check();
callback stop();
callback confirm();
callback prev-page();
callback next-page();
/// A copy was chosen to stay: row on this page, copy in the group.
callback pick-survivor(int, int);
/// A group was put in or taken out of the plan, by row on this page.
callback toggle(int, bool);
}
component CopyLine inherits Rectangle {
in property <DuplicateCopy> copy;
in property <bool> enabled: true;
callback picked();
height: Theme.touch-target;
border-radius: Theme.radius-sm;
background: root.copy.survivor ? Theme.surface-raised : transparent;
accessible-role: button;
accessible-label: (root.copy.survivor ? "Stays: " : "To the trash: ") + root.copy.path;
accessible-action-default => { root.picked(); }
HorizontalLayout {
padding-left: Theme.gap-sm;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
Text {
width: 88px;
text: root.copy.survivor ? "Stays" : "To trash";
color: root.copy.survivor ? Theme.ink : Theme.ink-faint;
font-size: Theme.text-sm;
font-weight: root.copy.survivor ? 600 : 400;
vertical-alignment: center;
}
Text {
text: root.copy.path;
color: root.copy.survivor ? Theme.ink : Theme.ink-dim;
font-size: Theme.text-sm;
overflow: elide;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
TouchArea {
enabled: root.enabled;
clicked => { root.picked(); }
}
}
component GroupCard inherits Rectangle {
in property <DuplicateRow> group;
in property <bool> busy;
callback pick(int);
callback toggled(bool);
background: Theme.surface;
border-radius: Theme.radius;
height: layout.preferred-height;
layout := HorizontalLayout {
padding: Theme.gap;
spacing: Theme.gap;
// One picture for the group: the copies are the same file.
Rectangle {
width: 96px;
height: 96px;
background: Theme.ground;
border-radius: Theme.radius-sm;
clip: true;
if root.group.has-thumb: Image {
width: parent.width;
height: parent.height;
source: root.group.thumb;
image-fit: contain;
}
}
VerticalLayout {
spacing: 2px;
horizontal-stretch: 1;
HorizontalLayout {
spacing: Theme.gap-sm;
Value {
text: root.group.title;
overflow: elide;
horizontal-stretch: 1;
}
if root.group.can-include && !root.group.done: Check {
label: "Include";
checked: root.group.include;
controlled: true;
toggled(on) => { root.toggled(on); }
}
}
for copy[i] in root.group.copies: CopyLine {
copy: copy;
enabled: !root.busy && !root.group.done && root.group.can-include;
picked => { root.pick(i); }
}
Caption {
text: root.group.status;
warn: root.group.warn;
wrap: word-wrap;
}
if root.group.detail != "": Caption {
text: root.group.detail;
wrap: word-wrap;
}
}
}
}
export component DuplicatesPage inherits Rectangle {
background: Theme.ground;
FocusScope {
width: 0px;
height: 0px;
init => { self.focus(); }
}
VerticalLayout {
Rectangle {
height: 44px;
background: Theme.surface;
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: Theme.gap;
Button {
text: "‹ Back";
y: (parent.height - self.height) / 2;
clicked => { Duplicates.close(); }
}
Value { text: "Duplicate originals"; }
Rectangle { horizontal-stretch: 1; }
if Duplicates.running: Button {
text: "Stop";
y: (parent.height - self.height) / 2;
clicked => { Duplicates.stop(); }
}
// GESTURE: Check duplicate originals are the same file
// where: Duplicate originals
// touch: Tap "Check"
// pointer: Click "Check"
// why: Nothing is moved on the catalog's say-so. The
// check reads the first and last megabyte of each
// copy and its sidecar, keeps what it read, and
// drops any group whose bytes or edits differ.
// manual: duplicate-originals
if !Duplicates.running && Duplicates.can-check: Button {
text: Duplicates.check-label;
y: (parent.height - self.height) / 2;
clicked => { Duplicates.check(); }
}
// GESTURE: Move the spare copies to the trash
// where: Duplicate originals
// touch: Tap "Move N copies to trash"
// pointer: Click "Move N copies to trash"
// why: The one verb, named with its count, and off until
// a check has proved something. Each group is merged
// onto the copy that stays and the others go to the
// trash together or not at all; the trash view gives
// them back.
// manual: duplicate-originals
if !Duplicates.running: Button {
text: Duplicates.confirm-label;
primary: true;
enabled: Duplicates.can-confirm;
y: (parent.height - self.height) / 2;
clicked => { Duplicates.confirm(); }
}
}
Rectangle {
y: parent.height - 1px;
height: 1px;
background: Theme.rule;
}
}
if Duplicates.running: ProgressBar {
fraction: Duplicates.fraction;
label: Duplicates.progress-label;
}
if Duplicates.error != "": Rectangle {
height: 32px;
background: Theme.surface;
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap;
Caption { text: Duplicates.error; warn: true; overflow: elide; }
}
}
Flickable {
vertical-stretch: 1;
viewport-height: body.preferred-height;
body := VerticalLayout {
width: 100%;
padding: Theme.gap-lg;
spacing: Theme.gap;
alignment: start;
Caption {
text: Duplicates.summary;
wrap: word-wrap;
}
if Duplicates.running: Caption {
text: Duplicates.progress-label;
}
if Duplicates.check-cost != "": Caption {
text: Duplicates.check-cost;
wrap: word-wrap;
}
// GESTURE: Choose which copy of a duplicate stays
// where: Duplicate originals
// touch: Tap the copy's path
// pointer: Click the copy's path
// why: The rule picks the copy outside a backup folder
// that still has the camera's name. The path is the
// evidence, so the path is what is tapped to
// overrule it.
// manual: duplicate-originals
//
// GESTURE: Leave a duplicate group as it is
// where: Duplicate originals
// touch: Untick "Include" on the group
// pointer: Untick "Include" on the group
// why: The plan is every group the check proved; a group
// you want to keep in two places is taken out of it
// rather than argued with.
// manual: duplicate-originals
for row[i] in Duplicates.rows: GroupCard {
group: row;
busy: Duplicates.running;
pick(copy) => { Duplicates.pick-survivor(i, copy); }
toggled(on) => { Duplicates.toggle(i, on); }
}
if Duplicates.has-prev || Duplicates.has-next: HorizontalLayout {
spacing: Theme.gap;
alignment: center;
Button {
text: "‹ Previous";
enabled: Duplicates.has-prev;
clicked => { Duplicates.prev-page(); }
}
Caption {
text: Duplicates.page-label;
vertical-alignment: center;
}
Button {
text: "Next ›";
enabled: Duplicates.has-next;
clicked => { Duplicates.next-page(); }
}
}
}
}
}
}