diff --git a/core/dr-gpu/examples/develop.rs b/core/dr-gpu/examples/develop.rs index 5285787..da1f865 100644 --- a/core/dr-gpu/examples/develop.rs +++ b/core/dr-gpu/examples/develop.rs @@ -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); + } _ => {} } diff --git a/core/dr-gpu/src/adjust.rs b/core/dr-gpu/src/adjust.rs index f28777c..da4199e 100644 --- a/core/dr-gpu/src/adjust.rs +++ b/core/dr-gpu/src/adjust.rs @@ -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); } } } diff --git a/core/dr-pipeline/src/ops/curve.rs b/core/dr-pipeline/src/ops/curve.rs index 244b650..91d0e94 100644 --- a/core/dr-pipeline/src/ops/curve.rs +++ b/core/dr-pipeline/src/ops/curve.rs @@ -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, ys: array, 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` 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, ys: array, 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(x0, x1, x2, x3, x4); - let ys = array(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. diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index c1765f4..d631925 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -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 { 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::::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 { + // 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 = 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 { + 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 diff --git a/ui/dr-ui/src/launch.rs b/ui/dr-ui/src/launch.rs index f1c35ff..f85a41b 100644 --- a/ui/dr-ui/src/launch.rs +++ b/ui/dr-ui/src/launch.rs @@ -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, +} + +/// 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, + /// True while a listing is in flight. + pub loading: bool, +} + +impl FolderBrowser { + /// Breadcrumb segments, root first. + pub fn breadcrumbs(&self) -> Vec { + 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 { + 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::>() + .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) { + 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) { + 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 { + 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) { 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(), ""); diff --git a/ui/dr-ui/src/launch_ui.rs b/ui/dr-ui/src/launch_ui.rs index 2b3396f..796c819 100644 --- a/ui/dr-ui/src/launch_ui.rs +++ b/ui/dr-ui/src/launch_ui.rs @@ -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 = 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 = 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, ctl: Rc) { +fn spawn_folder_list(weak: slint::Weak, ctl: Rc, path: String) { let Some(session) = ctl.model.borrow().session().cloned() else { return; }; @@ -329,7 +403,7 @@ fn spawn_folder_list(weak: slint::Weak, ctl: Rc) { }; 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 = entries .iter() diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index fb598a4..cbe8678 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -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>, session: &Rc>>) { +fn sync_rows( + window: &AppWindow, + rows: &Rc>, + session: &Rc>>, +) { 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>, session: &Rc) -> 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) -> 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) -> 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) -> 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); }); } diff --git a/ui/dr-ui/ui/adjust.slint b/ui/dr-ui/ui/adjust.slint index 2ae6a26..de4b439 100644 --- a/ui/dr-ui/ui/adjust.slint +++ b/ui/dr-ui/ui/adjust.slint @@ -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 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 active-point: -1; + + callback point-moved(int, float, float); + callback reset(); + + property 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 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 exists: idx < root.point-count; + property px: root.data.points[idx * 2]; + property 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 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 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); + } + } } } } diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index a53d173..a76e2af 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -162,6 +162,16 @@ export component AppWindow inherits Window { callback launch-format-toggled(int, bool); callback launch-copy-url(); + in property launch-browsing: false; + in property launch-browse-path: ""; + in property 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 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(); } } } diff --git a/ui/dr-ui/ui/launch.slint b/ui/dr-ui/ui/launch.slint index b61f701..719bb92 100644 --- a/ui/dr-ui/ui/launch.slint +++ b/ui/dr-ui/ui/launch.slint @@ -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 label; + in property 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 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 browsing: false; + in property browse-path: ""; + in property 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 {