Let a manual collection be put in the order it is meant to be seen in
`collection_members.position` and `Sort::CollectionPosition` have been in the catalog since collections were, and nothing above dr-catalog has ever written or read either: `collections::set_order` had no callers, and the grid ordered everything by capture time whatever it was scoped to — dr-ui does not construct a `Query` at all, it has its own `GRID_ORDER` constant. So a manual collection was a set with an order nobody could see or change. Three pieces, because it could not be fewer: `grid_order_for` decides the ordering from the scope, and both readers take it from there. That is the load-bearing part. An ordinal only names a photograph relative to an ordering, so the window read and the span read have to agree — a shift-click resolved through a different ORDER BY than the cells were drawn with selects a different run than the one on screen, and the user finds out when the export runs. `read_ids_span` already stated that invariant about `GRID_ORDER`; this widens it to an ordering that depends on the scope. Only a single manual collection has one. A set draws its descendants' images too, and two children's positions are unrelated integers that interleave arbitrarily; a smart collection has no member rows to carry a position at all. Both fall back to capture time and refuse the drop rather than pretending. The drop is on the cell, on whichever half of it the finger landed — the trailing edge is the only way to name the last place in a collection, since there is no cell beyond the last one to drop in front of. `reordered` is pure and the membership is rewritten whole. `set_order` sets the positions it is given and leaves the rest, so a partial write would interleave the moved run with rows nobody touched; and it is read unfiltered, so what the filter is hiding keeps its place relative to what the user can see. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -477,6 +477,23 @@ pub fn collections_for_image(
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What kind of collection `id` is, or `None` if there is no such collection.
|
||||||
|
///
|
||||||
|
/// Cheaper than reading the whole [`Collection`] where the caller only needs to
|
||||||
|
/// know whether member rows exist — the grid asks this to decide whether manual
|
||||||
|
/// position is a thing it can order by, and a smart collection has no
|
||||||
|
/// `collection_members` rows to carry one.
|
||||||
|
pub fn kind(conn: &Connection, id: CollectionId) -> Result<Option<CollectionKind>, CatalogError> {
|
||||||
|
let found = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT kind FROM collections WHERE id = ?1 AND deleted = 0",
|
||||||
|
[id.0 as i64],
|
||||||
|
|r| r.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
Ok(found.map(CollectionKind::from_i64))
|
||||||
|
}
|
||||||
|
|
||||||
/// A collection and everything beneath it, including itself.
|
/// A collection and everything beneath it, including itself.
|
||||||
///
|
///
|
||||||
/// Used for cycle checks and for scoping the grid to a parent: selecting a
|
/// Used for cycle checks and for scoping the grid to a parent: selecting a
|
||||||
|
|||||||
+22
-22
File diff suppressed because one or more lines are too long
@@ -43,6 +43,7 @@ use dr_types::{CollectionId, ImageId};
|
|||||||
use rusqlite::OptionalExtension as _;
|
use rusqlite::OptionalExtension as _;
|
||||||
use slint::{ComponentHandle, Model as _};
|
use slint::{ComponentHandle, Model as _};
|
||||||
|
|
||||||
|
use crate::library;
|
||||||
use crate::{AppWindow, CollectionRow};
|
use crate::{AppWindow, CollectionRow};
|
||||||
|
|
||||||
/// The selection as it stood before a press, for [`cancel_press`].
|
/// The selection as it stood before a press, for [`cancel_press`].
|
||||||
@@ -678,6 +679,12 @@ pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog
|
|||||||
*ctl.row_smart.borrow_mut() = smart;
|
*ctl.row_smart.borrow_mut() = smart;
|
||||||
*ctl.row_has_children.borrow_mut() = has_kids;
|
*ctl.row_has_children.borrow_mut() = has_kids;
|
||||||
window.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out)));
|
window.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out)));
|
||||||
|
|
||||||
|
// The rows are what `sync_reorderable` reads, so it has to be told they
|
||||||
|
// changed: a collection that gains a child, or one that is deleted out from
|
||||||
|
// under the scope, changes whether the grid can be reordered without the
|
||||||
|
// scope itself moving.
|
||||||
|
sync_reorderable(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-NC-6a | FR-NC-6c
|
/// TRACES: FR-NC-6a | FR-NC-6c
|
||||||
@@ -935,6 +942,29 @@ pub fn sync_selection(window: &AppWindow, ctl: &Rc<CollectionsController>, ids:
|
|||||||
window.set_library_selected_count(selection.len() as i32);
|
window.set_library_selected_count(selection.len() as i32);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-7
|
||||||
|
/// Whether what the grid is showing has an order the user can change.
|
||||||
|
///
|
||||||
|
/// A single manual collection: not the whole library, not a saved filter whose
|
||||||
|
/// membership is a rule, and not a set — a set draws its descendants' images
|
||||||
|
/// too, and two children's `position` columns are unrelated integers that would
|
||||||
|
/// interleave arbitrarily.
|
||||||
|
///
|
||||||
|
/// Read off the tree rows the sidebar already draws rather than asked of the
|
||||||
|
/// catalog. `smart` and `has_children` are the only two facts it needs and both
|
||||||
|
/// are in the model already, so this cannot disagree with what the user is
|
||||||
|
/// looking at, and a scope change costs no extra query.
|
||||||
|
fn sync_reorderable(window: &AppWindow) {
|
||||||
|
let id = window.get_collection_selected();
|
||||||
|
let rows = window.get_collection_rows();
|
||||||
|
let manual = id > 0
|
||||||
|
&& (0..rows.row_count())
|
||||||
|
.filter_map(|i| rows.row_data(i))
|
||||||
|
.find(|r| r.id == id)
|
||||||
|
.is_some_and(|r| !r.smart && !r.has_children);
|
||||||
|
window.set_library_reorderable(manual);
|
||||||
|
}
|
||||||
|
|
||||||
/// Refresh the per-cell "in this many collections" badges.
|
/// Refresh the per-cell "in this many collections" badges.
|
||||||
///
|
///
|
||||||
/// One query for the whole window rather than one per cell: 120 cells is 120
|
/// One query for the whole window rather than one per cell: 120 cells is 120
|
||||||
@@ -1574,6 +1604,93 @@ pub fn wire<S, R, P, C>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-CAT-7
|
||||||
|
// Drag a photograph, or a whole selection of them, to a new place in the
|
||||||
|
// collection being shown.
|
||||||
|
//
|
||||||
|
// `collection_members.position` and `Sort::CollectionPosition` have been in
|
||||||
|
// the catalog since collections were, and until now nothing above it ever
|
||||||
|
// wrote or read them — the grid ordered by capture time whatever it was
|
||||||
|
// scoped to. `library::grid_order_for` reads them; this writes them.
|
||||||
|
//
|
||||||
|
// The membership is rewritten whole rather than patched. `set_order` sets
|
||||||
|
// the positions it is given and leaves the rest, so a partial write would
|
||||||
|
// interleave the moved run with rows whose positions nobody touched — and
|
||||||
|
// it is read unfiltered for the same reason: the user reorders what they
|
||||||
|
// can see, and what the filter is hiding keeps its place relative to it.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
let catalog = catalog.clone();
|
||||||
|
let visible = visible_ids.clone();
|
||||||
|
let reload = on_scope_changed.clone();
|
||||||
|
window.on_library_reorder_to(move |row, after| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
// Both guards belong here rather than only in `library.slint`: the
|
||||||
|
// scope can change between the drag starting and the drop landing.
|
||||||
|
if !w.get_library_reorderable() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(scope) = *ctl.scope.borrow() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let moving = ctl.selected();
|
||||||
|
if moving.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ids = visible();
|
||||||
|
let Some(&target) = usize::try_from(row).ok().and_then(|r| ids.get(r)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dropped on one of its own. There is no gap between a run and
|
||||||
|
// itself to land in, and rewriting the whole membership to say so
|
||||||
|
// would be a revision bump for no change.
|
||||||
|
if moving.contains(&target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let borrow = catalog.borrow();
|
||||||
|
let Some(cat) = borrow.as_ref() else { return };
|
||||||
|
|
||||||
|
let current = match library::read_member_order(cat, scope) {
|
||||||
|
Ok(current) => current,
|
||||||
|
Err(e) => {
|
||||||
|
w.set_collection_error(format!("reading the collection's order: {e}").into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let wanted = library::reordered(¤t, &moving, target, after);
|
||||||
|
|
||||||
|
match coll::set_order(cat.connection(), scope, &wanted) {
|
||||||
|
Ok(()) => {
|
||||||
|
w.set_collection_error(slint::SharedString::new());
|
||||||
|
w.set_library_status(
|
||||||
|
format!(
|
||||||
|
"{} photograph{} moved",
|
||||||
|
moving.len(),
|
||||||
|
if moving.len() == 1 { "" } else { "s" }
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
drop(borrow);
|
||||||
|
|
||||||
|
// The selection survives. It is what was just moved, and
|
||||||
|
// dropping it would make a second nudge — which is how a
|
||||||
|
// drag-to-reorder is usually corrected — start over.
|
||||||
|
reload();
|
||||||
|
sync_selection(&w, &ctl, &visible());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
drop(borrow);
|
||||||
|
w.set_collection_error(format!("reordering: {e}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// TRACES: FR-CAT-5 | FR-UI-4
|
// TRACES: FR-CAT-5 | FR-UI-4
|
||||||
// Everything the grid is showing.
|
// Everything the grid is showing.
|
||||||
//
|
//
|
||||||
@@ -2231,6 +2348,7 @@ pub fn wire<S, R, P, C>(
|
|||||||
};
|
};
|
||||||
w.set_collection_selected(id);
|
w.set_collection_selected(id);
|
||||||
w.set_collection_scope_label(label.into());
|
w.set_collection_scope_label(label.into());
|
||||||
|
sync_reorderable(&w);
|
||||||
reload();
|
reload();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+330
-6
@@ -3794,9 +3794,10 @@ pub fn read_cells(
|
|||||||
/// look broken. The id list comes from
|
/// look broken. The id list comes from
|
||||||
/// [`dr_catalog::collections::descendants`], which is depth-guarded.
|
/// [`dr_catalog::collections::descendants`], which is depth-guarded.
|
||||||
///
|
///
|
||||||
/// Ordering matches the unscoped grid (capture time, then name) rather than
|
/// Ordering comes from [`grid_order_for`]: manual position for a single manual
|
||||||
/// manual position: position is only meaningful inside one collection and this
|
/// collection, capture time for a set — because position is only meaningful
|
||||||
/// query also serves sets, where two children's positions are unrelated.
|
/// inside one collection, and this query also serves sets, where two children's
|
||||||
|
/// positions are unrelated integers.
|
||||||
pub fn read_cells_scoped(
|
pub fn read_cells_scoped(
|
||||||
catalog: &Catalog,
|
catalog: &Catalog,
|
||||||
scope: Option<dr_types::CollectionId>,
|
scope: Option<dr_types::CollectionId>,
|
||||||
@@ -3814,20 +3815,24 @@ pub fn read_cells_scoped(
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(",");
|
.join(",");
|
||||||
let rated = filter.sql();
|
let rated = filter.sql();
|
||||||
|
let (order, order_params) = grid_order_for(catalog, Some(scope));
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT {CELL_COLUMNS}
|
"SELECT {CELL_COLUMNS}
|
||||||
FROM images i
|
FROM images i
|
||||||
WHERE {VISIBLE}{rated}
|
WHERE {VISIBLE}{rated}
|
||||||
AND i.id IN (SELECT image_id FROM collection_members
|
AND i.id IN (SELECT image_id FROM collection_members
|
||||||
WHERE collection_id IN ({placeholders}))
|
WHERE collection_id IN ({placeholders}))
|
||||||
{GRID_ORDER}
|
{order}
|
||||||
LIMIT ? OFFSET ?"
|
LIMIT ? OFFSET ?"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Bound in the order the `?`s appear: the scope's ids in the WHERE, then
|
||||||
|
// whatever the ORDER BY needs, then the window.
|
||||||
let mut params: Vec<rusqlite::types::Value> = ids
|
let mut params: Vec<rusqlite::types::Value> = ids
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
|
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
|
||||||
.collect();
|
.collect();
|
||||||
|
params.extend(order_params);
|
||||||
params.push(rusqlite::types::Value::Integer(limit as i64));
|
params.push(rusqlite::types::Value::Integer(limit as i64));
|
||||||
params.push(rusqlite::types::Value::Integer(offset as i64));
|
params.push(rusqlite::types::Value::Integer(offset as i64));
|
||||||
|
|
||||||
@@ -3952,13 +3957,18 @@ pub fn read_ids_span(
|
|||||||
Vec::new(),
|
Vec::new(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
let (clause, params) = scope_clause(catalog, scope)?;
|
let (clause, mut params) = scope_clause(catalog, scope)?;
|
||||||
let rated = filter.sql();
|
let rated = filter.sql();
|
||||||
|
// The same ordering the cells were drawn with, from the same place.
|
||||||
|
// A range is a pair of ordinals, and an ordinal read through a
|
||||||
|
// different ORDER BY names a different photograph.
|
||||||
|
let (order, order_params) = grid_order_for(catalog, scope);
|
||||||
|
params.extend(order_params);
|
||||||
(
|
(
|
||||||
format!(
|
format!(
|
||||||
"SELECT i.id FROM images i
|
"SELECT i.id FROM images i
|
||||||
WHERE {VISIBLE}{rated}{clause}
|
WHERE {VISIBLE}{rated}{clause}
|
||||||
{GRID_ORDER}
|
{order}
|
||||||
LIMIT ? OFFSET ?"
|
LIMIT ? OFFSET ?"
|
||||||
),
|
),
|
||||||
params,
|
params,
|
||||||
@@ -4105,6 +4115,146 @@ pub fn total_images_scoped(
|
|||||||
Ok(n as usize)
|
Ok(n as usize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-7
|
||||||
|
/// Every member of `scope`, in the order its positions put them.
|
||||||
|
///
|
||||||
|
/// The *whole* membership, not the window and not the filtered view. A reorder
|
||||||
|
/// rewrites positions, and [`dr_catalog::collections::set_order`] only touches
|
||||||
|
/// the rows it is given — so writing back a filtered subset would leave the
|
||||||
|
/// images the filter is hiding at their old positions, interleaved with the new
|
||||||
|
/// ones arbitrarily. The user reorders what they can see; the rows they cannot
|
||||||
|
/// keep their place relative to it.
|
||||||
|
pub fn read_member_order(
|
||||||
|
catalog: &Catalog,
|
||||||
|
scope: dr_types::CollectionId,
|
||||||
|
) -> Result<Vec<dr_types::ImageId>, dr_catalog::CatalogError> {
|
||||||
|
let mut stmt = catalog.connection().prepare(
|
||||||
|
"SELECT image_id FROM collection_members
|
||||||
|
WHERE collection_id = ?1
|
||||||
|
ORDER BY position ASC, image_id ASC",
|
||||||
|
)?;
|
||||||
|
let ids = stmt
|
||||||
|
.query_map([scope.0 as i64], |r| {
|
||||||
|
Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64))
|
||||||
|
})?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-7
|
||||||
|
/// `current` with `moving` lifted out and set down beside `target`.
|
||||||
|
///
|
||||||
|
/// `target` names a *photograph*, not an index, and that is the point: the grid
|
||||||
|
/// may be filtered, so the cell the user dropped on sits at one position in
|
||||||
|
/// what they can see and another in the membership being rewritten. An id
|
||||||
|
/// survives both. `after` puts the run on the far side of it, which is the only
|
||||||
|
/// way to name the last place in a collection — there is no cell beyond the
|
||||||
|
/// last one to drop in front of.
|
||||||
|
///
|
||||||
|
/// The run keeps the order `current` has it in rather than the order the
|
||||||
|
/// selection was built in: the user is looking at the grid, and a selection
|
||||||
|
/// gathered by tapping the last frame first should not reverse itself on being
|
||||||
|
/// moved.
|
||||||
|
///
|
||||||
|
/// A `target` that is itself being moved leaves the run at the end. There is no
|
||||||
|
/// gap between a run and itself to land in, so the caller refuses that drop
|
||||||
|
/// before it gets here; this is what the function does rather than panicking if
|
||||||
|
/// one ever arrives.
|
||||||
|
///
|
||||||
|
/// Pure, so the awkward half of a drag can be tested without a window.
|
||||||
|
pub fn reordered(
|
||||||
|
current: &[dr_types::ImageId],
|
||||||
|
moving: &[dr_types::ImageId],
|
||||||
|
target: dr_types::ImageId,
|
||||||
|
after: bool,
|
||||||
|
) -> Vec<dr_types::ImageId> {
|
||||||
|
let lifting: std::collections::BTreeSet<_> = moving.iter().copied().collect();
|
||||||
|
let rest: Vec<_> = current
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|id| !lifting.contains(id))
|
||||||
|
.collect();
|
||||||
|
let run: Vec<_> = current
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|id| lifting.contains(id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Resolved against `rest`, not against `current`: the run has already been
|
||||||
|
// lifted, so an index into the original list would be off by however many
|
||||||
|
// of it sat ahead of the target.
|
||||||
|
let at = match rest.iter().position(|id| *id == target) {
|
||||||
|
Some(at) if after => at + 1,
|
||||||
|
Some(at) => at,
|
||||||
|
None => rest.len(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(current.len());
|
||||||
|
out.extend_from_slice(&rest[..at]);
|
||||||
|
out.extend(run);
|
||||||
|
out.extend_from_slice(&rest[at..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-7
|
||||||
|
/// The ORDER BY the grid reads `scope` with, and the parameters it binds.
|
||||||
|
///
|
||||||
|
/// Manual position where the grid is scoped to a single manual collection with
|
||||||
|
/// no children; [`GRID_ORDER`] — capture time, then filename — everywhere else.
|
||||||
|
///
|
||||||
|
/// **Why the narrowing.** `position` is a column of `collection_members`, so it
|
||||||
|
/// only exists relative to one collection. A collection *set* shows its
|
||||||
|
/// descendants' images too, and two children's positions are unrelated integers
|
||||||
|
/// that would interleave arbitrarily; a smart collection has no member rows to
|
||||||
|
/// carry a position at all. Outside those cases there is no manual order to
|
||||||
|
/// read, and falling back is the only honest answer.
|
||||||
|
///
|
||||||
|
/// **Why every reader must agree.** An ordinal only names a photograph relative
|
||||||
|
/// to an ordering. The window read and the span read are two halves of one
|
||||||
|
/// grid: a shift-click resolved through a different ORDER BY than the cells
|
||||||
|
/// were drawn with selects a different run than the one on screen, and the user
|
||||||
|
/// finds out when the export runs. That is the same invariant
|
||||||
|
/// [`read_ids_span`] already states about `GRID_ORDER`, widened to cover the
|
||||||
|
/// case where the ordering depends on the scope.
|
||||||
|
///
|
||||||
|
/// A correlated subquery rather than a join, so the FROM and WHERE the two
|
||||||
|
/// readers already share are untouched: position is looked up per row through
|
||||||
|
/// `collection_members`' primary key, which is `(collection_id, image_id)`.
|
||||||
|
fn grid_order_for(
|
||||||
|
catalog: &Catalog,
|
||||||
|
scope: Option<dr_types::CollectionId>,
|
||||||
|
) -> (String, Vec<rusqlite::types::Value>) {
|
||||||
|
let Some(id) = scope else {
|
||||||
|
return (GRID_ORDER.to_string(), Vec::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
// A set orders by capture time. `descendants` includes the collection
|
||||||
|
// itself, so one entry means it has no children.
|
||||||
|
let alone = dr_catalog::collections::descendants(catalog.connection(), id)
|
||||||
|
.map(|d| d.len() == 1)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let manual = matches!(
|
||||||
|
dr_catalog::collections::kind(catalog.connection(), id),
|
||||||
|
Ok(Some(dr_catalog::collections::CollectionKind::Manual))
|
||||||
|
);
|
||||||
|
if !alone || !manual {
|
||||||
|
return (GRID_ORDER.to_string(), Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// `i.id` breaks the tie. Positions are dense after a `set_order`, but a
|
||||||
|
// collection that has never been reordered by hand has whatever
|
||||||
|
// `add_images` assigned, and two rows can share a position if a merge from
|
||||||
|
// another device brought one in — an ordering that is not total is an
|
||||||
|
// ordering the window read and the span read can disagree about.
|
||||||
|
(
|
||||||
|
"ORDER BY (SELECT cm.position FROM collection_members cm
|
||||||
|
WHERE cm.collection_id = ? AND cm.image_id = i.id) ASC,
|
||||||
|
i.id ASC"
|
||||||
|
.to_string(),
|
||||||
|
vec![rusqlite::types::Value::Integer(id.0 as i64)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// The SQL restricting a query to `scope` and its descendants, with the bound
|
/// The SQL restricting a query to `scope` and its descendants, with the bound
|
||||||
/// parameters to go with it.
|
/// parameters to go with it.
|
||||||
///
|
///
|
||||||
@@ -5333,6 +5483,180 @@ mod tests {
|
|||||||
assert_eq!(read_trashed_cells(&catalog, 0, 50).unwrap().len(), 3);
|
assert_eq!(read_trashed_cells(&catalog, 0, 50).unwrap().len(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- manual order within a collection (FR-CAT-7) ------------------------
|
||||||
|
|
||||||
|
fn ids(n: &[u64]) -> Vec<dr_types::ImageId> {
|
||||||
|
n.iter().copied().map(dr_types::ImageId).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_run_moved_forward_lands_before_the_photograph_it_was_dropped_on() {
|
||||||
|
let current = ids(&[1, 2, 3, 4, 5]);
|
||||||
|
assert_eq!(
|
||||||
|
reordered(¤t, &ids(&[4]), dr_types::ImageId(2), false),
|
||||||
|
ids(&[1, 4, 2, 3, 5])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_run_moved_backward_lands_before_it_too() {
|
||||||
|
// The direction of travel must not change what "before this one" means,
|
||||||
|
// or the same drop would land in two different places depending on
|
||||||
|
// where the photograph came from.
|
||||||
|
let current = ids(&[1, 2, 3, 4, 5]);
|
||||||
|
assert_eq!(
|
||||||
|
reordered(¤t, &ids(&[2]), dr_types::ImageId(5), false),
|
||||||
|
ids(&[1, 3, 4, 2, 5])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_trailing_half_of_the_last_cell_is_how_the_end_is_reached() {
|
||||||
|
// There is no cell beyond the last one to drop in front of, so without
|
||||||
|
// `after` the final position is unreachable — which is exactly the
|
||||||
|
// place a "put this at the end" drag is aiming for.
|
||||||
|
let current = ids(&[1, 2, 3]);
|
||||||
|
assert_eq!(
|
||||||
|
reordered(¤t, &ids(&[1]), dr_types::ImageId(3), true),
|
||||||
|
ids(&[2, 3, 1])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_moved_run_keeps_the_order_the_grid_shows_it_in() {
|
||||||
|
// Not the order the selection was built in. A user who tapped the last
|
||||||
|
// frame first has said nothing about how the run should be arranged —
|
||||||
|
// only about where it should go.
|
||||||
|
let current = ids(&[1, 2, 3, 4, 5]);
|
||||||
|
assert_eq!(
|
||||||
|
reordered(¤t, &ids(&[5, 1]), dr_types::ImageId(3), false),
|
||||||
|
ids(&[2, 1, 5, 3, 4])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_run_dropped_on_one_of_its_own_members_stays_together() {
|
||||||
|
// The caller refuses this drop, so it is only reachable if that guard
|
||||||
|
// is ever lost. It must not lose photographs when it is.
|
||||||
|
let current = ids(&[1, 2, 3, 4]);
|
||||||
|
let moved = reordered(¤t, &ids(&[2, 3]), dr_types::ImageId(3), false);
|
||||||
|
assert_eq!(moved.len(), current.len(), "nothing was dropped");
|
||||||
|
let mut sorted = moved.clone();
|
||||||
|
sorted.sort();
|
||||||
|
assert_eq!(sorted, ids(&[1, 2, 3, 4]), "and nothing was invented");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reorder_never_loses_or_duplicates_a_member() {
|
||||||
|
// The property that matters most: this writes the whole membership
|
||||||
|
// back, so a run that dropped one image would delete it from the
|
||||||
|
// collection.
|
||||||
|
let current = ids(&[1, 2, 3, 4, 5, 6]);
|
||||||
|
for target in [1u64, 2, 3, 4, 5, 6] {
|
||||||
|
for after in [false, true] {
|
||||||
|
let moved = reordered(¤t, &ids(&[2, 5]), dr_types::ImageId(target), after);
|
||||||
|
let mut sorted = moved.clone();
|
||||||
|
sorted.sort();
|
||||||
|
assert_eq!(
|
||||||
|
sorted,
|
||||||
|
ids(&[1, 2, 3, 4, 5, 6]),
|
||||||
|
"target {target}, after {after}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scoped grid and the range a shift-click resolves are two halves of
|
||||||
|
/// one ordering. This is the assertion that keeps them one: an ordinal read
|
||||||
|
/// through a different ORDER BY names a different photograph, and the user
|
||||||
|
/// finds out when the export runs.
|
||||||
|
#[test]
|
||||||
|
fn a_manual_collection_is_read_and_spanned_in_the_order_it_was_given() {
|
||||||
|
let catalog = with_images(5);
|
||||||
|
let all = image_ids(&catalog);
|
||||||
|
let id = dr_catalog::collections::create(
|
||||||
|
catalog.connection(),
|
||||||
|
"Trip",
|
||||||
|
None,
|
||||||
|
dr_catalog::collections::CollectionKind::Manual,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
dr_catalog::collections::add_images(catalog.connection(), id, &all).unwrap();
|
||||||
|
|
||||||
|
// Reversed, so position and capture time disagree about everything.
|
||||||
|
let wanted: Vec<_> = all.iter().rev().copied().collect();
|
||||||
|
dr_catalog::collections::set_order(catalog.connection(), id, &wanted).unwrap();
|
||||||
|
|
||||||
|
let cells = read_cells_scoped(&catalog, Some(id), &RatingFilter::default(), 0, 50).unwrap();
|
||||||
|
let drawn: Vec<_> = cells
|
||||||
|
.iter()
|
||||||
|
.map(|c| dr_types::ImageId(c.image_id as u64))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(drawn, wanted, "the grid draws the order that was written");
|
||||||
|
|
||||||
|
let spanned =
|
||||||
|
read_ids_span(&catalog, Some(id), &RatingFilter::default(), false, 0, 4).unwrap();
|
||||||
|
assert_eq!(spanned, wanted, "and a range resolves through the same one");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
read_member_order(&catalog, id).unwrap(),
|
||||||
|
wanted,
|
||||||
|
"and so does the membership a reorder rewrites"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_collection_with_children_falls_back_to_capture_time() {
|
||||||
|
// A set draws its descendants' images too, and two children's positions
|
||||||
|
// are unrelated integers. Ordering by them interleaves the two
|
||||||
|
// arbitrarily, which is worse than an order that at least means
|
||||||
|
// something.
|
||||||
|
let catalog = with_images(4);
|
||||||
|
let all = image_ids(&catalog);
|
||||||
|
let parent = dr_catalog::collections::create(
|
||||||
|
catalog.connection(),
|
||||||
|
"Iceland",
|
||||||
|
None,
|
||||||
|
dr_catalog::collections::CollectionKind::Manual,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
dr_catalog::collections::create(
|
||||||
|
catalog.connection(),
|
||||||
|
"Day one",
|
||||||
|
Some(parent),
|
||||||
|
dr_catalog::collections::CollectionKind::Manual,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
dr_catalog::collections::add_images(catalog.connection(), parent, &all).unwrap();
|
||||||
|
let reversed: Vec<_> = all.iter().rev().copied().collect();
|
||||||
|
dr_catalog::collections::set_order(catalog.connection(), parent, &reversed).unwrap();
|
||||||
|
|
||||||
|
let cells =
|
||||||
|
read_cells_scoped(&catalog, Some(parent), &RatingFilter::default(), 0, 50).unwrap();
|
||||||
|
let drawn: Vec<_> = cells
|
||||||
|
.iter()
|
||||||
|
.map(|c| dr_types::ImageId(c.image_id as u64))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(drawn, all, "capture time, not the positions that were set");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_smart_collection_has_no_manual_order_to_read() {
|
||||||
|
// No member rows at all, so `position` is not a column any of its
|
||||||
|
// images have. Falling back is the only thing there is to do.
|
||||||
|
let catalog = with_images(3);
|
||||||
|
let id = dr_catalog::collections::create(
|
||||||
|
catalog.connection(),
|
||||||
|
"Picks",
|
||||||
|
None,
|
||||||
|
dr_catalog::collections::CollectionKind::Smart,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let (order, params) = grid_order_for(&catalog, Some(id));
|
||||||
|
assert_eq!(order, GRID_ORDER);
|
||||||
|
assert!(params.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_most_recently_trashed_image_is_listed_first() {
|
fn the_most_recently_trashed_image_is_listed_first() {
|
||||||
// A mistaken delete is corrected within seconds, so the row the user
|
// A mistaken delete is corrected within seconds, so the row the user
|
||||||
|
|||||||
@@ -310,6 +310,14 @@ export component AppWindow inherits Window {
|
|||||||
/// TRACES: FR-CAT-5
|
/// TRACES: FR-CAT-5
|
||||||
callback library-clear-selection();
|
callback library-clear-selection();
|
||||||
callback library-select-all();
|
callback library-select-all();
|
||||||
|
/// TRACES: FR-CAT-7
|
||||||
|
/// Whether the grid is showing something with a manual order to change —
|
||||||
|
/// a single manual collection, not a set and not a saved filter.
|
||||||
|
in property <bool> library-reorderable: false;
|
||||||
|
/// Move the selection beside the photograph at this row of the loaded
|
||||||
|
/// window: after it where the drop landed on the cell's trailing half,
|
||||||
|
/// before it otherwise.
|
||||||
|
callback library-reorder-to(int, bool);
|
||||||
callback library-collection-from-selection(string);
|
callback library-collection-from-selection(string);
|
||||||
/// TRACES: FR-CAT-6
|
/// TRACES: FR-CAT-6
|
||||||
in property <string> library-range-from;
|
in property <string> library-range-from;
|
||||||
@@ -1442,6 +1450,8 @@ in property <bool> panel-visible: true;
|
|||||||
toggle-date-range() => { root.library-toggle-date-range(); }
|
toggle-date-range() => { root.library-toggle-date-range(); }
|
||||||
clear-selection() => { root.library-clear-selection(); }
|
clear-selection() => { root.library-clear-selection(); }
|
||||||
select-all() => { root.library-select-all(); }
|
select-all() => { root.library-select-all(); }
|
||||||
|
reorderable: root.library-reorderable;
|
||||||
|
reorder-to(row, after) => { root.library-reorder-to(row, after); }
|
||||||
collection-from-selection(name) => {
|
collection-from-selection(name) => {
|
||||||
root.library-collection-from-selection(name);
|
root.library-collection-from-selection(name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1240,6 +1240,27 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
in property <bool> select-mode: false;
|
in property <bool> select-mode: false;
|
||||||
callback toggle-select-mode();
|
callback toggle-select-mode();
|
||||||
/// TRACES: FR-CAT-5
|
/// TRACES: FR-CAT-5
|
||||||
|
// --- reordering a manual collection (FR-CAT-7) --------------------------
|
||||||
|
//
|
||||||
|
// `collection_members.position` and `Sort::CollectionPosition` have existed
|
||||||
|
// in the catalog since collections did, and nothing above it ever wrote or
|
||||||
|
// read them: the grid ordered everything by capture time, always. This is
|
||||||
|
// the gesture that makes the column mean something.
|
||||||
|
//
|
||||||
|
// Only where there is a manual order to change — a single manual collection
|
||||||
|
// with no children. A set interleaves two children's unrelated positions
|
||||||
|
// and a smart collection has no member rows at all, so both fall back to
|
||||||
|
// capture time and refuse the drop rather than pretending.
|
||||||
|
/// Whether a drop on a cell should move photographs within the collection
|
||||||
|
/// being shown. Set by Rust from the scope, because what a scope *is* is a
|
||||||
|
/// catalog question.
|
||||||
|
in property <bool> reorderable: false;
|
||||||
|
/// Move the selection so it sits beside the photograph at this row of the
|
||||||
|
/// loaded window — before it, or after it where the drop landed on the
|
||||||
|
/// cell's trailing half. The trailing half is what makes the last position
|
||||||
|
/// reachable at all; without it there is no cell to drop "before".
|
||||||
|
callback reorder-to(int, bool);
|
||||||
|
|
||||||
/// Drop the selection without leaving select mode.
|
/// Drop the selection without leaving select mode.
|
||||||
callback clear-selection();
|
callback clear-selection();
|
||||||
/// TRACES: FR-CAT-5 | FR-UI-4
|
/// TRACES: FR-CAT-5 | FR-UI-4
|
||||||
@@ -2777,6 +2798,41 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
}
|
}
|
||||||
drag-finished(action) => { root.drag-finished(); }
|
drag-finished(action) => { root.drag-finished(); }
|
||||||
|
|
||||||
|
// Where a reorder lands. Behind the cell's content and
|
||||||
|
// before it in the file, for the reason `TreeRow`'s drop
|
||||||
|
// area gives: a `DropArea` only takes part in a drag, so it
|
||||||
|
// does not block the presses the cell's own TouchArea
|
||||||
|
// needs — but drawn first it cannot paint over the
|
||||||
|
// thumbnail either.
|
||||||
|
//
|
||||||
|
// Present on every cell rather than wrapped in an `if`, so
|
||||||
|
// the marker below can name it. `can-drop` is where the
|
||||||
|
// refusal lives, which also means the cursor says no while
|
||||||
|
// the user can still aim somewhere else.
|
||||||
|
reorder-drop := DropArea {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
/// Whether the run would land after this photograph
|
||||||
|
/// rather than before it. Tracked during the hover so
|
||||||
|
/// the marker can move to the edge the drop will
|
||||||
|
/// actually use.
|
||||||
|
property <bool> after: false;
|
||||||
|
|
||||||
|
can-drop(ev) => {
|
||||||
|
if (!root.reorderable) {
|
||||||
|
return DragAction.none;
|
||||||
|
}
|
||||||
|
self.after = ev.position.x > self.width / 2;
|
||||||
|
return DragAction.copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
dropped(ev) => {
|
||||||
|
root.reorder-to(i, ev.position.x > self.width / 2);
|
||||||
|
return DragAction.copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
// Lifted cells shrink toward their own centre, as though pulled
|
// Lifted cells shrink toward their own centre, as though pulled
|
||||||
// off the page. Inset rather than scaled: Slint has no transform
|
// off the page. Inset rather than scaled: Slint has no transform
|
||||||
@@ -3051,6 +3107,28 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Where the run would land. A bar in the gutter beside the
|
||||||
|
// cell it would sit next to, on whichever side the drop
|
||||||
|
// will actually use — the trailing edge is what makes the
|
||||||
|
// last place in a collection reachable, and a marker that
|
||||||
|
// did not move with it would be pointing at the wrong gap
|
||||||
|
// half the time.
|
||||||
|
//
|
||||||
|
// Last child of the `DragArea`, so it draws over the
|
||||||
|
// thumbnail rather than under it. In the gutter rather than
|
||||||
|
// on the cell, because a bar drawn *on* the first cell of a
|
||||||
|
// row reads as belonging to that cell instead of to the
|
||||||
|
// space before it.
|
||||||
|
if reorder-drop.has-drag: Rectangle {
|
||||||
|
x: reorder-drop.after
|
||||||
|
? parent.width + Theme.gap / 2 - 1.5px
|
||||||
|
: -Theme.gap / 2 - 1.5px;
|
||||||
|
y: 0;
|
||||||
|
width: 3px;
|
||||||
|
height: parent.height;
|
||||||
|
background: Theme.active;
|
||||||
|
border-radius: 1.5px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user