From eddaa44cd3d0a57d99ae36a7a1e7fd8d2cb146f0 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 20 Sep 2026 00:12:34 +0200 Subject: [PATCH] Announce a month at the next row it opens, not only if it begins one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A heading was drawn only on a cell that both began a month and began a row, so at seven columns most months were never named, and the one heading on screen — always on the window's first cell — was wrong about every row below it. Worse, two headings drawn on the same cell overprinted each other. Now a row carries a heading whenever its first cell's month is not the one last announced: a month starting mid-row is named on the next row it opens, one row late and right about everything under it. --- ui/dr-ui/src/library_ui.rs | 120 +++++++++++++++++++++++++++---------- 1 file changed, 89 insertions(+), 31 deletions(-) diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index d9b6bbd..42412a8 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -2824,38 +2824,17 @@ fn load_window(window: &AppWindow, ctl: &Rc) { // Month headings. The grid is ordered by capture time, so without these a // wall of thumbnails gives no sense of *when* you are looking — the // sidebar says it, but only if you consult it. - // - // Marked on the cell that both begins a month and begins a row: a heading - // stranded mid-row would label the cells to its left, which belong to the - // previous month. let columns = window.get_library_columns().max(1) as usize; - let mut previous_month: Option<(i64, i64)> = None; - let headings: Vec = cells - .iter() - .enumerate() - .map(|(i, c)| { - let Some(t) = c.captured_at else { - return String::new(); - }; - let (y, m, _, _) = civil_from_unix(t); - let is_new = previous_month != Some((y, m)); - previous_month = Some((y, m)); - - // A heading is drawn above its row, so it can only sit on a cell - // that begins one — a heading stranded mid-row would appear to - // label the cells to its left, which belong to the month before. - // - // The first cell of the window always carries one, whichever - // column it lands in: a scrolled window would otherwise show no - // date at all until the next month began. - let begins_row = (i + offset) % columns == 0; - if i == 0 || (is_new && begins_row) { - format!("{} {y}", month_name(m)) - } else { - String::new() - } - }) - .collect(); + let headings = period_headings( + cells.iter().map(|c| { + c.captured_at.map(|t| { + let (y, m, _, _) = civil_from_unix(t); + (y, m) + }) + }), + offset, + columns, + ); // What the outgoing model is still holding — see [`hold_thumbnails`]. let held = { @@ -7151,6 +7130,46 @@ fn stop(slot: &RefCell>) { } } +/// The heading each cell of a window carries: a month name on some, nothing +/// on most. +/// +/// A heading is drawn above its row, so it can only sit on a cell that +/// begins one — stranded mid-row it would appear to label the cells to its +/// left, which belong to the month before. So a row carries a heading when +/// its first cell's month is not the one last announced. A month that begins +/// mid-row is therefore announced at the *next* row it opens, which is one +/// row late and still right about every cell under it. It used to be +/// announced only if it happened to begin a row, which at seven columns +/// meant most months were never named at all and the one heading on screen +/// was wrong about everything below its first row. +/// +/// The first cell of the window always carries one, whichever column it +/// lands in: a scrolled window would otherwise show no date at all until +/// the next month began. +fn period_headings( + months: impl Iterator>, + offset: usize, + columns: usize, +) -> Vec { + let columns = columns.max(1); + let mut announced: Option<(i64, i64)> = None; + months + .enumerate() + .map(|(i, month)| { + let Some((y, m)) = month else { + return String::new(); + }; + let begins_row = (i + offset).is_multiple_of(columns); + if i == 0 || (begins_row && announced != Some((y, m))) { + announced = Some((y, m)); + format!("{} {y}", month_name(m)) + } else { + String::new() + } + }) + .collect() +} + /// The line in the library header that says what is being catalogued. /// /// For an account with a user, the user and the chosen subtree, or the @@ -7180,6 +7199,45 @@ fn library_root_label(account: &Account) -> String { mod tests { use super::*; + #[test] + fn a_month_that_begins_mid_row_is_still_announced() { + // Four columns; August starts in the second column of the second + // row and October in the third of the third. Neither begins a row, + // and both must still be named — on the next row each opens. + let mar = Some((2024, 3)); + let aug = Some((2024, 8)); + let oct = Some((2025, 10)); + let months = [ + mar, mar, mar, mar, // + mar, aug, aug, aug, // + aug, aug, oct, oct, // + oct, + ]; + let h = period_headings(months.into_iter(), 0, 4); + assert_eq!(h[0], "March 2024"); + assert_eq!(h[4], "", "still March at the top of row two"); + assert_eq!(h[5], "", "never mid-row"); + assert_eq!(h[8], "August 2024"); + assert_eq!(h[12], "October 2025"); + assert_eq!(h.iter().filter(|s| !s.is_empty()).count(), 3); + } + + #[test] + fn a_scrolled_window_names_its_first_cell_wherever_it_lands() { + let jan = Some((2026, 1)); + let feb = Some((2026, 2)); + // Offset 2 into a 4-column grid: the window's first cell is in the + // third column, and the row after it begins a new month. + let h = period_headings([jan, jan, feb, feb, feb].into_iter(), 2, 4); + assert_eq!(h[0], "January 2026"); + assert_eq!(h[2], "February 2026", "cell 2 begins a row at offset 2"); + assert_eq!(h[3], ""); + // Undated cells carry nothing and announce nothing. + let h = period_headings([None, jan, None, None, jan].into_iter(), 0, 4); + assert_eq!(h[0], ""); + assert_eq!(h[4], "January 2026"); + } + #[test] fn the_header_names_a_folder_library_by_its_folder() { let mut folder = Account::new("folder", "/var/tmp/dr-demo/library");