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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user