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
+11 -1
View File
@@ -12,7 +12,7 @@
//! it. This is a diagnostic, not the export path (FR-EXP-*). //! it. This is a diagnostic, not the export path (FR-EXP-*).
use dr_gpu::{AdjustPass, Demosaicer, GpuContext}; use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
use dr_pipeline::ops::{colour, colour_mixer, contrast, exposure, tone, white_balance}; use dr_pipeline::ops::{colour, colour_mixer, contrast, curve, exposure, tone, white_balance};
use dr_pipeline::{EditGraph, ParamId}; use dr_pipeline::{EditGraph, ParamId};
fn main() { fn main() {
@@ -86,6 +86,16 @@ fn main() {
"mixer_one" => { "mixer_one" => {
graph.set_param(colour_mixer::ID, ParamId("green_sat"), 100.0); graph.set_param(colour_mixer::ID, ParamId("green_sat"), 100.0);
} }
// A classic S-curve: shadows down, highlights up, mid held.
"curve_s" => {
graph.set_param(curve::ID, curve::P1_Y, 0.15);
graph.set_param(curve::ID, curve::P3_Y, 0.85);
}
// The inverse, a film-like lifted-shadow look.
"curve_lift" => {
graph.set_param(curve::ID, curve::P0_Y, 0.12);
graph.set_param(curve::ID, curve::P1_Y, 0.32);
}
_ => {} _ => {}
} }
+14 -3
View File
@@ -736,9 +736,20 @@ mod tests {
let mut g = EditGraph::default_chain(); let mut g = EditGraph::default_chain();
for cap in EditGraph::default_chain().capabilities() { for cap in EditGraph::default_chain().capabilities() {
for p in &cap.params { for (i, p) in cap.params.iter().enumerate() {
if let dr_pipeline::ParamKind::Scalar { max, .. } = p.kind { if let dr_pipeline::ParamKind::Scalar { min, max, .. } = p.kind {
g.set_param(cap.id, p.id, max * 0.6); // Stepped away from each parameter's own default by a
// varying fraction. A single shared value would leave the
// tone curve inactive: its neutral is the *relationship*
// between its points, so setting them all alike keeps it
// on the identity diagonal.
let step = (max - min) * (0.15 + 0.05 * (i % 4) as f32);
let v = if p.default + step <= max {
p.default + step
} else {
p.default - step
};
g.set_param(cap.id, p.id, v);
} }
} }
} }
+50 -45
View File
@@ -102,62 +102,39 @@ static CURVE_HELPERS: &[Helper] = &[
helpers::LUMINANCE, helpers::LUMINANCE,
helpers::APPLY_TONE_GAIN, helpers::APPLY_TONE_GAIN,
Helper { Helper {
name: "curve_eval", name: "curve_span",
source: "\ source: "\
// Evaluate a monotone cubic Hermite spline through five points. // One span of a monotone cubic Hermite spline.
// //
// Fritsch-Carlson (1980): compute secant slopes, take a smooth average for // Takes the span's endpoints and the secants either side of it, rather than
// the interior tangents, then *limit* each tangent to three times the // an array and an index. **No dynamic indexing anywhere in this file**:
// adjoining secant. That limiter is what prevents overshoot — an // indexing a `array<f32, 5>` by a runtime value made RADV (Mesa 26.1) crash
// unconstrained spline can dip below a point's neighbour, inverting tones // the process with SIGSEGV during pipeline creation, not merely fail to
// and putting a dark halo through a smooth gradient. // compile. Five points means four spans, so unrolling costs a short branch
// // chain and removes the hazard entirely.
// Points arrive pre-sorted by x with a minimum separation enforced on the fn curve_span(
// CPU, so no division here can be by zero. x0: f32, y0: f32, x1: f32, y1: f32,
fn curve_eval(xs: array<f32, 5>, ys: array<f32, 5>, x: f32) -> f32 { s_prev: f32, s_next: f32, x: f32,
// Outside the point range the curve is flat, matching how the endpoints ) -> f32 {
// read in the widget: nothing exists beyond them to interpolate toward.
if (x <= xs[0]) { return ys[0]; }
if (x >= xs[4]) { return ys[4]; }
// Locate the span. Five points is few enough that a chain of comparisons
// beats any cleverer search.
var i = 0;
if (x >= xs[3]) { i = 3; }
else if (x >= xs[2]) { i = 2; }
else if (x >= xs[1]) { i = 1; }
let x0 = xs[i];
let x1 = xs[i + 1];
let y0 = ys[i];
let y1 = ys[i + 1];
let h = x1 - x0; let h = x1 - x0;
let secant = (y1 - y0) / h; let secant = (y1 - y0) / h;
// Secants either side of each knot, duplicated at the ends so the // Tangents: the average of the adjoining secants, but zero wherever the
// boundary tangents match the adjoining secant. // data turns, which is what pins a local extremum in place.
var s_prev = secant;
if (i > 0) {
s_prev = (ys[i] - ys[i - 1]) / (xs[i] - xs[i - 1]);
}
var s_next = secant;
if (i + 2 <= 4) {
s_next = (ys[i + 2] - ys[i + 1]) / (xs[i + 2] - xs[i + 1]);
}
// Tangents: the average of adjoining secants, but zero wherever the data
// turns, which is what pins a local extremum in place.
var m0 = 0.5 * (s_prev + secant); var m0 = 0.5 * (s_prev + secant);
var m1 = 0.5 * (secant + s_next); var m1 = 0.5 * (secant + s_next);
if (s_prev * secant <= 0.0) { m0 = 0.0; } if (s_prev * secant <= 0.0) { m0 = 0.0; }
if (secant * s_next <= 0.0) { m1 = 0.0; } if (secant * s_next <= 0.0) { m1 = 0.0; }
// A flat span must stay flat.
if (abs(secant) < 0.000001) { if (abs(secant) < 0.000001) {
// A flat span must stay flat.
m0 = 0.0; m0 = 0.0;
m1 = 0.0; m1 = 0.0;
} else { } else {
// The Fritsch-Carlson limiter. // The Fritsch-Carlson (1980) limiter: cap each tangent at three
// times the secant. This is what prevents overshoot — an
// unconstrained spline can dip below a point's neighbour, inverting
// tones and putting a dark halo through a smooth gradient.
let a = m0 / secant; let a = m0 / secant;
let b = m1 / secant; let b = m1 / secant;
let magnitude = a * a + b * b; let magnitude = a * a + b * b;
@@ -178,6 +155,36 @@ fn curve_eval(xs: array<f32, 5>, ys: array<f32, 5>, x: f32) -> f32 {
let h11 = t3 - t2; let h11 = t3 - t2;
return h00 * y0 + h10 * h * m0 + h01 * y1 + h11 * h * m1; return h00 * y0 + h10 * h * m0 + h01 * y1 + h11 * h * m1;
}",
},
Helper {
name: "curve_eval",
source: "\
// Evaluate the five-point tone curve at `x`.
//
// Spans are unrolled and secants passed explicitly; see `curve_span` for why
// there is no array indexing here. Points arrive pre-sorted with a minimum
// separation enforced on the CPU, so no division can be by zero.
fn curve_eval(
x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32,
x3: f32, y3: f32, x4: f32, y4: f32, x: f32,
) -> f32 {
// Outside the point range the curve is flat, matching how the endpoints
// read in the widget: nothing exists beyond them to interpolate toward.
if (x <= x0) { return y0; }
if (x >= x4) { return y4; }
let s0 = (y1 - y0) / (x1 - x0);
let s1 = (y2 - y1) / (x2 - x1);
let s2 = (y3 - y2) / (x3 - x2);
let s3 = (y4 - y3) / (x4 - x3);
// The outermost secants are duplicated, so the boundary tangents match
// the span they adjoin.
if (x < x1) { return curve_span(x0, y0, x1, y1, s0, s1, x); }
if (x < x2) { return curve_span(x1, y1, x2, y2, s0, s2, x); }
if (x < x3) { return curve_span(x2, y2, x3, y3, s1, s3, x); }
return curve_span(x3, y3, x4, y4, s2, s3, x);
}", }",
}, },
]; ];
@@ -302,9 +309,7 @@ if (luma > 0.0001) {
// would not correspond to the middle of the visible range. // would not correspond to the middle of the visible range.
let encoded = pow(clamp(luma, 0.0, 1.0), 1.0 / 2.2); let encoded = pow(clamp(luma, 0.0, 1.0), 1.0 / 2.2);
let xs = array<f32, 5>(x0, x1, x2, x3, x4); let curved = curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
let ys = array<f32, 5>(y0, y1, y2, y3, y4);
let curved = curve_eval(xs, ys, encoded);
let decoded = pow(clamp(curved, 0.0, 1.0), 2.2); let decoded = pow(clamp(curved, 0.0, 1.0), 2.2);
// Applied as a ratio so hue is preserved, exactly as contrast does. // Applied as a ratio so hue is preserved, exactly as contrast does.
+159 -1
View File
@@ -11,7 +11,10 @@
use dr_decode::RawImage; use dr_decode::RawImage;
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext}; 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::labels;
use crate::ParamRow; use crate::ParamRow;
@@ -43,6 +46,23 @@ impl DevelopSession {
pub fn rows(&self) -> Vec<ParamRow> { pub fn rows(&self) -> Vec<ParamRow> {
let mut rows = Vec::new(); let mut rows = Vec::new();
for (op_index, op) in self.graph.capabilities().iter().enumerate() { 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() { for (param_index, p) in op.params.iter().enumerate() {
let (kind, min, max, precision, unit) = match &p.kind { let (kind, min, max, precision, unit) = match &p.kind {
ParamKind::Scalar { ParamKind::Scalar {
@@ -76,12 +96,128 @@ impl DevelopSession {
maximum: max, maximum: max,
precision, precision,
unit: unit.into(), unit: unit.into(),
// Only curve rows carry points.
points: slint::ModelRc::new(slint::VecModel::from(Vec::<f32>::new())),
}); });
} }
} }
rows 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. /// Apply a change from the interface.
/// ///
/// Indices are positions in [`Self::rows`]; the mapping back to ids stays /// 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. /// Largest size fitting `(sw, sh)` inside `(max_w, max_h)`, preserving aspect.
/// ///
/// Rendering to the letterboxed size rather than the full viewport avoids /// 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, pub can_remember: bool,
/// Which formats to scan for, in `Format::ALL` order. /// Which formats to scan for, in `Format::ALL` order.
pub formats: Vec<(Format, bool)>, 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 { impl Default for LaunchModel {
@@ -54,6 +119,7 @@ impl Default for LaunchModel {
status: None, status: None,
can_remember: true, can_remember: true,
formats: Format::ALL.iter().map(|f| (*f, f.is_raw())).collect(), 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>) { pub fn busy(&mut self, message: impl Into<String>) {
self.error = None; self.error = None;
let session = self.session().cloned().map(Box::new); let session = self.session().cloned().map(Box::new);
@@ -377,6 +500,104 @@ mod tests {
assert!(!m.is_signed_in()); 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] #[test]
fn account_label_is_empty_when_signed_out() { fn account_label_is_empty_when_signed_out() {
assert_eq!(LaunchModel::default().account_label(), ""); 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_status(status.unwrap_or_default().into());
window.set_launch_error(m.error.clone().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 let labels: Vec<slint::SharedString> = m
.formats .formats
.iter() .iter()
@@ -148,9 +162,9 @@ where
let ctl = controller.clone(); let ctl = controller.clone();
window.on_launch_choose_folder(move || { window.on_launch_choose_folder(move || {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
ctl.model.borrow_mut().busy("Listing folders…"); ctl.model.borrow_mut().open_browser();
render(&w, &ctl); 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 --------------------------------------------- // --- copy the login URL ---------------------------------------------
{ {
let ctl = controller.clone(); let ctl = controller.clone();
@@ -301,7 +375,7 @@ fn poll_channel(
} }
/// List top-level folders so one can be chosen as the library root. /// 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 { let Some(session) = ctl.model.borrow().session().cloned() else {
return; return;
}; };
@@ -329,7 +403,7 @@ fn spawn_folder_list(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>) {
}; };
rt.block_on(async { rt.block_on(async {
match NextcloudBackend::new(&creds, &user_id) { 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) => { Ok(entries) => {
let mut dirs: Vec<String> = entries let mut dirs: Vec<String> = entries
.iter() .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 /// progress. The symptom is a slider that jumps on click but cannot be
/// dragged, because each move event destroys the thing that would deliver /// dragged, because each move event destroys the thing that would deliver
/// the next one. /// 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 _; use slint::Model as _;
let current = match session.borrow().as_ref() { let (current, samples) = match session.borrow().as_ref() {
Some(s) => s.rows(), Some(s) => (s.rows(), s.curve_samples()),
None => Vec::new(), None => (Vec::new(), Vec::new()),
}; };
if current.len() == rows.row_count() { 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. // is correct here — there is no drag to preserve.
rows.set_vec(current); 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 /// TRACES: M-13 | M-14
@@ -304,9 +312,13 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// this code names no operation (FR-DEV-3a). // this code names no operation (FR-DEV-3a).
match l.session { match l.session {
Some(s) => { Some(s) => {
rows.set_vec(s.rows());
window.set_adjust_enabled(true); window.set_adjust_enabled(true);
*session.borrow_mut() = Some(s); *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); redraw(window);
} }
None => { None => {
@@ -351,7 +363,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
if let Some(s) = session.borrow_mut().as_mut() { if let Some(s) = session.borrow_mut().as_mut() {
s.set_param(op, param, value); s.set_param(op, param, value);
} }
sync_rows(&rows, &session); sync_rows(&w, &rows, &session);
redraw(&w); redraw(&w);
}); });
} }
@@ -365,7 +377,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
if let Some(s) = session.borrow_mut().as_mut() { if let Some(s) = session.borrow_mut().as_mut() {
s.reset_param(op, param); s.reset_param(op, param);
} }
sync_rows(&rows, &session); sync_rows(&w, &rows, &session);
redraw(&w); redraw(&w);
}); });
} }
@@ -379,7 +391,24 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
if let Some(s) = session.borrow_mut().as_mut() { if let Some(s) = session.borrow_mut().as_mut() {
s.reset_all(); 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); redraw(&w);
}); });
} }
+157 -2
View File
@@ -28,8 +28,9 @@ export struct ParamRow {
// section heading without knowing what the sections are. // section heading without knowing what the sections are.
starts-group: bool, starts-group: bool,
// Which control to build. Mirrors ParamKind. // Which control to build. Mirrors ParamKind, plus the widget kinds an
kind: string, // "scalar" | "bool" // operation can request through its presentation.
kind: string, // "scalar" | "bool" | "curve"
value: float, value: float,
default-value: float, default-value: float,
@@ -37,6 +38,16 @@ export struct ParamRow {
maximum: float, maximum: float,
precision: int, precision: int,
unit: string, unit: string,
// Curve rows only: the point coordinates, x and y interleaved.
//
// Carried on the row rather than fetched separately because a Slint
// model row is the unit of update — splitting them would let the curve
// and its points refresh out of step. Empty for every other kind.
//
// `param-index` on a curve row is the index of the *first* point
// parameter, so a drag routes back by offsetting from it.
points: [float],
} }
// A slider with a label, value readout, and double-click reset. // A slider with a label, value readout, and double-click reset.
@@ -168,12 +179,138 @@ component ParamSlider inherits Rectangle {
} }
} }
// A tone curve editor: a square grid with draggable control points.
//
// The curve *line* is drawn from `samples`, which Rust evaluates with the
// same spline the shader uses. Reimplementing the interpolation here would
// mean two curves that could disagree — the drawn one and the applied one —
// which is the worst possible failure for a control whose whole job is to
// show you what it is doing.
component CurveEditor inherits Rectangle {
in property <ParamRow> data;
// Polyline of the curve, y values sampled at even x. 0..1, y up.
in property <[float]> samples;
// Which point is being dragged, or -1.
in-out property <int> active-point: -1;
callback point-moved(int, float, float);
callback reset();
property <int> point-count: root.data.points.length / 2;
// Square: a tone curve is read as a deviation from the 45° diagonal, and
// that reading only works if the axes share a scale.
height: self.width;
plot := Rectangle {
background: Theme.ground;
border-width: 1px;
border-color: Theme.rule;
// Quarter gridlines and the identity diagonal, so the shape of the
// edit is legible at a glance.
for i in [1, 2, 3]: Rectangle {
x: parent.width * i / 4;
width: 1px;
background: Theme.rule;
opacity: 0.5;
}
for i in [1, 2, 3]: Rectangle {
y: parent.height * i / 4;
height: 1px;
background: Theme.rule;
opacity: 0.5;
}
// The curve. One thin rectangle per sample: Slint has no polyline
// primitive, and at this size the segments are sub-pixel anyway.
for s[i] in root.samples: Rectangle {
property <float> next: i + 1 < root.samples.length
? root.samples[i + 1] : s;
x: parent.width * i / max(root.samples.length - 1, 1);
width: parent.width / max(root.samples.length - 1, 1) + 1px;
// Span the segment vertically, so a steep section stays joined.
y: parent.height * (1.0 - max(s, self.next));
height: max(parent.height * abs(self.next - s), 1.5px);
background: Theme.accent;
}
// Control points.
for idx in [0, 1, 2, 3, 4]: Rectangle {
property <bool> exists: idx < root.point-count;
property <float> px: root.data.points[idx * 2];
property <float> py: root.data.points[idx * 2 + 1];
visible: self.exists;
x: parent.width * self.px - 5px;
y: parent.height * (1.0 - self.py) - 5px;
width: 10px;
height: 10px;
border-radius: 5px;
background: root.active-point == idx ? Theme.accent : Theme.ink;
border-width: 1px;
border-color: Theme.ground;
}
// Catches the release even when the pointer has left the grab
// target, and resets on double-click.
area := TouchArea {
width: 100%;
height: 100%;
moved => {
if (root.active-point >= 0) {
root.point-moved(
root.active-point,
clamp(self.mouse-x / parent.width, 0.0, 1.0),
clamp(1.0 - self.mouse-y / parent.height, 0.0, 1.0));
}
}
pointer-event(ev) => {
if (ev.kind == PointerEventKind.up) {
root.active-point = -1;
}
}
double-clicked => { root.reset(); }
}
// One grab target per point, above the shared area so a press picks
// the point under the pointer rather than the panel guessing.
for idx in [0, 1, 2, 3, 4]: TouchArea {
property <bool> exists: idx < root.point-count;
x: parent.width * root.data.points[idx * 2] - 11px;
y: parent.height * (1.0 - root.data.points[idx * 2 + 1]) - 11px;
width: 22px;
height: 22px;
enabled: self.exists;
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down) {
root.active-point = idx;
}
}
moved => {
if (root.active-point == idx) {
root.point-moved(
idx,
clamp((self.x + self.mouse-x) / parent.width, 0.0, 1.0),
clamp(1.0 - (self.y + self.mouse-y) / parent.height, 0.0, 1.0));
}
}
}
}
}
// The panel: a heading per operation, a control per parameter. // The panel: a heading per operation, a control per parameter.
export component AdjustPanel inherits Rectangle { export component AdjustPanel inherits Rectangle {
in property <[ParamRow]> rows; in property <[ParamRow]> rows;
in property <bool> enabled: true; in property <bool> enabled: true;
/// The tone curve's sampled shape, evaluated in Rust by the same spline
/// the shader runs so the drawn line cannot disagree with the applied one.
in property <[float]> curve-samples;
callback param-changed(int, int, float); callback param-changed(int, int, float);
callback param-reset(int, int); callback param-reset(int, int);
callback curve-reset(int);
callback reset-all(); callback reset-all();
background: Theme.surface; background: Theme.surface;
@@ -246,6 +383,24 @@ export component AdjustPanel inherits Rectangle {
root.param-reset(row.op-index, row.param-index); root.param-reset(row.op-index, row.param-index);
} }
} }
if row.kind == "curve": CurveEditor {
data: row;
samples: root.curve-samples;
// A point carries two parameters, so the parameter
// index is the row's base plus the point's offset.
// This component still knows nothing about which
// operation it belongs to.
point-moved(point, x, y) => {
root.param-changed(
row.op-index, row.param-index + point * 2, x);
root.param-changed(
row.op-index, row.param-index + point * 2 + 1, y);
}
reset => {
root.curve-reset(row.op-index);
}
}
} }
} }
} }
+26
View File
@@ -162,6 +162,16 @@ export component AppWindow inherits Window {
callback launch-format-toggled(int, bool); callback launch-format-toggled(int, bool);
callback launch-copy-url(); callback launch-copy-url();
in property <bool> launch-browsing: false;
in property <string> launch-browse-path: "";
in property <bool> launch-browse-loading: false;
in-out property <[string]> launch-browse-entries;
callback launch-browse-into(string);
callback launch-browse-up();
callback launch-browse-confirm();
callback launch-browse-cancel();
callback next-image(); callback next-image();
callback prev-image(); callback prev-image();
@@ -170,8 +180,12 @@ export component AppWindow inherits Window {
// exist (FR-DEV-3a). // exist (FR-DEV-3a).
in property <[ParamRow]> adjust-rows; in property <[ParamRow]> adjust-rows;
in property <bool> adjust-enabled: false; in property <bool> adjust-enabled: false;
// The tone curve's sampled shape, evaluated by the core so the drawn
// line and the applied one cannot disagree.
in property <[float]> curve-samples;
callback param-changed(int, int, float); callback param-changed(int, int, float);
callback param-reset(int, int); callback param-reset(int, int);
callback curve-reset(int);
callback reset-all(); callback reset-all();
// FR-UI-1: layout class follows window width, not device type. A narrow // FR-UI-1: layout class follows window width, not device type. A narrow
@@ -212,6 +226,16 @@ export component AppWindow inherits Window {
open-library() => { root.launch-open-library(); } open-library() => { root.launch-open-library(); }
format-toggled(i, on) => { root.launch-format-toggled(i, on); } format-toggled(i, on) => { root.launch-format-toggled(i, on); }
copy-login-url() => { root.launch-copy-url(); } copy-login-url() => { root.launch-copy-url(); }
browsing: root.launch-browsing;
browse-path: root.launch-browse-path;
browse-loading: root.launch-browse-loading;
browse-entries: root.launch-browse-entries;
browse-into(name) => { root.launch-browse-into(name); }
browse-up() => { root.launch-browse-up(); }
browse-confirm() => { root.launch-browse-confirm(); }
browse-cancel() => { root.launch-browse-cancel(); }
} }
if !root.show-launch: VerticalLayout { if !root.show-launch: VerticalLayout {
@@ -311,12 +335,14 @@ export component AppWindow inherits Window {
vertical-stretch: 1; vertical-stretch: 1;
rows: root.adjust-rows; rows: root.adjust-rows;
enabled: root.adjust-enabled; enabled: root.adjust-enabled;
curve-samples: root.curve-samples;
param-changed(op, param, value) => { param-changed(op, param, value) => {
root.param-changed(op, param, value); root.param-changed(op, param, value);
} }
param-reset(op, param) => { param-reset(op, param) => {
root.param-reset(op, param); root.param-reset(op, param);
} }
curve-reset(op) => { root.curve-reset(op); }
reset-all => { root.reset-all(); } reset-all => { root.reset-all(); }
} }
} }
+135 -1
View File
@@ -91,6 +91,40 @@ component Button inherits Rectangle {
} }
} }
// One folder in the picker. The whole row is the target, not just the text.
component FolderRow inherits Rectangle {
in property <string> label;
in property <bool> is-parent: false;
callback clicked();
height: Theme.touch-target;
background: touch.has-hover ? Theme.surface-raised : transparent;
border-radius: 3px;
touch := TouchArea { clicked => { root.clicked(); } }
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: Theme.gap;
Text {
text: root.is-parent ? "↑" : "▸";
color: Theme.ink-faint;
font-size: Theme.text;
vertical-alignment: center;
width: 14px;
}
Text {
text: root.label;
color: Theme.ink;
font-size: Theme.text;
vertical-alignment: center;
overflow: elide;
}
}
}
export component LaunchScreen inherits Rectangle { export component LaunchScreen inherits Rectangle {
// --- state in --- // --- state in ---
in property <bool> signed-in: false; in property <bool> signed-in: false;
@@ -117,6 +151,17 @@ export component LaunchScreen inherits Rectangle {
callback format-toggled(int, bool); callback format-toggled(int, bool);
callback copy-login-url(); callback copy-login-url();
// --- folder picker ---
in property <bool> browsing: false;
in property <string> browse-path: "";
in property <bool> browse-loading: false;
in-out property <[string]> browse-entries;
callback browse-into(string);
callback browse-up();
callback browse-confirm();
callback browse-cancel();
background: Theme.ground; background: Theme.ground;
VerticalLayout { VerticalLayout {
@@ -257,8 +302,97 @@ export component LaunchScreen inherits Rectangle {
} }
} }
// --- folder picker ---
if root.signed-in && root.browsing: VerticalLayout {
spacing: Theme.gap;
Text {
text: "CHOOSE LIBRARY FOLDER";
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
// Current location, so it is always clear what
// "Use this folder" would select.
Text {
text: root.browse-path == "" ? "/" : "/" + root.browse-path;
color: Theme.ink;
font-size: Theme.text;
overflow: elide;
}
Rectangle {
height: 220px;
background: Theme.surface;
border-radius: 4px;
border-width: 1px;
border-color: Theme.rule;
if root.browse-loading: Text {
text: "Loading…";
color: Theme.ink-faint;
font-size: Theme.text-sm;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
}
if !root.browse-loading: Flickable {
width: 100%;
height: 100%;
viewport-height: content.preferred-height;
content := VerticalLayout {
padding: 4px;
spacing: 2px;
alignment: start;
if root.browse-path != "": FolderRow {
label: "..";
is-parent: true;
clicked => { root.browse-up(); }
}
for name in root.browse-entries: FolderRow {
label: name;
clicked => { root.browse-into(name); }
}
}
}
if !root.browse-loading && root.browse-entries.length == 0
&& root.browse-path != "": Text {
text: "No subfolders here";
color: Theme.ink-faint;
font-size: Theme.text-sm;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
}
}
HorizontalLayout {
spacing: Theme.gap;
Button {
label: "Cancel";
horizontal-stretch: 1;
clicked => { root.browse-cancel(); }
}
Button {
label: "Use this folder";
primary: true;
horizontal-stretch: 1;
clicked => { root.browse-confirm(); }
}
}
}
// --- signed in --- // --- signed in ---
if root.signed-in: VerticalLayout { if root.signed-in && !root.browsing: VerticalLayout {
spacing: Theme.gap; spacing: Theme.gap;
Text { Text {