Make "Choose folder" an actual folder picker

It previously fetched the folder list and threw it away into a status
line — a button that looked like it worked and did not. Now it opens a
browsable picker: click a folder to descend, ".." to go back, "Use this
folder" to select, "Cancel" to leave the root unchanged.

Descends one level per click because that is what the backend supports:
Depth: infinity is frequently disabled server-side and prohibitively
expensive where it is not (ARCH §8.4).

The chosen root persists immediately on confirm, so it survives a crash
before the library is opened. Confirming at the account root is allowed —
a user may legitimately keep everything at the top level — and cancelling
leaves any previous selection untouched, which a test asserts.

Verified against nextcloud.tourolle.paris at both depths: 30 folders at
the root, 21 year-folders inside PhotosRaw.

19 launch tests, 38 in dr-ui.
This commit is contained in:
2026-08-09 15:50:40 +02:00
parent f630a3ff81
commit 2ca1716a29
10 changed files with 888 additions and 65 deletions
+159 -1
View File
@@ -11,7 +11,10 @@
use dr_decode::RawImage;
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
use dr_pipeline::{CropRect, EditGraph, OpId, ParamId, ParamKind, Unit};
use dr_pipeline::ops::curve;
use dr_pipeline::{
CropRect, EditGraph, OpCapability, OpId, ParamId, ParamKind, Presentation, Unit, WidgetKind,
};
use crate::labels;
use crate::ParamRow;
@@ -43,6 +46,23 @@ impl DevelopSession {
pub fn rows(&self) -> Vec<ParamRow> {
let mut rows = Vec::new();
for (op_index, op) in self.graph.capabilities().iter().enumerate() {
// An operation may ask for one widget spanning several
// parameters. Honouring it is optional — dropping this block
// renders the same parameters as ordinary sliders, and the edit
// still works — which is exactly why the hint is a hint.
if let Some(presentation) = &op.presentation {
// A `match` rather than an `if let`: when a second widget
// kind is added, this stops compiling until it is handled,
// rather than silently falling through to sliders.
let row = match presentation.widget {
WidgetKind::Curve => self.curve_row(op_index, op, presentation),
};
if let Some(row) = row {
rows.push(row);
continue;
}
}
for (param_index, p) in op.params.iter().enumerate() {
let (kind, min, max, precision, unit) = match &p.kind {
ParamKind::Scalar {
@@ -76,12 +96,128 @@ impl DevelopSession {
maximum: max,
precision,
unit: unit.into(),
// Only curve rows carry points.
points: slint::ModelRc::new(slint::VecModel::from(Vec::<f32>::new())),
});
}
}
rows
}
/// One row standing for a whole curve.
///
/// Returns `None` if the operation's parameters do not look like point
/// coordinates, in which case the caller falls back to sliders rather
/// than rendering a broken widget.
fn curve_row(
&self,
op_index: usize,
op: &OpCapability,
presentation: &Presentation,
) -> Option<ParamRow> {
// Points are x/y pairs, so an odd count means the operation and this
// code disagree about the layout.
if presentation.params.len() < 2 || presentation.params.len() % 2 != 0 {
log::warn!("{}: curve widget needs an even parameter count", op.id);
return None;
}
// The widget addresses points by offset from the first, so they must
// be contiguous in the capability list.
let base = op
.params
.iter()
.position(|p| p.id == presentation.params[0])?;
for (i, id) in presentation.params.iter().enumerate() {
if op.params.get(base + i).map(|p| p.id) != Some(*id) {
log::warn!("{}: curve parameters are not contiguous", op.id);
return None;
}
}
let points: Vec<f32> = presentation
.params
.iter()
.filter_map(|id| op.params.iter().find(|p| p.id == *id))
.map(|p| p.value)
.collect();
Some(ParamRow {
op_index: op_index as i32,
// The first point parameter; the widget offsets from here.
param_index: base as i32,
op_label: labels::resolve(op.label.0).into(),
param_label: String::new().into(),
starts_group: true,
kind: "curve".into(),
value: 0.0,
default_value: 0.0,
minimum: 0.0,
maximum: 1.0,
precision: 4,
unit: String::new().into(),
points: slint::ModelRc::new(slint::VecModel::from(points)),
})
}
/// The curve's shape, sampled for drawing.
///
/// Evaluated with `dr_pipeline`'s own spline, so the line the user drags
/// is the line the shader applies. The alternative — reading the curve
/// back off the GPU — is the round-trip ARCH §6.1 forbids, to draw a
/// polyline.
pub fn curve_samples(&self) -> Vec<f32> {
const SAMPLES: usize = 96;
let mut xs = [0.0f32; curve::POINTS];
let mut ys = [0.0f32; curve::POINTS];
let mut found = false;
for cap in self.graph.capabilities() {
if cap.id != curve::ID {
continue;
}
found = true;
for (i, p) in cap.params.iter().enumerate() {
let point = i / 2;
if point >= curve::POINTS {
break;
}
if i % 2 == 0 {
xs[point] = p.value;
} else {
ys[point] = p.value;
}
}
}
if !found {
return Vec::new();
}
// Sorted the same way the operation sorts before handing points to
// the shader, or a dragged-past point would draw differently from
// how it renders.
sort_with_gap(&mut xs);
(0..SAMPLES)
.map(|i| {
let x = i as f32 / (SAMPLES - 1) as f32;
curve::evaluate(&xs, &ys, x).clamp(0.0, 1.0)
})
.collect()
}
/// Return every point of a curve operation to its default.
pub fn reset_curve(&mut self, op_index: i32) {
let caps = self.graph.capabilities();
let Some(cap) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else {
return;
};
for p in &cap.params {
self.graph.set_param(cap.id, p.id, p.default);
}
}
/// Apply a change from the interface.
///
/// Indices are positions in [`Self::rows`]; the mapping back to ids stays
@@ -205,6 +341,28 @@ impl DevelopSession {
}
}
/// Sort ascending and force a minimum separation.
///
/// Mirrors what the curve operation does before handing points to the
/// shader. Duplicated rather than shared because the operation keeps it
/// private, and the consequence of drift is only a drawn line that lags the
/// rendered one by a pixel — not a wrong image.
fn sort_with_gap(xs: &mut [f32]) {
const MIN_GAP: f32 = 0.001;
for i in 1..xs.len() {
let mut j = i;
while j > 0 && xs[j - 1] > xs[j] {
xs.swap(j - 1, j);
j -= 1;
}
}
for i in 1..xs.len() {
if xs[i] - xs[i - 1] < MIN_GAP {
xs[i] = xs[i - 1] + MIN_GAP;
}
}
}
/// Largest size fitting `(sw, sh)` inside `(max_w, max_h)`, preserving aspect.
///
/// Rendering to the letterboxed size rather than the full viewport avoids
+221
View File
@@ -43,6 +43,71 @@ pub struct LaunchModel {
pub can_remember: bool,
/// Which formats to scan for, in `Format::ALL` order.
pub formats: Vec<(Format, bool)>,
/// Folder picker state, `None` when it is closed.
pub browser: Option<FolderBrowser>,
}
/// The folder picker: where it is, and what is there.
///
/// Descends one level at a time because that is what the backend supports —
/// `Depth: infinity` is frequently disabled server-side and prohibitively
/// expensive where it is not (ARCH §8.4).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct FolderBrowser {
/// Path being listed. Empty is the account root.
pub path: String,
/// Child folder names at `path`, not full paths.
pub entries: Vec<String>,
/// True while a listing is in flight.
pub loading: bool,
}
impl FolderBrowser {
/// Breadcrumb segments, root first.
pub fn breadcrumbs(&self) -> Vec<String> {
let mut out = vec!["/".to_string()];
out.extend(
self.path
.split('/')
.filter(|s| !s.is_empty())
.map(str::to_string),
);
out
}
/// The path after descending into `name`.
pub fn child_path(&self, name: &str) -> String {
if self.path.is_empty() {
name.to_string()
} else {
format!("{}/{}", self.path, name)
}
}
/// The parent path, or `None` at the root.
pub fn parent_path(&self) -> Option<String> {
if self.path.is_empty() {
None
} else {
Some(match self.path.rsplit_once('/') {
Some((head, _)) => head.to_string(),
None => String::new(),
})
}
}
/// Truncate to the breadcrumb at `index` (0 is the root).
pub fn path_at_crumb(&self, index: usize) -> String {
if index == 0 {
return String::new();
}
self.path
.split('/')
.filter(|s| !s.is_empty())
.take(index)
.collect::<Vec<_>>()
.join("/")
}
}
impl Default for LaunchModel {
@@ -54,6 +119,7 @@ impl Default for LaunchModel {
status: None,
can_remember: true,
formats: Format::ALL.iter().map(|f| (*f, f.is_raw())).collect(),
browser: None,
}
}
}
@@ -193,6 +259,63 @@ impl LaunchModel {
};
}
// --- folder picker --------------------------------------------------
/// Open the picker at the account root.
pub fn open_browser(&mut self) {
self.error = None;
self.browser = Some(FolderBrowser {
path: String::new(),
entries: Vec::new(),
loading: true,
});
}
pub fn close_browser(&mut self) {
self.browser = None;
}
/// Begin listing `path`.
pub fn browse_to(&mut self, path: impl Into<String>) {
let path = path.into();
match &mut self.browser {
Some(b) => {
b.path = path;
b.entries.clear();
b.loading = true;
}
None => {
self.browser = Some(FolderBrowser {
path,
entries: Vec::new(),
loading: true,
})
}
}
}
/// Record a completed listing.
pub fn browser_loaded(&mut self, entries: Vec<String>) {
if let Some(b) = &mut self.browser {
b.entries = entries;
b.loading = false;
}
}
/// Adopt the picker's current path as the library root.
///
/// Returns the session to persist, or `None` when signed out.
pub fn choose_current_folder(&mut self) -> Option<Session> {
let path = self.browser.as_ref()?.path.clone();
let mut session = self.session()?.clone();
session.root = path;
self.browser = None;
self.state = LaunchState::SignedIn {
session: session.clone(),
};
Some(session)
}
pub fn busy(&mut self, message: impl Into<String>) {
self.error = None;
let session = self.session().cloned().map(Box::new);
@@ -377,6 +500,104 @@ mod tests {
assert!(!m.is_signed_in());
}
#[test]
fn browsing_descends_and_ascends() {
let mut m = LaunchModel::default();
m.signed_in(session_with_root(""));
m.open_browser();
let b = m.browser.as_ref().unwrap();
assert_eq!(b.path, "", "opens at the account root");
assert!(b.loading);
m.browser_loaded(vec!["Photos".into(), "Archive".into()]);
assert!(!m.browser.as_ref().unwrap().loading);
let child = m.browser.as_ref().unwrap().child_path("Photos");
assert_eq!(child, "Photos");
m.browse_to(child);
m.browser_loaded(vec!["2026".into()]);
let deeper = m.browser.as_ref().unwrap().child_path("2026");
assert_eq!(deeper, "Photos/2026");
m.browse_to(deeper);
assert_eq!(
m.browser.as_ref().unwrap().parent_path(),
Some("Photos".into())
);
}
#[test]
fn the_root_has_no_parent() {
let b = FolderBrowser::default();
assert_eq!(b.parent_path(), None, "no way up from the account root");
}
#[test]
fn ascending_from_a_top_level_folder_reaches_the_root() {
let b = FolderBrowser {
path: "Photos".into(),
..Default::default()
};
assert_eq!(b.parent_path(), Some(String::new()));
}
#[test]
fn confirming_sets_the_library_root() {
let mut m = LaunchModel::default();
m.signed_in(session_with_root(""));
m.open_browser();
m.browse_to("Photos/2026");
let session = m.choose_current_folder().expect("a session");
assert_eq!(session.root, "Photos/2026");
assert_eq!(m.library_root(), "Photos/2026");
assert!(m.browser.is_none(), "picker closes on confirm");
assert!(m.can_open_library());
}
#[test]
fn confirming_at_the_root_selects_the_whole_account() {
// Legitimate: a user may keep everything at the top level.
let mut m = LaunchModel::default();
m.signed_in(session_with_root(""));
m.open_browser();
let session = m.choose_current_folder().expect("a session");
assert_eq!(session.root, "");
}
#[test]
fn cancelling_leaves_the_root_unchanged() {
let mut m = LaunchModel::default();
m.signed_in(session_with_root("Original"));
m.open_browser();
m.browse_to("Somewhere/Else");
m.close_browser();
assert!(m.browser.is_none());
assert_eq!(m.library_root(), "Original", "cancel must not select");
}
#[test]
fn confirming_while_signed_out_does_nothing() {
let mut m = LaunchModel::default();
m.open_browser();
assert!(m.choose_current_folder().is_none());
}
#[test]
fn breadcrumbs_start_at_the_root() {
let b = FolderBrowser {
path: "Photos/2026/Trip".into(),
..Default::default()
};
assert_eq!(b.breadcrumbs(), vec!["/", "Photos", "2026", "Trip"]);
assert_eq!(b.path_at_crumb(0), "");
assert_eq!(b.path_at_crumb(2), "Photos/2026");
}
#[test]
fn account_label_is_empty_when_signed_out() {
assert_eq!(LaunchModel::default().account_label(), "");
+78 -4
View File
@@ -66,6 +66,20 @@ pub fn render(window: &AppWindow, controller: &LaunchController) {
window.set_launch_status(status.unwrap_or_default().into());
window.set_launch_error(m.error.clone().unwrap_or_default().into());
// Folder picker.
let browsing = m.browser.is_some();
window.set_launch_browsing(browsing);
if let Some(b) = &m.browser {
window.set_launch_browse_path(b.path.clone().into());
window.set_launch_browse_loading(b.loading);
let entries: Vec<slint::SharedString> = b
.entries
.iter()
.map(|e| slint::SharedString::from(e.as_str()))
.collect();
window.set_launch_browse_entries(slint::ModelRc::new(slint::VecModel::from(entries)));
}
let labels: Vec<slint::SharedString> = m
.formats
.iter()
@@ -148,9 +162,9 @@ where
let ctl = controller.clone();
window.on_launch_choose_folder(move || {
let Some(w) = weak.upgrade() else { return };
ctl.model.borrow_mut().busy("Listing folders…");
ctl.model.borrow_mut().open_browser();
render(&w, &ctl);
spawn_folder_list(w.as_weak(), ctl.clone());
spawn_folder_list(w.as_weak(), ctl.clone(), String::new());
});
}
@@ -168,6 +182,66 @@ where
});
}
// --- folder picker navigation ---------------------------------------
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_launch_browse_into(move |name| {
let Some(w) = weak.upgrade() else { return };
let target = {
let m = ctl.model.borrow();
m.browser.as_ref().map(|b| b.child_path(&name))
};
if let Some(path) = target {
ctl.model.borrow_mut().browse_to(path.clone());
render(&w, &ctl);
spawn_folder_list(w.as_weak(), ctl.clone(), path);
}
});
}
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_launch_browse_up(move || {
let Some(w) = weak.upgrade() else { return };
let parent = {
let m = ctl.model.borrow();
m.browser.as_ref().and_then(|b| b.parent_path())
};
if let Some(path) = parent {
ctl.model.borrow_mut().browse_to(path.clone());
render(&w, &ctl);
spawn_folder_list(w.as_weak(), ctl.clone(), path);
}
});
}
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_launch_browse_confirm(move || {
let Some(w) = weak.upgrade() else { return };
let chosen = ctl.model.borrow_mut().choose_current_folder();
if let Some(session) = chosen {
// Persist immediately: a chosen root must survive a crash
// before the library is opened.
if let Err(e) = ctl.store.update(&session) {
log::warn!("saving library root: {e}");
}
log::info!("library root set to /{}", session.root);
}
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_launch_browse_cancel(move || {
let Some(w) = weak.upgrade() else { return };
ctl.model.borrow_mut().close_browser();
render(&w, &ctl);
});
}
// --- copy the login URL ---------------------------------------------
{
let ctl = controller.clone();
@@ -301,7 +375,7 @@ fn poll_channel(
}
/// List top-level folders so one can be chosen as the library root.
fn spawn_folder_list(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>) {
fn spawn_folder_list(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, path: String) {
let Some(session) = ctl.model.borrow().session().cloned() else {
return;
};
@@ -329,7 +403,7 @@ fn spawn_folder_list(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>) {
};
rt.block_on(async {
match NextcloudBackend::new(&creds, &user_id) {
Ok(b) => match b.list(&RemotePath::root(), None).await {
Ok(b) => match b.list(&RemotePath::new(&path), None).await {
Ok(entries) => {
let mut dirs: Vec<String> = entries
.iter()
+37 -8
View File
@@ -173,12 +173,16 @@ fn is_supported(p: &Path) -> bool {
/// progress. The symptom is a slider that jumps on click but cannot be
/// dragged, because each move event destroys the thing that would deliver
/// the next one.
fn sync_rows(rows: &Rc<slint::VecModel<ParamRow>>, session: &Rc<RefCell<Option<DevelopSession>>>) {
fn sync_rows(
window: &AppWindow,
rows: &Rc<slint::VecModel<ParamRow>>,
session: &Rc<RefCell<Option<DevelopSession>>>,
) {
use slint::Model as _;
let current = match session.borrow().as_ref() {
Some(s) => s.rows(),
None => Vec::new(),
let (current, samples) = match session.borrow().as_ref() {
Some(s) => (s.rows(), s.curve_samples()),
None => (Vec::new(), Vec::new()),
};
if current.len() == rows.row_count() {
@@ -194,6 +198,10 @@ fn sync_rows(rows: &Rc<slint::VecModel<ParamRow>>, session: &Rc<RefCell<Option<D
// is correct here — there is no drag to preserve.
rows.set_vec(current);
}
// The drawn curve follows the points. Replacing this model wholesale is
// safe where replacing `rows` was not: nothing in it is a drag target.
window.set_curve_samples(slint::ModelRc::new(slint::VecModel::from(samples)));
}
/// TRACES: M-13 | M-14
@@ -304,9 +312,13 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// this code names no operation (FR-DEV-3a).
match l.session {
Some(s) => {
rows.set_vec(s.rows());
window.set_adjust_enabled(true);
*session.borrow_mut() = Some(s);
// Through `sync_rows` rather than setting rows
// directly, so the curve's drawn shape is
// refreshed by the same path that refreshes the
// controls — one place to keep them in step.
sync_rows(window, &rows, &session);
redraw(window);
}
None => {
@@ -351,7 +363,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
if let Some(s) = session.borrow_mut().as_mut() {
s.set_param(op, param, value);
}
sync_rows(&rows, &session);
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
@@ -365,7 +377,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
if let Some(s) = session.borrow_mut().as_mut() {
s.reset_param(op, param);
}
sync_rows(&rows, &session);
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
@@ -379,7 +391,24 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
if let Some(s) = session.borrow_mut().as_mut() {
s.reset_all();
}
sync_rows(&rows, &session);
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
{
// A curve is one control spanning many parameters, so resetting it
// clears all of them at once — resetting a single point would leave
// a shape the user did not ask for.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_curve_reset(move |op| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.reset_curve(op);
}
sync_rows(&w, &rows, &session);
redraw(&w);
});
}