Choose folders in the platform's dialogue, not by typing a path

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.
This commit is contained in:
2026-09-26 14:13:53 -04:00
parent 94542371f6
commit 6683c14b40
12 changed files with 612 additions and 47 deletions
+22 -10
View File
@@ -1,6 +1,7 @@
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.
@@ -77,6 +78,8 @@ export component ImportPage inherits Rectangle {
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 ---------------------------------------------------
@@ -113,6 +116,7 @@ export component ImportPage inherits Rectangle {
callback verify-toggled(bool);
callback backup-toggled(bool);
callback backup-path-changed(string);
callback choose-backup();
// --- doing it --------------------------------------------------------
in property <bool> running: false;
@@ -219,7 +223,9 @@ export component ImportPage inherits Rectangle {
PanelHeading { text: "From"; }
if root.volume-labels.length == 0: Caption {
text: "No removable volume found. Type where the card is mounted, or plug it in and refresh.";
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;
}
@@ -230,13 +236,17 @@ export component ImportPage inherits Rectangle {
clicked => { root.volume-picked(i); }
}
TextRow {
// 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";
text <=> root.card-path;
path: root.card-path;
placeholder: "/run/media/…";
field-width: 320px;
button-text: "Browse…";
enabled: !root.running;
accepted(t) => { root.card-path-changed(t); }
choose => { root.choose-card(); }
typed(t) => { root.card-path-changed(t); }
}
HorizontalLayout {
@@ -359,13 +369,15 @@ export component ImportPage inherits Rectangle {
toggled(v) => { if (!root.running) { root.backup-toggled(v); } }
}
if root.backup-on: TextRow {
if root.backup-on: PathRow {
label: "Second copy in";
text <=> root.backup-path;
placeholder: "/mnt/backup/Photos";
field-width: 320px;
// 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;
accepted(t) => { root.backup-path-changed(t); }
choose => { root.choose-backup(); }
typed(t) => { root.backup-path-changed(t); }
}
}