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:
+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