Draw the date histogram over the collection you are looking at
Build and test / Desktop (Linux) (push) Failing after 57m25s
Build and test / Layer separation (push) Successful in 33s
Traceability / Requirement traces (push) Failing after 27s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 9m45s
Build and test / Desktop (Linux) (push) Failing after 57m25s
Build and test / Layer separation (push) Successful in 33s
Traceability / Requirement traces (push) Failing after 27s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 9m45s
The timeline counted the whole library whatever the grid was showing, so opening a collection left a fortnight in Arosa as one column of a fifteen-year axis — an axis describing photographs that were not on screen. Scope the buckets and the span to the same collection and rating filter the grid uses. `timeline_range` counts `images` alone and cannot express the membership join, so the scoped query lives beside the other scoped readers in the UI and shares their descendants-of-scope rule. `catalog_span` now delegates to the same scoped reader. Zoom and scrub measured the full library while the bars were scoped, so a scrub could land on an instant the collection did not contain and send the view somewhere the user had not asked to go. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2630,6 +2630,100 @@ pub fn total_images_scoped(
|
||||
Ok(n as usize)
|
||||
}
|
||||
|
||||
/// The SQL restricting a query to `scope` and its descendants, with the bound
|
||||
/// parameters to go with it.
|
||||
///
|
||||
/// Shared by the span and the histogram so the two cannot drift: an axis drawn
|
||||
/// over one set of images and bars counted over another puts the bars in the
|
||||
/// wrong place.
|
||||
fn scope_clause(
|
||||
catalog: &Catalog,
|
||||
scope: Option<dr_types::CollectionId>,
|
||||
) -> Result<(String, Vec<rusqlite::types::Value>), dr_catalog::CatalogError> {
|
||||
let Some(scope) = scope else {
|
||||
return Ok((String::new(), Vec::new()));
|
||||
};
|
||||
let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?;
|
||||
let placeholders = std::iter::repeat_n("?", ids.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
Ok((
|
||||
format!(
|
||||
" AND i.id IN (SELECT image_id FROM collection_members
|
||||
WHERE collection_id IN ({placeholders}))"
|
||||
),
|
||||
ids.iter()
|
||||
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Earliest and latest capture time within `scope`, honouring the filter.
|
||||
///
|
||||
/// The timeline's extent. Taken over the same images the histogram counts, so
|
||||
/// opening a collection shows that collection's years rather than the whole
|
||||
/// library's — the axis was previously spanning everything, which left a
|
||||
/// collection's bars crushed into a sliver of it.
|
||||
pub fn span_scoped(
|
||||
catalog: &Catalog,
|
||||
scope: Option<dr_types::CollectionId>,
|
||||
filter: &RatingFilter,
|
||||
) -> Option<(i64, i64)> {
|
||||
let (clause, params) = scope_clause(catalog, scope).ok()?;
|
||||
let rated = filter.sql();
|
||||
let sql = format!(
|
||||
"SELECT min(i.captured_at), max(i.captured_at) FROM images i
|
||||
WHERE {VISIBLE}{rated} AND i.captured_at IS NOT NULL{clause}"
|
||||
);
|
||||
catalog
|
||||
.connection()
|
||||
.query_row(&sql, rusqlite::params_from_iter(params.iter()), |r| {
|
||||
Ok((r.get::<_, Option<i64>>(0)?, r.get::<_, Option<i64>>(1)?))
|
||||
})
|
||||
.ok()
|
||||
.and_then(|(lo, hi)| Some((lo?, hi?)))
|
||||
}
|
||||
|
||||
/// Histogram buckets within `scope` and a date range.
|
||||
///
|
||||
/// `Catalog::timeline_range` counts the whole library; a collection needs the
|
||||
/// membership join, and the rating filter applied too, or the bars disagree
|
||||
/// with the grid beneath them.
|
||||
pub fn timeline_scoped(
|
||||
catalog: &Catalog,
|
||||
scope: Option<dr_types::CollectionId>,
|
||||
filter: &RatingFilter,
|
||||
granularity: dr_catalog::Granularity,
|
||||
from: i64,
|
||||
to: i64,
|
||||
) -> Result<Vec<dr_catalog::TimeBucket>, dr_catalog::CatalogError> {
|
||||
let (clause, mut params) = scope_clause(catalog, scope)?;
|
||||
let rated = filter.sql();
|
||||
let sql = format!(
|
||||
"SELECT min(i.captured_at) AS start, count(*) AS n FROM images i
|
||||
WHERE {VISIBLE}{rated} AND i.captured_at IS NOT NULL{clause}
|
||||
AND i.captured_at >= ? AND i.captured_at <= ?
|
||||
GROUP BY strftime('{}', i.captured_at
|
||||
+ coalesce(i.captured_offset, 0) * 60, 'unixepoch')
|
||||
ORDER BY start ASC",
|
||||
granularity.strftime()
|
||||
);
|
||||
params.push(rusqlite::types::Value::Integer(from));
|
||||
params.push(rusqlite::types::Value::Integer(to));
|
||||
|
||||
let conn = catalog.connection();
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||
Ok(dr_catalog::TimeBucket {
|
||||
start: r.get(0)?,
|
||||
count: r.get::<_, i64>(1)? as u32,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Total images in the catalog, honouring the rating filter.
|
||||
fn total_images_filtered(
|
||||
catalog: &Catalog,
|
||||
|
||||
+18
-29
@@ -2720,7 +2720,12 @@ fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
/// from the data rather than fixing it means the histogram is informative at
|
||||
/// both scales instead of one flat bar or ten thousand slivers.
|
||||
fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc<LibraryController>) {
|
||||
let span = match catalog_span(catalog) {
|
||||
// Scoped to whatever the grid is showing. A collection's histogram drawn
|
||||
// over the whole library's span said almost nothing: every bar for a
|
||||
// fortnight in Arosa landed in one column of a fifteen-year axis.
|
||||
let scope = *ctl.scope.borrow();
|
||||
let filter = *ctl.filter.borrow();
|
||||
let span = match library::span_scoped(catalog, scope, &filter) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
// No dated images yet. An empty histogram is honest — EXIF is read
|
||||
@@ -2737,13 +2742,7 @@ fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc<LibraryContr
|
||||
let (from, to) = zoomed_span(span, zoom, *ctl.timeline_centre.borrow());
|
||||
|
||||
let granularity = dr_catalog::Granularity::for_span(to - from);
|
||||
let buckets = match catalog.timeline_range(
|
||||
&dr_catalog::Query::default(),
|
||||
granularity,
|
||||
from,
|
||||
to,
|
||||
now_secs(),
|
||||
) {
|
||||
let buckets = match library::timeline_scoped(catalog, scope, &filter, granularity, from, to) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::debug!("timeline: {e}");
|
||||
@@ -2875,17 +2874,14 @@ fn zoomed_span(full: (i64, i64), zoom: i32, centre: Option<i64>) -> (i64, i64) {
|
||||
}
|
||||
|
||||
/// Earliest and latest capture time in the catalog.
|
||||
fn catalog_span(catalog: &Catalog) -> Option<(i64, i64)> {
|
||||
catalog
|
||||
.connection()
|
||||
.query_row(
|
||||
"SELECT min(captured_at), max(captured_at)
|
||||
FROM images WHERE captured_at IS NOT NULL",
|
||||
[],
|
||||
|r| Ok((r.get::<_, Option<i64>>(0)?, r.get::<_, Option<i64>>(1)?)),
|
||||
)
|
||||
.ok()
|
||||
.and_then(|(lo, hi)| Some((lo?, hi?)))
|
||||
/// The timeline's extent, over exactly the images the grid is showing.
|
||||
///
|
||||
/// Scrubbing and zooming must measure the same span the histogram is drawn
|
||||
/// over. When this read the whole library while the bars were scoped to a
|
||||
/// collection, a scrub landed at an instant the collection did not contain and
|
||||
/// the view jumped somewhere the user had not asked for.
|
||||
fn catalog_span(catalog: &Catalog, ctl: &Rc<LibraryController>) -> Option<(i64, i64)> {
|
||||
library::span_scoped(catalog, *ctl.scope.borrow(), &ctl.filter.borrow())
|
||||
}
|
||||
|
||||
/// Capture time of the image at row `ordinal` in the grid's own ordering.
|
||||
@@ -3003,13 +2999,6 @@ fn civil_from_unix(t: i64) -> (i64, i64, i64, i64) {
|
||||
(if m <= 2 { y + 1 } else { y }, m, d, secs / 3600)
|
||||
}
|
||||
|
||||
fn now_secs() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Copy decoded RGBA into a Slint image.
|
||||
///
|
||||
/// This is a CPU copy, which is acceptable here and not in the develop path:
|
||||
@@ -3318,7 +3307,7 @@ pub fn wire<F>(
|
||||
|
||||
let zoom = *ctl.timeline_zoom.borrow();
|
||||
let centre = *ctl.timeline_centre.borrow();
|
||||
if let Some(full) = catalog_span(catalog) {
|
||||
if let Some(full) = catalog_span(catalog, &ctl) {
|
||||
let (from, to) = zoomed_span(full, zoom, centre);
|
||||
w.set_library_current_bucket(when as i32);
|
||||
w.set_library_current_fraction(
|
||||
@@ -3362,7 +3351,7 @@ pub fn wire<F>(
|
||||
let Some(catalog) = borrow.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(full) = catalog_span(catalog) else {
|
||||
let Some(full) = catalog_span(catalog, &ctl) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -3410,7 +3399,7 @@ pub fn wire<F>(
|
||||
let Some(catalog) = borrow.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(full) = catalog_span(catalog) else {
|
||||
let Some(full) = catalog_span(catalog, &ctl) else {
|
||||
return;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user