Let the top level be chosen as the library root

Confirming "/" in the folder picker set an empty root, which the launch
model read as no root at all: "Open library" stayed disabled after the
question had plainly been answered, and a folder library — whose folder
is the whole library — could never be opened without first descending
into a subfolder of it. The empty string was carrying two meanings.

Record the choice as its own fact on the account (`root_chosen`, defaulted
so existing configuration loads unchanged), treat a folder endpoint as
chosen by definition, and let the launch screen say so: a folder is shown
as a LIBRARY rather than an ACCOUNT, the second question becomes an
optional "scan only a subfolder", and the library header names the folder
instead of calling it "· whole account".
This commit is contained in:
2026-09-20 00:21:13 +02:00
parent e750bcdb8c
commit 0fa9003e54
8 changed files with 218 additions and 85 deletions
+14
View File
@@ -127,6 +127,19 @@ pub struct Account {
#[serde(default)]
pub root: String,
/// Whether [`root`](Self::root) has been chosen at all.
///
/// An empty `root` is two different things: nothing picked yet, and the
/// endpoint itself picked on purpose — a user who keeps everything at
/// the top level, or a folder library, which is its own root. The string
/// cannot tell them apart, and reading empty as "not chosen" meant the
/// top level could be confirmed in the picker and still not open. So the
/// fact is recorded separately. Defaulted, so an account written before
/// it existed loads as it always did: a non-empty root is chosen by
/// virtue of being there, and an empty one asks again.
#[serde(default)]
pub root_chosen: bool,
/// Which formats the scan looks for (the tick-boxes).
#[serde(default)]
pub formats: Vec<String>,
@@ -149,6 +162,7 @@ impl Account {
login: String::new(),
user_id: String::new(),
root: String::new(),
root_chosen: false,
formats: Vec::new(),
last_scan: None,
}
+11 -11
View File
@@ -25,7 +25,7 @@ Sampling a neutral is the first move of the tonal pass — every colour judgemen
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between.
<sub>`ui/dr-ui/ui/app.slint:2068`</sub>
<sub>`ui/dr-ui/ui/app.slint:2070`</sub>
### Move a magnified photograph about
@@ -34,7 +34,7 @@ Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as m
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
<sub>`ui/dr-ui/ui/app.slint:2159`</sub>
<sub>`ui/dr-ui/ui/app.slint:2161`</sub>
### Paint a mask by hand
@@ -43,7 +43,7 @@ Only once there is something outside the viewport to reach, which is why the cur
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
<sub>`ui/dr-ui/ui/app.slint:2246`</sub>
<sub>`ui/dr-ui/ui/app.slint:2248`</sub>
### Take back the last change
@@ -53,7 +53,7 @@ A model's mask stops inside a shoulder and leaks into the hair, and no single ed
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
<sub>`ui/dr-ui/ui/app.slint:2467`</sub>
<sub>`ui/dr-ui/ui/app.slint:2469`</sub>
### Do it again after taking it back
@@ -61,7 +61,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
- **Pointer** — Click it, or press Redo in the History header
- **Keyboard** — Ctrl+Shift+Z
<sub>`ui/dr-ui/ui/app.slint:2480`</sub>
<sub>`ui/dr-ui/ui/app.slint:2482`</sub>
### Copy the settings from this photograph
@@ -71,7 +71,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
The panel is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
<sub>`ui/dr-ui/ui/app.slint:2513`</sub>
<sub>`ui/dr-ui/ui/app.slint:2515`</sub>
### Paste the settings onto this photograph
@@ -81,7 +81,7 @@ The panel is the copy that has to work: a tablet has no modifier key to hold and
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
<sub>`ui/dr-ui/ui/app.slint:2525`</sub>
<sub>`ui/dr-ui/ui/app.slint:2527`</sub>
### Change which group of adjustments is on screen
@@ -91,7 +91,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
<sub>`ui/dr-ui/ui/app.slint:2553`</sub>
<sub>`ui/dr-ui/ui/app.slint:2555`</sub>
### Look at the photograph at 1:1
@@ -101,7 +101,7 @@ The groups are whatever the operation set declares itself to be about, so there
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans.
<sub>`ui/dr-ui/ui/app.slint:2588`</sub>
<sub>`ui/dr-ui/ui/app.slint:2590`</sub>
### Move to the next or previous photograph
@@ -111,7 +111,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing.
<sub>`ui/dr-ui/ui/app.slint:2640`</sub>
<sub>`ui/dr-ui/ui/app.slint:2642`</sub>
### See the photograph before you edited it
@@ -121,7 +121,7 @@ The edit on screen is saved on the way out, so stepping through a folder is as m
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
<sub>`ui/dr-ui/ui/app.slint:2764`</sub>
<sub>`ui/dr-ui/ui/app.slint:2766`</sub>
### Put one control back to its default
+50 -50
View File
File diff suppressed because one or more lines are too long
+79 -10
View File
@@ -218,6 +218,43 @@ impl LaunchModel {
self.session().map(|s| s.root.clone()).unwrap_or_default()
}
/// Whether a root has been settled on, including the top level itself.
///
/// Three ways to have one: a subfolder is named; the picker confirmed
/// the top level, which `Account::root_chosen` records because the
/// empty string cannot; or the endpoint is a folder, whose top level is
/// the library by definition.
pub fn root_is_chosen(&self) -> bool {
self.session()
.is_some_and(|s| !s.root.is_empty() || s.root_chosen)
|| self.endpoint_is_library()
}
/// What the signed-in section shows for the root: empty when nothing is
/// chosen, `/` for the top level, else the path.
pub fn library_root_label(&self) -> String {
let root = self.library_root();
if root.is_empty() && self.root_is_chosen() {
"/".to_string()
} else {
root
}
}
/// Whether the endpoint itself is the library.
///
/// A folder account has no sub-root to choose: the directory the user
/// typed is the whole library, and asking them to pick a "library
/// folder" inside it a second time reads as though the first answer was
/// not taken. A cloud account is the opposite — its endpoint is an
/// entire server-side tree, and a root inside it is required (see
/// [`can_open_library`](Self::can_open_library)). Keyed on the absence
/// of a login, the same fact [`prefill`] keys on, rather than on the
/// connector's id.
pub fn endpoint_is_library(&self) -> bool {
self.session().is_some_and(|s| s.login.is_empty())
}
/// The login URL while approval is pending.
pub fn login_url(&self) -> String {
match &self.state {
@@ -228,11 +265,13 @@ impl LaunchModel {
/// Whether "Open library" should be clickable.
///
/// Requires a signed-in account *and* a chosen folder: opening without one
/// would scan the whole account, which on a real library is thousands of
/// directories the user did not ask for.
/// A cloud account requires a signed-in session *and* a chosen folder:
/// opening without one would scan the whole account, which on a real
/// library is thousands of directories the user did not ask for. Chosen
/// is the operative word, not non-empty — the top level is a legitimate
/// choice, and a folder library is the whole folder by definition.
pub fn can_open_library(&self) -> bool {
self.is_signed_in() && !self.library_root().is_empty()
self.is_signed_in() && self.root_is_chosen()
}
/// What the app should do on startup.
@@ -386,6 +425,9 @@ impl LaunchModel {
let path = self.browser.as_ref()?.path.clone();
let mut session = self.session()?.clone();
session.root = path;
// Even when `path` is the top level: that is a choice, and the
// empty string alone would read as none having been made.
session.root_chosen = true;
self.browser = None;
self.state = LaunchState::SignedIn {
session: session.clone(),
@@ -547,17 +589,32 @@ mod tests {
// Everything past sign-in is backend-neutral, and this is the check
// that keeps it so: no branch on the account's connector below here.
let mut m = LaunchModel::default();
m.signed_in(folder_with_root(""));
assert!(m.is_signed_in());
assert!(!m.can_open_library(), "no root chosen yet");
assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen);
m.signed_in(folder_with_root("2026"));
assert!(m.is_signed_in());
assert!(m.can_open_library());
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
assert_eq!(m.account_label(), "/mnt/photos/2026");
}
#[test]
fn a_folder_is_the_whole_library_without_choosing_a_root() {
// The directory typed on the launch screen is the answer to "which
// folder"; a second question with the same name is what confused
// the first person to open one. A server account still needs a
// root, because its endpoint is the entire tree.
let mut m = LaunchModel::default();
m.signed_in(folder_with_root(""));
assert!(m.endpoint_is_library());
assert!(m.can_open_library(), "the folder itself is the library");
assert_eq!(m.library_root_label(), "/");
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
m.signed_in(session_with_root(""));
assert!(!m.endpoint_is_library());
assert!(!m.can_open_library(), "a server account needs a root");
assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen);
}
#[test]
fn format_toggles_apply_and_out_of_range_is_ignored() {
let mut m = LaunchModel::default();
@@ -665,13 +722,25 @@ mod tests {
#[test]
fn confirming_at_the_root_selects_the_whole_account() {
// Legitimate: a user may keep everything at the top level.
// Legitimate: a user may keep everything at the top level — and it
// must then open. It used to be recorded as an empty root, which
// is indistinguishable from no root, so "Open library" stayed
// disabled after the picker had plainly been answered.
let mut m = LaunchModel::default();
m.signed_in(session_with_root(""));
assert!(!m.can_open_library(), "nothing chosen yet");
assert_eq!(m.library_root_label(), "");
m.open_browser();
let session = m.choose_current_folder().expect("a session");
assert_eq!(session.root, "");
assert!(
session.root_chosen,
"the choice survives to the config file"
);
assert!(m.can_open_library());
assert_eq!(m.library_root_label(), "/");
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
}
#[test]
+2 -1
View File
@@ -44,7 +44,8 @@ pub fn render(window: &AppWindow, controller: &LaunchController) {
window.set_launch_signed_in(m.is_signed_in());
window.set_launch_account(m.account_label().into());
window.set_launch_root(m.library_root().into());
window.set_launch_root(m.library_root_label().into());
window.set_launch_endpoint_is_library(m.endpoint_is_library());
window.set_launch_server(m.server_url.clone().into());
window.set_launch_folder(m.folder_path.clone().into());
window.set_launch_busy(m.is_busy());
+40 -8
View File
@@ -984,14 +984,7 @@ pub fn open(
window.set_library_status("Starting…".into());
// Always visible: two folders one letter apart are easy to confuse, and a
// scan of the wrong one is indistinguishable from a broken scan.
window.set_library_root_label(
if conn.account.root.is_empty() {
format!("{} · whole account", conn.account.user_id)
} else {
format!("{}/{}", conn.account.user_id, conn.account.root)
}
.into(),
);
window.set_library_root_label(library_root_label(&conn.account).into());
// An empty filter would walk the whole tree and match nothing, which looks
// exactly like a broken scan. Say so instead.
@@ -7158,10 +7151,49 @@ fn stop(slot: &RefCell<Option<slint::Timer>>) {
}
}
/// The line in the library header that says what is being catalogued.
///
/// For an account with a user, the user and the chosen subtree, or the
/// whole account when none was chosen. For a folder there is no user and
/// the endpoint is the library, so the folder's own name: `library`, or
/// `library/2026` when narrowed — never " · whole account", which is a
/// sentence about a server said of a directory.
fn library_root_label(account: &Account) -> String {
if account.login.is_empty() {
let folder = std::path::Path::new(&account.endpoint)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| account.endpoint.clone());
if account.root.is_empty() {
folder
} else {
format!("{folder}/{}", account.root)
}
} else if account.root.is_empty() {
format!("{} · whole account", account.user_id)
} else {
format!("{}/{}", account.user_id, account.root)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_header_names_a_folder_library_by_its_folder() {
let mut folder = Account::new("folder", "/var/tmp/dr-demo/library");
assert_eq!(library_root_label(&folder), "library");
folder.root = "2026".into();
assert_eq!(library_root_label(&folder), "library/2026");
let mut cloud =
Account::new("nextcloud", "https://cloud.example").with_login("duncan", "duncan");
assert_eq!(library_root_label(&cloud), "duncan · whole account");
cloud.root = "PhotosRaw".into();
assert_eq!(library_root_label(&cloud), "duncan/PhotosRaw");
}
#[test]
fn a_scrub_fraction_interpolates_within_the_span() {
// The point of going fractional: a slow drag must advance
+2
View File
@@ -192,6 +192,7 @@ export component AppWindow inherits Window {
in property <bool> launch-signed-in: false;
in property <string> launch-account: "";
in property <string> launch-root: "";
in property <bool> launch-endpoint-is-library: false;
in property <string> launch-server: "";
in property <string> launch-folder: "";
in property <bool> launch-busy: false;
@@ -1546,6 +1547,7 @@ in property <bool> panel-visible: true;
signed-in: root.launch-signed-in;
account: root.launch-account;
library-root: root.launch-root;
endpoint-is-library: root.launch-endpoint-is-library;
server-url: root.launch-server;
folder-path: root.launch-folder;
busy: root.launch-busy;
+20 -5
View File
@@ -77,6 +77,10 @@ export component LaunchScreen inherits Rectangle {
in property <bool> signed-in: false;
in property <string> account: "";
in property <string> library-root: "";
/// The endpoint is the whole library — a folder — so there is no root
/// to choose inside it, only, optionally, a subfolder to narrow to. A
/// server account's endpoint is the entire tree and a root is required.
in property <bool> endpoint-is-library: false;
in property <string> server-url: "";
// Two endpoints, shown together. Someone deciding between a server and a
// folder should not have to clear one field to try the other.
@@ -349,7 +353,9 @@ export component LaunchScreen inherits Rectangle {
if root.signed-in && root.browsing: VerticalLayout {
spacing: Theme.gap;
PanelHeading { text: @tr("CHOOSE LIBRARY FOLDER"); }
PanelHeading {
text: root.endpoint-is-library ? @tr("SCAN ONLY A SUBFOLDER") : @tr("CHOOSE LIBRARY FOLDER");
}
// Current location, so it is always clear what
// "Use this folder" would select.
@@ -428,22 +434,31 @@ export component LaunchScreen inherits Rectangle {
if root.signed-in && !root.browsing: VerticalLayout {
spacing: Theme.gap;
PanelHeading { text: @tr("ACCOUNT"); }
// A folder *is* the library, and saying "account" of a
// directory, then asking for a "library folder" a second
// time, reads as though the path just typed was not
// taken. A server account genuinely has both: who it is,
// and which subtree of it to catalogue.
PanelHeading { text: root.endpoint-is-library ? @tr("LIBRARY") : @tr("ACCOUNT"); }
Value { text: root.account; }
Rectangle { height: Theme.gap-sm; }
PanelHeading { text: @tr("LIBRARY FOLDER"); }
PanelHeading {
text: root.endpoint-is-library ? @tr("SCAN") : @tr("LIBRARY FOLDER");
}
HorizontalLayout {
spacing: Theme.gap;
Value {
text: root.library-root == "" ? @tr("(not chosen)") : root.library-root;
text: root.library-root == "" ? @tr("(not chosen)")
: root.library-root == "/" && root.endpoint-is-library ? @tr("The whole folder")
: root.library-root;
placeholder: root.library-root == "";
horizontal-stretch: 1;
overflow: elide;
}
FormButton {
text: @tr("Choose…");
text: root.endpoint-is-library ? @tr("Subfolder…") : @tr("Choose…");
width: 110px;
clicked => { root.choose-folder(); }
}