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:
@@ -12,7 +12,7 @@
|
||||
//! it. This is a diagnostic, not the export path (FR-EXP-*).
|
||||
|
||||
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};
|
||||
|
||||
fn main() {
|
||||
@@ -86,6 +86,16 @@ fn main() {
|
||||
"mixer_one" => {
|
||||
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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
||||
@@ -736,9 +736,20 @@ mod tests {
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
for cap in EditGraph::default_chain().capabilities() {
|
||||
for p in &cap.params {
|
||||
if let dr_pipeline::ParamKind::Scalar { max, .. } = p.kind {
|
||||
g.set_param(cap.id, p.id, max * 0.6);
|
||||
for (i, p) in cap.params.iter().enumerate() {
|
||||
if let dr_pipeline::ParamKind::Scalar { min, max, .. } = p.kind {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,62 +102,39 @@ static CURVE_HELPERS: &[Helper] = &[
|
||||
helpers::LUMINANCE,
|
||||
helpers::APPLY_TONE_GAIN,
|
||||
Helper {
|
||||
name: "curve_eval",
|
||||
name: "curve_span",
|
||||
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
|
||||
// the interior tangents, then *limit* each tangent to three times the
|
||||
// adjoining secant. That limiter 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.
|
||||
//
|
||||
// Points arrive pre-sorted by x with a minimum separation enforced on the
|
||||
// CPU, so no division here can be by zero.
|
||||
fn curve_eval(xs: array<f32, 5>, ys: array<f32, 5>, 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 <= 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];
|
||||
// Takes the span's endpoints and the secants either side of it, rather than
|
||||
// an array and an index. **No dynamic indexing anywhere in this file**:
|
||||
// indexing a `array<f32, 5>` by a runtime value made RADV (Mesa 26.1) crash
|
||||
// the process with SIGSEGV during pipeline creation, not merely fail to
|
||||
// compile. Five points means four spans, so unrolling costs a short branch
|
||||
// chain and removes the hazard entirely.
|
||||
fn curve_span(
|
||||
x0: f32, y0: f32, x1: f32, y1: f32,
|
||||
s_prev: f32, s_next: f32, x: f32,
|
||||
) -> f32 {
|
||||
let h = x1 - x0;
|
||||
let secant = (y1 - y0) / h;
|
||||
|
||||
// Secants either side of each knot, duplicated at the ends so the
|
||||
// boundary tangents match the adjoining secant.
|
||||
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.
|
||||
// Tangents: the average of the 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 m1 = 0.5 * (secant + s_next);
|
||||
if (s_prev * secant <= 0.0) { m0 = 0.0; }
|
||||
if (secant * s_next <= 0.0) { m1 = 0.0; }
|
||||
|
||||
// A flat span must stay flat.
|
||||
if (abs(secant) < 0.000001) {
|
||||
// A flat span must stay flat.
|
||||
m0 = 0.0;
|
||||
m1 = 0.0;
|
||||
} 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 b = m1 / secant;
|
||||
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;
|
||||
|
||||
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.
|
||||
let encoded = pow(clamp(luma, 0.0, 1.0), 1.0 / 2.2);
|
||||
|
||||
let xs = array<f32, 5>(x0, x1, x2, x3, x4);
|
||||
let ys = array<f32, 5>(y0, y1, y2, y3, y4);
|
||||
let curved = curve_eval(xs, ys, encoded);
|
||||
let curved = curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
|
||||
|
||||
let decoded = pow(clamp(curved, 0.0, 1.0), 2.2);
|
||||
// Applied as a ratio so hue is preserved, exactly as contrast does.
|
||||
|
||||
+159
-1
@@ -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
|
||||
|
||||
@@ -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(), "");
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
+157
-2
@@ -28,8 +28,9 @@ export struct ParamRow {
|
||||
// section heading without knowing what the sections are.
|
||||
starts-group: bool,
|
||||
|
||||
// Which control to build. Mirrors ParamKind.
|
||||
kind: string, // "scalar" | "bool"
|
||||
// Which control to build. Mirrors ParamKind, plus the widget kinds an
|
||||
// operation can request through its presentation.
|
||||
kind: string, // "scalar" | "bool" | "curve"
|
||||
|
||||
value: float,
|
||||
default-value: float,
|
||||
@@ -37,6 +38,16 @@ export struct ParamRow {
|
||||
maximum: float,
|
||||
precision: int,
|
||||
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.
|
||||
@@ -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.
|
||||
export component AdjustPanel inherits Rectangle {
|
||||
in property <[ParamRow]> rows;
|
||||
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-reset(int, int);
|
||||
callback curve-reset(int);
|
||||
callback reset-all();
|
||||
|
||||
background: Theme.surface;
|
||||
@@ -246,6 +383,24 @@ export component AdjustPanel inherits Rectangle {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +162,16 @@ export component AppWindow inherits Window {
|
||||
callback launch-format-toggled(int, bool);
|
||||
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 prev-image();
|
||||
|
||||
@@ -170,8 +180,12 @@ export component AppWindow inherits Window {
|
||||
// exist (FR-DEV-3a).
|
||||
in property <[ParamRow]> adjust-rows;
|
||||
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-reset(int, int);
|
||||
callback curve-reset(int);
|
||||
callback reset-all();
|
||||
|
||||
// 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(); }
|
||||
format-toggled(i, on) => { root.launch-format-toggled(i, on); }
|
||||
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 {
|
||||
@@ -311,12 +335,14 @@ export component AppWindow inherits Window {
|
||||
vertical-stretch: 1;
|
||||
rows: root.adjust-rows;
|
||||
enabled: root.adjust-enabled;
|
||||
curve-samples: root.curve-samples;
|
||||
param-changed(op, param, value) => {
|
||||
root.param-changed(op, param, value);
|
||||
}
|
||||
param-reset(op, param) => {
|
||||
root.param-reset(op, param);
|
||||
}
|
||||
curve-reset(op) => { root.curve-reset(op); }
|
||||
reset-all => { root.reset-all(); }
|
||||
}
|
||||
}
|
||||
|
||||
+135
-1
@@ -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 {
|
||||
// --- state in ---
|
||||
in property <bool> signed-in: false;
|
||||
@@ -117,6 +151,17 @@ export component LaunchScreen inherits Rectangle {
|
||||
callback format-toggled(int, bool);
|
||||
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;
|
||||
|
||||
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 ---
|
||||
if root.signed-in: VerticalLayout {
|
||||
if root.signed-in && !root.browsing: VerticalLayout {
|
||||
spacing: Theme.gap;
|
||||
|
||||
Text {
|
||||
|
||||
Reference in New Issue
Block a user