Every folder the desktop asked for was a text field: the library folder at launch, an import's source and second copy, a preset folder brought over from Lightroom. A typed path is how a destination silently becomes a new folder nobody meant — one wrong letter three levels down and the write succeeds somewhere the photographer will never look — and a field cannot make the folder that is not there yet. They now open the platform's own dialogue through rfd: the XDG desktop portal on Linux, the common item dialogue on Windows. The portal rather than GTK because it reaches the user's files from inside the Flatpak and needs no GTK in a Slint application, and it draws whichever desktop's chooser is running, "New folder" included. It is awaited on Slint's event loop (spawn_local), so the window keeps drawing while it is open, and parented to the window so it opens over it. PathRow shows what is chosen, read-only, beside the button. Android has no filesystem dialogue — only SAF, which returns document trees, not paths — so there the same rows stay typed fields (Pickers.local-paths). The launch screen keeps the folder used last on screen with "Open folder" beside it, so reopening is one press. Presets get two buttons, a folder and a single .xmp file, because no platform dialogue picks "a file or a folder" in one go.
393 lines
16 KiB
Plaintext
393 lines
16 KiB
Plaintext
import { Theme } from "theme.slint";
|
||
import { Button, PanelHeading, Label, Value, Caption, Panel, ProgressBar } from "widgets.slint";
|
||
import { Segmented, TextRow, Check, ChoiceChip } from "controls.slint";
|
||
import { PathRow, Pickers } from "pickers.slint";
|
||
|
||
// TRACES: FR-CAT-10 | FR-CAT-11 | FR-NC-7a
|
||
// Import: bringing a card into the library.
|
||
//
|
||
// A full-window page, the same shape as Settings and for the first of the same
|
||
// reasons — it refers to the library constantly, and a modal that has to be
|
||
// dismissed to check where something went is a dialogue arguing with itself.
|
||
//
|
||
// The second reason does not apply and produces the one structural difference:
|
||
// settings save on change and have nothing to confirm, where an import is a
|
||
// decision. So this page has a commit button, and everything above it is the
|
||
// sentence that button completes: take *these* files, from *there*, and put
|
||
// them *here*.
|
||
//
|
||
// # Why the destination is shown and not editable
|
||
//
|
||
// A photograph goes into the library, and the library is chosen at launch.
|
||
// Offering a second place to set it here would let the two disagree, and the
|
||
// disagreement would be discovered as a folder of photographs the grid does
|
||
// not show. What *is* editable is the layout within it (FR-NC-7a), which is a
|
||
// different question with a visible answer right beside it.
|
||
|
||
// One volume the app found, as a row that can be picked.
|
||
//
|
||
// The subtitle carries why it is being offered — a camera card, a removable
|
||
// drive — because "EOS DIGITAL" and "archive" look equally plausible in a list
|
||
// and only one of them is what the user just plugged in.
|
||
component VolumeRow inherits Rectangle {
|
||
in property <string> label;
|
||
in property <string> detail;
|
||
in property <bool> selected: false;
|
||
callback clicked();
|
||
|
||
height: 44px;
|
||
border-radius: Theme.radius-sm;
|
||
background: root.selected ? Theme.selected
|
||
: (touch.pressed ? Theme.pressed
|
||
: (touch.has-hover ? Theme.hover : transparent));
|
||
border-width: root.selected ? 1px : 0px;
|
||
border-color: Theme.selected-ring;
|
||
|
||
touch := TouchArea {
|
||
clicked => { root.clicked(); }
|
||
}
|
||
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: Theme.gap;
|
||
|
||
VerticalLayout {
|
||
alignment: center;
|
||
spacing: 1px;
|
||
Label { text: root.label; body: true; }
|
||
Caption { text: root.detail; }
|
||
}
|
||
|
||
Rectangle { horizontal-stretch: 1; }
|
||
}
|
||
}
|
||
|
||
export component ImportPage inherits Rectangle {
|
||
// --- where the photographs are coming from ---------------------------
|
||
in property <[string]> volume-labels;
|
||
in property <[string]> volume-details;
|
||
/// -1 while nothing is chosen, which is what the page opens on.
|
||
in property <int> volume-selected: -1;
|
||
/// The chosen source, also typable — a card mounted somewhere this app
|
||
/// does not think to look is still a card.
|
||
in-out property <string> card-path;
|
||
/// Whether the source holds a DCIM folder. Advisory: plenty of legitimate
|
||
/// sources do not, so this informs and never blocks.
|
||
in property <bool> card-looks-right: false;
|
||
|
||
callback volume-picked(int);
|
||
callback card-path-changed(string);
|
||
/// Browse for the source with the platform's dialogue.
|
||
callback choose-card();
|
||
callback refresh-volumes();
|
||
|
||
// --- what is on it ---------------------------------------------------
|
||
in property <bool> surveying: false;
|
||
/// "1,847 photographs · 61.2 GB". Empty before anything has been counted.
|
||
in property <string> survey-summary;
|
||
|
||
// --- where they are going --------------------------------------------
|
||
/// The library folder on the server — the only destination there is.
|
||
///
|
||
/// Empty means no account is signed in, and Import stays disabled: the
|
||
/// library lives on the server, so an import without one has nowhere to
|
||
/// go at all.
|
||
in property <string> upload-target;
|
||
in-out property <string> folder-template;
|
||
/// The template expanded against a real date, so the answer to "what will
|
||
/// this do" is on screen rather than in the manual.
|
||
in property <string> template-preview;
|
||
|
||
in property <[string]> mode-labels;
|
||
in property <int> mode-selected: 0;
|
||
/// Whether the chosen mode will eventually erase the card.
|
||
in property <bool> mode-is-move: false;
|
||
|
||
in property <[string]> duplicate-labels;
|
||
in property <int> duplicate-selected: 0;
|
||
in property <bool> verify: true;
|
||
in property <bool> backup-on: false;
|
||
in-out property <string> backup-path;
|
||
|
||
callback template-changed(string);
|
||
callback mode-picked(int);
|
||
callback duplicate-picked(int);
|
||
callback verify-toggled(bool);
|
||
callback backup-toggled(bool);
|
||
callback backup-path-changed(string);
|
||
callback choose-backup();
|
||
|
||
// --- doing it --------------------------------------------------------
|
||
in property <bool> running: false;
|
||
in property <float> fraction: 0;
|
||
/// What it is doing now, or what it did — one line, always present once
|
||
/// something has happened.
|
||
in property <string> status;
|
||
in property <string> error;
|
||
/// Whether Import can be pressed at all.
|
||
in property <bool> can-start: false;
|
||
|
||
callback begin();
|
||
callback stop();
|
||
callback close();
|
||
|
||
background: Theme.ground;
|
||
|
||
// Somewhere for a key to start from, so Android's back gesture closes the
|
||
// page rather than the application. Same reasoning as the settings page.
|
||
FocusScope {
|
||
width: 0px;
|
||
height: 0px;
|
||
init => { self.focus(); }
|
||
}
|
||
|
||
VerticalLayout {
|
||
// --- header ------------------------------------------------------
|
||
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;
|
||
// Leaving mid-import would abandon a worker with no way
|
||
// back to it: the page is the only thing that reports what
|
||
// it is doing.
|
||
enabled: !root.running;
|
||
clicked => { root.close(); }
|
||
}
|
||
|
||
Value { text: "Import"; }
|
||
|
||
Rectangle { horizontal-stretch: 1; }
|
||
|
||
if root.running: Button {
|
||
text: "Stop";
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.stop(); }
|
||
}
|
||
|
||
if !root.running: Button {
|
||
text: "Import";
|
||
y: (parent.height - self.height) / 2;
|
||
enabled: root.can-start;
|
||
clicked => { root.begin(); }
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
y: parent.height - 1px;
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
}
|
||
|
||
// The bar sits directly under the header while a run is going, which
|
||
// is where every other progress bar in this application is.
|
||
if root.running: ProgressBar {
|
||
fraction: root.fraction;
|
||
indeterminate: root.surveying;
|
||
}
|
||
|
||
if root.error != "": Rectangle {
|
||
height: 32px;
|
||
background: Theme.surface;
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
Caption { text: root.error; warn: true; overflow: elide; }
|
||
}
|
||
}
|
||
|
||
// Named child plus an explicit viewport height, as the settings page
|
||
// does it: a Flickable cannot measure an anonymous layout, and scrolls
|
||
// nowhere without being told how tall its contents are.
|
||
Flickable {
|
||
vertical-stretch: 1;
|
||
viewport-height: content.preferred-height;
|
||
|
||
content := VerticalLayout {
|
||
width: 100%;
|
||
padding: Theme.gap;
|
||
spacing: Theme.gap;
|
||
alignment: start;
|
||
|
||
// --- source ------------------------------------------------
|
||
Panel {
|
||
PanelHeading { text: "From"; }
|
||
|
||
if root.volume-labels.length == 0: Caption {
|
||
text: Pickers.local-paths
|
||
? "No removable volume found. Browse to where the card is mounted, or plug it in and refresh."
|
||
: "No removable volume found. Type where the card is mounted, or plug it in and refresh.";
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
for label[i] in root.volume-labels: VolumeRow {
|
||
label: label;
|
||
detail: root.volume-details[i];
|
||
selected: i == root.volume-selected;
|
||
clicked => { root.volume-picked(i); }
|
||
}
|
||
|
||
// A card mounted somewhere this app does not think to
|
||
// look is still a card: the dialogue reaches it, where
|
||
// the list above only knows the usual mount points.
|
||
PathRow {
|
||
label: "Location";
|
||
path: root.card-path;
|
||
placeholder: "/run/media/…";
|
||
button-text: "Browse…";
|
||
enabled: !root.running;
|
||
choose => { root.choose-card(); }
|
||
typed(t) => { root.card-path-changed(t); }
|
||
}
|
||
|
||
HorizontalLayout {
|
||
spacing: Theme.gap;
|
||
Button {
|
||
text: "Refresh";
|
||
enabled: !root.running;
|
||
clicked => { root.refresh-volumes(); }
|
||
}
|
||
// Says what was found the moment it is known, so the
|
||
// user is not asked to press Import on faith.
|
||
Caption {
|
||
text: root.surveying ? "Looking…"
|
||
: (root.survey-summary != "" ? root.survey-summary : "");
|
||
vertical-alignment: center;
|
||
}
|
||
Rectangle { horizontal-stretch: 1; }
|
||
}
|
||
|
||
if root.card-path != "" && !root.card-looks-right && !root.surveying: Caption {
|
||
// Advisory, not a block: an archive folder is a
|
||
// legitimate source and has no DCIM either.
|
||
text: "No DCIM folder here — check this is the card and not somewhere else.";
|
||
wrap: word-wrap;
|
||
}
|
||
}
|
||
|
||
// --- destination -------------------------------------------
|
||
Panel {
|
||
PanelHeading { text: "Into"; }
|
||
|
||
HorizontalLayout {
|
||
spacing: Theme.gap;
|
||
Label { text: "Library"; }
|
||
Value {
|
||
text: root.upload-target != "" ? root.upload-target
|
||
: "not signed in";
|
||
overflow: elide;
|
||
horizontal-stretch: 1;
|
||
}
|
||
}
|
||
|
||
// The order is the promise, and a user watching a slow
|
||
// upload needs to already know the photographs are safe.
|
||
// Said here, beside the destination, rather than beside the
|
||
// Import button where it would read as a warning.
|
||
if root.upload-target != "": Caption {
|
||
text: "Copied to this computer and verified first, then uploaded. Anything that does not go up stays queued for the next import.";
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
if root.upload-target == "": Caption {
|
||
text: "Sign in first — the library this imports into is on the server.";
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
TextRow {
|
||
label: "Folders";
|
||
text <=> root.folder-template;
|
||
placeholder: "{yyyy}/{yyyy}-{mm}-{dd}";
|
||
field-width: 320px;
|
||
hint: "Tokens: {yyyy} {yy} {mm} {dd} {date} {make} {model}";
|
||
enabled: !root.running;
|
||
accepted(t) => { root.template-changed(t); }
|
||
}
|
||
|
||
// The template's answer, against a real photograph's date.
|
||
// A template language nobody can predict the output of is
|
||
// a template language nobody will change.
|
||
HorizontalLayout {
|
||
spacing: Theme.gap;
|
||
Label { text: "Example"; }
|
||
Value { text: root.template-preview; }
|
||
Rectangle { horizontal-stretch: 1; }
|
||
}
|
||
}
|
||
|
||
// --- how ----------------------------------------------------
|
||
Panel {
|
||
PanelHeading { text: "How"; }
|
||
|
||
Segmented {
|
||
label: "Transfer";
|
||
options: root.mode-labels;
|
||
selected: root.mode-selected;
|
||
enabled: !root.running;
|
||
picked(i) => { root.mode-picked(i); }
|
||
}
|
||
|
||
if root.mode-is-move: Caption {
|
||
// The one operation in this application with no undo,
|
||
// and the only honest place to say so is beside the
|
||
// control that arms it (FR-NC-7b).
|
||
text: "The card is emptied only of photographs the server has confirmed. Anything that does not upload keeps its card copy — which is then the only copy there is.";
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
Segmented {
|
||
label: "Already in the library";
|
||
options: root.duplicate-labels;
|
||
selected: root.duplicate-selected;
|
||
enabled: !root.running;
|
||
picked(i) => { root.duplicate-picked(i); }
|
||
}
|
||
|
||
Check {
|
||
label: "Verify every file";
|
||
hint: "Reads each copy back and compares it. Catches a dying card, which is otherwise invisible until the photograph is opened.";
|
||
checked: root.verify;
|
||
// `Check` has no enabled property, so the guard is on
|
||
// the callback: a toggle mid-run would change what the
|
||
// running worker was told to do, which it cannot hear.
|
||
toggled(v) => { if (!root.running) { root.verify-toggled(v); } }
|
||
}
|
||
|
||
Check {
|
||
label: "Second copy";
|
||
hint: "Written from the same read as the first, so it is a check on the copy rather than a copy of it.";
|
||
checked: root.backup-on;
|
||
toggled(v) => { if (!root.running) { root.backup-toggled(v); } }
|
||
}
|
||
|
||
if root.backup-on: PathRow {
|
||
label: "Second copy in";
|
||
// The toggle stores a single space to mean "on, no
|
||
// folder yet" (see `import_ui`), which is not a path.
|
||
path: root.backup-path == " " ? "" : root.backup-path;
|
||
placeholder: "No folder chosen";
|
||
enabled: !root.running;
|
||
choose => { root.choose-backup(); }
|
||
typed(t) => { root.backup-path-changed(t); }
|
||
}
|
||
}
|
||
|
||
// --- what happened -----------------------------------------
|
||
if root.status != "": Panel {
|
||
PanelHeading { text: root.running ? "Working" : "Result"; }
|
||
Caption { text: root.status; wrap: word-wrap; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|