Move the filter bar and the timeline onto the library's Slint global

The date-range fields and their band on the capture-time axis, the
timeline's bars, labels, scrub/pinch/pan/zoom callbacks and its
sweep/current-bucket/anchored state, and the filter bar's people chips,
mode, eyes-open toggle and gesture reference move from AppWindow onto the
`Library` global. Rust reaches them through window.global::<Library>()
rather than window.set_/get_/on_ on the root, as the earlier commits on
this branch did for the grid's cells, selection, ratings and keywords.
This commit is contained in:
2026-09-20 22:07:12 +02:00
parent 00e2fe6aaf
commit 6bf67cefc4
11 changed files with 488 additions and 424 deletions
+1 -1
View File
@@ -112,7 +112,7 @@ fn wire_selection(
// Where the window starts, so the press is recorded as the ordinal
// it is rather than as a row that stops meaning this photograph on
// the next scroll.
let offset = w.get_library_offset().max(0) as usize;
let offset = w.global::<Library>().get_library_offset().max(0) as usize;
select_row(&w, &ctl, &ids, offset, row as usize, ctrl_held, shift_held);
// TRACES: FR-UI-2 | FR-UI-4 | FR-CAT-7
+202 -173
View File
@@ -107,77 +107,87 @@ pub(super) fn wire_filter_ratings_and_people(window: &AppWindow, ctl: &Rc<Librar
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_filter_person_cleared(move |id| {
let Some(w) = weak.upgrade() else { return };
ctl.filter
.borrow_mut()
.people
.retain(|p| *p != id.max(0) as u64);
drop_eyes_open_without_people(&w, &ctl);
push_people_chips(&w, &ctl);
refilter(&w, &ctl);
});
window
.global::<Library>()
.on_library_filter_person_cleared(move |id| {
let Some(w) = weak.upgrade() else { return };
ctl.filter
.borrow_mut()
.people
.retain(|p| *p != id.max(0) as u64);
drop_eyes_open_without_people(&w, &ctl);
push_people_chips(&w, &ctl);
refilter(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_people_listed(move || {
let Some(w) = weak.upgrade() else { return };
push_people_roster(&w, &ctl);
});
window
.global::<Library>()
.on_library_people_listed(move || {
let Some(w) = weak.upgrade() else { return };
push_people_roster(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_filter_person_toggled(move |id| {
let Some(w) = weak.upgrade() else { return };
let person = id.max(0) as u64;
{
let mut f = ctl.filter.borrow_mut();
if let Some(at) = f.people.iter().position(|p| *p == person) {
f.people.remove(at);
} else {
f.people.push(person);
window
.global::<Library>()
.on_library_filter_person_toggled(move |id| {
let Some(w) = weak.upgrade() else { return };
let person = id.max(0) as u64;
{
let mut f = ctl.filter.borrow_mut();
if let Some(at) = f.people.iter().position(|p| *p == person) {
f.people.remove(at);
} else {
f.people.push(person);
}
}
}
drop_eyes_open_without_people(&w, &ctl);
push_people_chips(&w, &ctl);
refilter(&w, &ctl);
});
drop_eyes_open_without_people(&w, &ctl);
push_people_chips(&w, &ctl);
refilter(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_filter_eyes_open_toggled(move || {
let Some(w) = weak.upgrade() else { return };
let on = {
let mut f = ctl.filter.borrow_mut();
f.eyes_open = !f.eyes_open;
f.eyes_open
};
w.set_library_filter_eyes_open(on);
refilter(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_filter_people_mode_toggled(move || {
let Some(w) = weak.upgrade() else { return };
{
let mut f = ctl.filter.borrow_mut();
f.people_mode = match f.people_mode {
library::PeopleMode::Any => library::PeopleMode::All,
library::PeopleMode::All => library::PeopleMode::Any,
window
.global::<Library>()
.on_library_filter_eyes_open_toggled(move || {
let Some(w) = weak.upgrade() else { return };
let on = {
let mut f = ctl.filter.borrow_mut();
f.eyes_open = !f.eyes_open;
f.eyes_open
};
}
push_people_chips(&w, &ctl);
refilter(&w, &ctl);
});
w.global::<Library>().set_library_filter_eyes_open(on);
refilter(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Library>()
.on_library_filter_people_mode_toggled(move || {
let Some(w) = weak.upgrade() else { return };
{
let mut f = ctl.filter.borrow_mut();
f.people_mode = match f.people_mode {
library::PeopleMode::Any => library::PeopleMode::All,
library::PeopleMode::All => library::PeopleMode::Any,
};
}
push_people_chips(&w, &ctl);
refilter(&w, &ctl);
});
}
{
@@ -227,57 +237,59 @@ pub(super) fn wire_filter_dates(window: &AppWindow, ctl: &Rc<LibraryController>)
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_toggle_date_range(move || {
let Some(w) = weak.upgrade() else { return };
// Whether the *controls* are showing, which is not the same as
// whether a range is set — and reading it from the filter was why
// pressing this did nothing. The fields are how a range gets set,
// so requiring one before they appear is a door locked from the
// inside.
let on = !w.get_library_range_active();
if on {
// Seeded from the span the axis is drawn over, which is what
// the user is looking at when they ask for "this range".
//
// Only a seed. If there is no catalog yet, or nothing in it
// carries a capture date — dates are read from EXIF as
// thumbnails load, so a freshly opened library has none — the
// fields simply open empty and wait to be typed into. This
// used to `return` in both cases, which meant the button
// silently did nothing on exactly the libraries where a person
// is most likely to be looking for a date.
let seeded = {
let borrow = ctl.catalog.borrow();
borrow
.as_ref()
.and_then(|catalog| catalog_span(catalog, &ctl))
.map(|span| {
zoomed_span(
span,
*ctl.timeline_zoom.borrow(),
*ctl.timeline_centre.borrow(),
)
})
};
if let Some(span) = seeded {
// Through the same days-inclusive conversion a drag uses,
// so a seeded range and a dragged one are the same kind of
// thing — and so `show_range` reads this one back out as
// the two days it covers.
let (from, to) = dragged_range(span, 0.0, 1.0);
window
.global::<Library>()
.on_library_toggle_date_range(move || {
let Some(w) = weak.upgrade() else { return };
// Whether the *controls* are showing, which is not the same as
// whether a range is set — and reading it from the filter was why
// pressing this did nothing. The fields are how a range gets set,
// so requiring one before they appear is a door locked from the
// inside.
let on = !w.global::<Library>().get_library_range_active();
if on {
// Seeded from the span the axis is drawn over, which is what
// the user is looking at when they ask for "this range".
//
// Only a seed. If there is no catalog yet, or nothing in it
// carries a capture date — dates are read from EXIF as
// thumbnails load, so a freshly opened library has none — the
// fields simply open empty and wait to be typed into. This
// used to `return` in both cases, which meant the button
// silently did nothing on exactly the libraries where a person
// is most likely to be looking for a date.
let seeded = {
let borrow = ctl.catalog.borrow();
borrow
.as_ref()
.and_then(|catalog| catalog_span(catalog, &ctl))
.map(|span| {
zoomed_span(
span,
*ctl.timeline_zoom.borrow(),
*ctl.timeline_centre.borrow(),
)
})
};
if let Some(span) = seeded {
// Through the same days-inclusive conversion a drag uses,
// so a seeded range and a dragged one are the same kind of
// thing — and so `show_range` reads this one back out as
// the two days it covers.
let (from, to) = dragged_range(span, 0.0, 1.0);
let mut f = ctl.filter.borrow_mut();
f.captured_from = Some(from);
f.captured_to = Some(to);
}
} else {
let mut f = ctl.filter.borrow_mut();
f.captured_from = Some(from);
f.captured_to = Some(to);
f.captured_from = None;
f.captured_to = None;
}
} else {
let mut f = ctl.filter.borrow_mut();
f.captured_from = None;
f.captured_to = None;
}
w.set_library_range_active(on);
show_range(&w, &ctl);
refilter(&w, &ctl);
});
w.global::<Library>().set_library_range_active(on);
show_range(&w, &ctl);
refilter(&w, &ctl);
});
}
// TRACES: FR-CAT-6 | FR-UI-2
@@ -298,39 +310,41 @@ pub(super) fn wire_filter_dates(window: &AppWindow, ctl: &Rc<LibraryController>)
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_timeline_range_changed(move |a, b| {
let Some(w) = weak.upgrade() else { return };
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let Some(full) = catalog_span(catalog, &ctl) else {
return;
};
let span = zoomed_span(
full,
*ctl.timeline_zoom.borrow(),
*ctl.timeline_centre.borrow(),
);
drop(borrow);
window
.global::<Library>()
.on_library_timeline_range_changed(move |a, b| {
let Some(w) = weak.upgrade() else { return };
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let Some(full) = catalog_span(catalog, &ctl) else {
return;
};
let span = zoomed_span(
full,
*ctl.timeline_zoom.borrow(),
*ctl.timeline_centre.borrow(),
);
drop(borrow);
let (from, to) = dragged_range(span, a, b);
{
let mut f = ctl.filter.borrow_mut();
f.captured_from = Some(from);
f.captured_to = Some(to);
}
let (from, to) = dragged_range(span, a, b);
{
let mut f = ctl.filter.borrow_mut();
f.captured_from = Some(from);
f.captured_to = Some(to);
}
// A dragged range cannot be mistyped, so any standing complaint
// about the fields is now about a range that no longer exists.
w.set_library_range_invalid(false);
w.set_library_range_active(true);
show_range(&w, &ctl);
// Reloads the window, and `load_window` redraws the timeline —
// which is what puts the band back under the finger that dropped
// it, at the whole days it was snapped to.
refilter(&w, &ctl);
});
// A dragged range cannot be mistyped, so any standing complaint
// about the fields is now about a range that no longer exists.
w.global::<Library>().set_library_range_invalid(false);
w.global::<Library>().set_library_range_active(true);
show_range(&w, &ctl);
// Reloads the window, and `load_window` redraws the timeline —
// which is what puts the band back under the finger that dropped
// it, at the whole days it was snapped to.
refilter(&w, &ctl);
});
}
// TRACES: FR-CAT-6
@@ -343,39 +357,41 @@ pub(super) fn wire_filter_dates(window: &AppWindow, ctl: &Rc<LibraryController>)
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_range_edited(move |from, to| {
let Some(w) = weak.upgrade() else { return };
window
.global::<Library>()
.on_library_range_edited(move |from, to| {
let Some(w) = weak.upgrade() else { return };
// Both ends, or neither. A range with one end parsed and the other
// silently dropped is a filter nobody asked for, and the grid
// going empty is a poor way to find out a date was mistyped.
let (Some(from), Some(to)) = (
dr_types::parse_date(from.as_str()),
dr_types::parse_date(to.as_str()),
) else {
w.set_library_range_invalid(true);
return;
};
// Both ends, or neither. A range with one end parsed and the other
// silently dropped is a filter nobody asked for, and the grid
// going empty is a poor way to find out a date was mistyped.
let (Some(from), Some(to)) = (
dr_types::parse_date(from.as_str()),
dr_types::parse_date(to.as_str()),
) else {
w.global::<Library>().set_library_range_invalid(true);
return;
};
// Typed the other way round is a slip, not an error: the user has
// said which two days they mean and there is exactly one range
// between them.
let (from, to) = if from <= to { (from, to) } else { (to, from) };
// Typed the other way round is a slip, not an error: the user has
// said which two days they mean and there is exactly one range
// between them.
let (from, to) = if from <= to { (from, to) } else { (to, from) };
{
let mut f = ctl.filter.borrow_mut();
f.captured_from = Some(from);
// Inclusive of the last day. "To the 5th" means the whole of
// the 5th — `parse_date` returns its midnight, and a range
// ending there would exclude every photograph taken on the day
// the user named. The same rule `dragged_range` applies.
f.captured_to = Some(to + 86_400 - 1);
}
w.set_library_range_invalid(false);
w.set_library_range_active(true);
show_range(&w, &ctl);
refilter(&w, &ctl);
});
{
let mut f = ctl.filter.borrow_mut();
f.captured_from = Some(from);
// Inclusive of the last day. "To the 5th" means the whole of
// the 5th — `parse_date` returns its midnight, and a range
// ending there would exclude every photograph taken on the day
// the user named. The same rule `dragged_range` applies.
f.captured_to = Some(to + 86_400 - 1);
}
w.global::<Library>().set_library_range_invalid(false);
w.global::<Library>().set_library_range_active(true);
show_range(&w, &ctl);
refilter(&w, &ctl);
});
}
}
@@ -511,7 +527,9 @@ fn drop_eyes_open_without_people(window: &AppWindow, ctl: &Rc<LibraryController>
let mut f = ctl.filter.borrow_mut();
if f.people.is_empty() && f.eyes_open {
f.eyes_open = false;
window.set_library_filter_eyes_open(false);
window
.global::<Library>()
.set_library_filter_eyes_open(false);
}
}
@@ -519,6 +537,7 @@ pub(super) fn push_people_chips(window: &AppWindow, ctl: &Rc<LibraryController>)
let ids = ctl.filter.borrow().people.clone();
if ids.is_empty() {
window
.global::<Library>()
.set_library_filter_people(slint::ModelRc::new(slint::VecModel::from(
Vec::<PersonChip>::new(),
)));
@@ -563,11 +582,15 @@ pub(super) fn push_people_chips(window: &AppWindow, ctl: &Rc<LibraryController>)
})
.collect();
window.set_library_filter_people(slint::ModelRc::new(slint::VecModel::from(chips)));
window.set_library_filter_people_all(matches!(
ctl.filter.borrow().people_mode,
library::PeopleMode::All
));
window
.global::<Library>()
.set_library_filter_people(slint::ModelRc::new(slint::VecModel::from(chips)));
window
.global::<Library>()
.set_library_filter_people_all(matches!(
ctl.filter.borrow().people_mode,
library::PeopleMode::All
));
push_people_roster(window, ctl);
}
@@ -632,7 +655,9 @@ fn push_people_roster(window: &AppWindow, ctl: &Rc<LibraryController>) {
rows.sort_by_key(|(unnamed, faces, _)| (*unnamed, -faces));
let rows: Vec<PersonChip> = rows.into_iter().map(|(_, _, chip)| chip).collect();
window.set_library_people(slint::ModelRc::new(slint::VecModel::from(rows)));
window
.global::<Library>()
.set_library_people(slint::ModelRc::new(slint::VecModel::from(rows)));
}
pub(super) fn refilter(window: &AppWindow, ctl: &Rc<LibraryController>) {
@@ -698,10 +723,14 @@ fn show_range(window: &AppWindow, ctl: &Rc<LibraryController>) {
let text = |t: Option<i64>| -> slint::SharedString {
t.map(dr_types::format_date).unwrap_or_default().into()
};
window.set_library_range_from(text(f.captured_from));
window
.global::<Library>()
.set_library_range_from(text(f.captured_from));
// The stored end is the last second of the closing day; naming that day is
// what the user typed and what they should read back.
window.set_library_range_to(text(f.captured_to.map(|t| t - 86_400 + 1)));
window
.global::<Library>()
.set_library_range_to(text(f.captured_to.map(|t| t - 86_400 + 1)));
}
#[cfg(test)]
+18 -15
View File
@@ -184,18 +184,20 @@ pub fn wire<F>(
// table is a compiled-in constant, so there is nothing to be fresh about
// and nothing to recompute — a callback to fill it would only be a way for
// it to be empty the first time the sheet opens.
window.set_library_gestures(slint::ModelRc::new(slint::VecModel::from(
crate::gestures::rows()
.into_iter()
.map(|r| GestureRow {
heading: r.heading.into(),
title: r.title.into(),
touch: r.touch.into(),
pointer: r.pointer.into(),
keys: r.keys.into(),
})
.collect::<Vec<_>>(),
)));
window
.global::<Library>()
.set_library_gestures(slint::ModelRc::new(slint::VecModel::from(
crate::gestures::rows()
.into_iter()
.map(|r| GestureRow {
heading: r.heading.into(),
title: r.title.into(),
touch: r.touch.into(),
pointer: r.pointer.into(),
keys: r.keys.into(),
})
.collect::<Vec<_>>(),
)));
// Shared rather than moved: a click and `Return` both open an image, and
// they are two callbacks.
@@ -621,12 +623,13 @@ fn wire_grid_sync_and_load(window: &AppWindow, ctl: &Rc<LibraryController>) {
let centre = *ctl.timeline_centre.borrow();
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(
w.global::<Library>()
.set_library_current_bucket(when as i32);
w.global::<Library>().set_library_current_fraction(
((when - from) as f64 / (to - from).max(1) as f64)
.clamp(0.0, 1.0) as f32,
);
w.set_library_timeline_anchored(true);
w.global::<Library>().set_library_timeline_anchored(true);
}
}
}
+3 -1
View File
@@ -85,7 +85,9 @@ pub fn open(
window.set_library_status("Starting…".into());
// Always visible: two folders one letter apart are easy to confuse, and a
// scan of the wrong one is indistinguishable from a broken scan.
window.set_library_root_label(library_root_label(&conn.account).into());
window
.global::<Library>()
.set_library_root_label(library_root_label(&conn.account).into());
// An empty filter would walk the whole tree and match nothing, which looks
// exactly like a broken scan. Say so instead.
+10 -7
View File
@@ -12,7 +12,7 @@ use std::rc::Rc;
use slint::ComponentHandle;
use crate::library;
use crate::AppWindow;
use crate::{AppWindow, Library};
use super::controller::{stop, LibraryController};
use super::open::schedule_reload;
@@ -302,7 +302,7 @@ pub(super) fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
w.set_library_sweep_total(0);
w.global::<Library>().set_library_sweep_total(0);
job.fail("stopped without finishing");
stop(&ctl_cb.sweep_timer);
return;
@@ -311,13 +311,16 @@ pub(super) fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
match msg {
library::SweepMessage::Total(n) => {
w.set_library_sweep_total(n as i32);
w.set_library_sweep_done(0);
w.global::<Library>().set_library_sweep_total(n as i32);
w.global::<Library>().set_library_sweep_done(0);
job.total(n);
}
library::SweepMessage::Progress { done, dated } => {
w.set_library_sweep_done(done as i32);
job.progress(done, w.get_library_sweep_total() as usize);
w.global::<Library>().set_library_sweep_done(done as i32);
job.progress(
done,
w.global::<Library>().get_library_sweep_total() as usize,
);
// Rebuild as it goes: the histogram growing while the
// sweep runs is the visible sign it is working.
if dated > 0 {
@@ -330,7 +333,7 @@ pub(super) fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
library::SweepMessage::Finished { dated } => {
log::info!("sweep finished: {dated} dated");
job.finish(format!("{dated} dated"));
w.set_library_sweep_total(0);
w.global::<Library>().set_library_sweep_total(0);
{
let borrow = ctl_cb.catalog.borrow();
if let Some(catalog) = borrow.as_ref() {
+102 -75
View File
@@ -95,8 +95,12 @@ pub(super) fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc<L
None => {
// No dated images yet. An empty histogram is honest — EXIF is read
// as thumbnails load, so this populates as the user browses.
window.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(vec![])));
window.set_library_timeline_label(slint::SharedString::new());
window
.global::<Library>()
.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(vec![])));
window
.global::<Library>()
.set_library_timeline_label(slint::SharedString::new());
return;
}
};
@@ -206,14 +210,27 @@ pub(super) fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc<L
buckets.get(i).map(|b| b.start)
})
.unwrap_or(0);
window.set_library_current_bucket(lit as i32);
window.set_library_current_fraction(fraction);
window.set_library_range_from_fraction(band_from);
window.set_library_range_to_fraction(band_to);
window.set_library_timeline_anchored(current.is_some());
window
.global::<Library>()
.set_library_current_bucket(lit as i32);
window
.global::<Library>()
.set_library_current_fraction(fraction);
window
.global::<Library>()
.set_library_range_from_fraction(band_from);
window
.global::<Library>()
.set_library_range_to_fraction(band_to);
window
.global::<Library>()
.set_library_timeline_anchored(current.is_some());
window
.global::<Library>()
.set_library_timeline_label(format!("{} – {}", format_date(from), format_date(to)).into());
window.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(bars)));
window
.global::<Library>()
.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(bars)));
}
/// Whether this bucket size is fine enough for month labels to mean anything.
@@ -904,7 +921,9 @@ fn push_filter(window: &AppWindow, ctl: &Rc<LibraryController>) {
.global::<Library>()
.set_library_local_only(filter.local_only);
ctl.local_only.set(filter.local_only);
window.set_library_filter_eyes_open(filter.eyes_open);
window
.global::<Library>()
.set_library_filter_eyes_open(filter.eyes_open);
push_people_chips(window, ctl);
}
@@ -1019,30 +1038,32 @@ pub(super) fn wire_timeline(window: &AppWindow, ctl: &Rc<LibraryController>) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_timeline_pan(move |fraction| {
let Some(w) = weak.upgrade() else { return };
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let Some(full) = catalog_span(catalog, &ctl) else {
return;
};
window
.global::<Library>()
.on_library_timeline_pan(move |fraction| {
let Some(w) = weak.upgrade() else { return };
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let Some(full) = catalog_span(catalog, &ctl) else {
return;
};
// A fraction of the *visible* span, so dragging half the axis
// moves half a span's worth of time whatever the zoom — and a
// small movement produces a small shift rather than nothing.
let zoom = *ctl.timeline_zoom.borrow();
let (from, to) = zoomed_span(full, zoom, *ctl.timeline_centre.borrow());
let shift = ((to - from) as f64 * fraction as f64) as i64;
if shift == 0 {
return;
}
// A fraction of the *visible* span, so dragging half the axis
// moves half a span's worth of time whatever the zoom — and a
// small movement produces a small shift rather than nothing.
let zoom = *ctl.timeline_zoom.borrow();
let (from, to) = zoomed_span(full, zoom, *ctl.timeline_centre.borrow());
let shift = ((to - from) as f64 * fraction as f64) as i64;
if shift == 0 {
return;
}
let centre = ctl.timeline_centre.borrow().unwrap_or((from + to) / 2) + shift;
*ctl.timeline_centre.borrow_mut() = Some(centre.clamp(full.0, full.1));
refresh_timeline(&w, catalog, &ctl);
});
let centre = ctl.timeline_centre.borrow().unwrap_or((from + to) / 2) + shift;
*ctl.timeline_centre.borrow_mut() = Some(centre.clamp(full.0, full.1));
refresh_timeline(&w, catalog, &ctl);
});
}
// The wheel zooms the axis: a sidebar is a scale, not a list.
@@ -1052,11 +1073,13 @@ pub(super) fn wire_timeline(window: &AppWindow, ctl: &Rc<LibraryController>) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_timeline_zoom(move |delta| {
if let Some(w) = weak.upgrade() {
apply_zoom(&w, &ctl, delta);
}
});
window
.global::<Library>()
.on_library_timeline_zoom(move |delta| {
if let Some(w) = weak.upgrade() {
apply_zoom(&w, &ctl, delta);
}
});
}
// Dragging the histogram moves the grid through time.
@@ -1067,25 +1090,27 @@ pub(super) fn wire_timeline(window: &AppWindow, ctl: &Rc<LibraryController>) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_scrub_fraction(move |f| {
let Some(w) = weak.upgrade() else { return };
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let Some(full) = catalog_span(catalog, &ctl) else {
return;
};
window
.global::<Library>()
.on_library_scrub_fraction(move |f| {
let Some(w) = weak.upgrade() else { return };
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let Some(full) = catalog_span(catalog, &ctl) else {
return;
};
let (from, to) = zoomed_span(
full,
*ctl.timeline_zoom.borrow(),
*ctl.timeline_centre.borrow(),
);
let when = instant_at((from, to), f);
drop(borrow);
scrub_to(&w, &ctl, when);
});
let (from, to) = zoomed_span(
full,
*ctl.timeline_zoom.borrow(),
*ctl.timeline_centre.borrow(),
);
let when = instant_at((from, to), f);
drop(borrow);
scrub_to(&w, &ctl, when);
});
}
// Pinch, for tablet: no wheel there, so this is the only way to reach the
@@ -1097,30 +1122,32 @@ pub(super) fn wire_timeline(window: &AppWindow, ctl: &Rc<LibraryController>) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_timeline_pinch(move |ratio| {
let Some(w) = weak.upgrade() else { return };
if !(0.01..=100.0).contains(&ratio) {
return;
}
window
.global::<Library>()
.on_library_timeline_pinch(move |ratio| {
let Some(w) = weak.upgrade() else { return };
if !(0.01..=100.0).contains(&ratio) {
return;
}
let mut accum = ctl.pinch_accum.borrow_mut();
*accum *= ratio;
let mut accum = ctl.pinch_accum.borrow_mut();
*accum *= ratio;
// Whole doublings out of the accumulated ratio, remainder carried.
//
// `log2` rather than repeated halving: one update can carry a
// large ratio — a fast spread, or a trackpad reporting coarsely —
// and stepping once per update would turn an 8× pinch into a
// single level instead of three.
let steps = accum.log2().trunc() as i32;
if steps == 0 {
return;
}
*accum /= (2.0f32).powi(steps);
drop(accum);
// Whole doublings out of the accumulated ratio, remainder carried.
//
// `log2` rather than repeated halving: one update can carry a
// large ratio — a fast spread, or a trackpad reporting coarsely —
// and stepping once per update would turn an 8× pinch into a
// single level instead of three.
let steps = accum.log2().trunc() as i32;
if steps == 0 {
return;
}
*accum /= (2.0f32).powi(steps);
drop(accum);
apply_zoom(&w, &ctl, steps);
});
apply_zoom(&w, &ctl, steps);
});
}
}
+2 -2
View File
@@ -254,7 +254,7 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
let window_size = *ctl.window.borrow();
let offset = (*ctl.offset.borrow()).min(total.saturating_sub(window_size.min(total)));
*ctl.offset.borrow_mut() = offset;
window.set_library_offset(offset as i32);
window.global::<Library>().set_library_offset(offset as i32);
// The axis the scrubber draws. A `MIN`/`MAX` and a `GROUP BY` over the
// whole scope — 5.7 ms on 24,000 images — and the bars do not change as the
@@ -308,7 +308,7 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
Some((lo, hi)) => (lo.min(t), hi.max(t)),
})
});
window.set_library_window_label(
window.global::<Library>().set_library_window_label(
match span {
Some((lo, hi)) => format!("{} – {}", format_date(lo), format_date(hi)),
// Nothing here has a date yet: EXIF is read as thumbnails load, so
+34 -82
View File
@@ -337,20 +337,6 @@ export component AppWindow inherits Window {
in property <bool> library-offline: false;
in property <string> library-offline-reason: "";
in property <string> library-offline-since: "";
/// Whether the grid is narrowed to the timeline's visible span.
in-out property <bool> library-range-active: false;
callback library-toggle-date-range();
/// TRACES: FR-CAT-6
in property <string> library-range-from;
in property <string> library-range-to;
in property <bool> library-range-invalid: false;
callback library-range-edited(string, string);
/// The same range as a band on the capture-time axis, and the ends of it
/// dragged. Fractions of the span the axis is drawn over — Rust owns the
/// conversion both ways, so the band lands where the finger left it.
in property <float> library-range-from-fraction: -1;
in property <float> library-range-to-fraction: -1;
callback library-timeline-range-changed(float, float);
callback library-retry-connection();
// --- pinning a collection offline (FR-NC-6a) ---
@@ -388,23 +374,6 @@ export component AppWindow inherits Window {
callback recovery-rebuild();
callback recovery-dismiss();
in property <string> library-root-label: "";
in-out property <[TimelineBar]> library-timeline;
in property <string> library-timeline-label: "";
in property <string> library-window-label: "";
in property <int> library-offset: 0;
in property <int> library-sweep-done: 0;
in property <int> library-sweep-total: 0;
in property <int> library-current-bucket: 0;
in property <float> library-current-fraction: -1;
in property <bool> library-timeline-anchored: false;
callback library-scrub-fraction(float);
callback library-timeline-pinch(float);
callback library-timeline-pan(float);
callback library-timeline-zoom(int);
in property <bool> library-syncing: false;
/// TRACES: FR-CAT-3 | FR-NC-3
/// The whole-library thumbnail pass, launched from the settings page.
@@ -511,23 +480,6 @@ export component AppWindow inherits Window {
/// Whose photographs the grid is narrowed to, one entry each.
in property <[PersonChip]> library-filter-people;
/// Whether those people are an intersection rather than a union.
in property <bool> library-filter-people-all: false;
callback library-filter-person-cleared(int);
callback library-filter-people-mode-toggled();
/// TRACES: FR-CULL-8a | FR-CULL-13
/// Whether the grid is narrowed to photographs with nobody blinking.
in property <bool> library-filter-eyes-open: false;
callback library-filter-eyes-open-toggled();
/// Everyone the library knows, for the filter bar's people tray.
/// TRACES: FR-UI-4
/// The gesture reference's rows, read from the generated table.
in property <[GestureRow]> library-gestures;
in property <[PersonChip]> library-people;
callback library-people-listed();
callback library-filter-person-toggled(int);
callback next-image();
@@ -1318,7 +1270,7 @@ in property <bool> panel-visible: true;
confirm-all() => { root.identity-confirm-all(); }
split-picked() => { root.identity-split-picked(); }
compact: !root.expanded;
photos-filtered: root.library-filter-people.length > 0;
photos-filtered: Library.library-filter-people.length > 0;
regrouping: root.identity-regrouping;
regroup-status: root.identity-regroup-status;
merge-probability: root.identity-merge-probability;
@@ -1471,8 +1423,8 @@ in property <bool> panel-visible: true;
local-only: Library.library-local-only;
local-count: Library.library-local-count;
toggle-local-only() => { Library.library-toggle-local-only(); }
range-active: root.library-range-active;
toggle-date-range() => { root.library-toggle-date-range(); }
range-active: Library.library-range-active;
toggle-date-range() => { Library.library-toggle-date-range(); }
clear-selection() => { Library.library-clear-selection(); }
select-all() => { Library.library-select-all(); }
reorderable: Library.library-reorderable;
@@ -1480,14 +1432,14 @@ in property <bool> panel-visible: true;
collection-from-selection(name) => {
Library.library-collection-from-selection(name);
}
range-from: root.library-range-from;
range-to: root.library-range-to;
range-invalid: root.library-range-invalid;
range-edited(a, b) => { root.library-range-edited(a, b); }
range-from-fraction: root.library-range-from-fraction;
range-to-fraction: root.library-range-to-fraction;
range-from: Library.library-range-from;
range-to: Library.library-range-to;
range-invalid: Library.library-range-invalid;
range-edited(a, b) => { Library.library-range-edited(a, b); }
range-from-fraction: Library.library-range-from-fraction;
range-to-fraction: Library.library-range-to-fraction;
timeline-range-changed(a, b) => {
root.library-timeline-range-changed(a, b);
Library.library-timeline-range-changed(a, b);
}
collections-visible: root.collections-visible;
@@ -1495,11 +1447,11 @@ in property <bool> panel-visible: true;
pin-done: root.library-pin-done;
pin-total: root.library-pin-total;
root-label: root.library-root-label;
timeline: root.library-timeline;
timeline-label: root.library-timeline-label;
window-label: root.library-window-label;
offset: root.library-offset;
root-label: Library.library-root-label;
timeline: Library.library-timeline;
timeline-label: Library.library-timeline-label;
window-label: Library.library-window-label;
offset: Library.library-offset;
selected-count: Library.library-selected-count;
scope-label: Collections.collection-scope-label;
settings-armed: Transfer.armed;
@@ -1524,17 +1476,17 @@ in property <bool> panel-visible: true;
// TRACES: FR-MRG-1
merge-selection => { root.library-merge-selection(); }
sweep-done: root.library-sweep-done;
sweep-total: root.library-sweep-total;
sweep-done: Library.library-sweep-done;
sweep-total: Library.library-sweep-total;
current-bucket: root.library-current-bucket;
current-bucket-fraction: root.library-current-fraction;
timeline-anchored: root.library-timeline-anchored;
current-bucket: Library.library-current-bucket;
current-bucket-fraction: Library.library-current-fraction;
timeline-anchored: Library.library-timeline-anchored;
scrub-fraction(f) => { root.library-scrub-fraction(f); }
timeline-pinch(r) => { root.library-timeline-pinch(r); }
timeline-pan(d) => { root.library-timeline-pan(d); }
timeline-zoom(d) => { root.library-timeline-zoom(d); }
scrub-fraction(f) => { Library.library-scrub-fraction(f); }
timeline-pinch(r) => { Library.library-timeline-pinch(r); }
timeline-pan(d) => { Library.library-timeline-pan(d); }
timeline-zoom(d) => { Library.library-timeline-zoom(d); }
syncing: root.library-syncing;
requested-cell-size: Library.library-cell-size;
touched <=> Library.library-touched;
@@ -1590,11 +1542,11 @@ in property <bool> panel-visible: true;
}
open-membership() => { Library.library-open-membership(); }
filter-people: root.library-filter-people;
filter-people-all: root.library-filter-people-all;
filter-eyes-open: root.library-filter-eyes-open;
people: root.library-people;
gestures: root.library-gestures;
filter-people: Library.library-filter-people;
filter-people-all: Library.library-filter-people-all;
filter-eyes-open: Library.library-filter-eyes-open;
people: Library.library-people;
gestures: Library.library-gestures;
filter-min-rating: Library.library-filter-min-rating;
filter-unjudged: Library.library-filter-unjudged;
filter-flag: Library.library-filter-flag;
@@ -1629,11 +1581,11 @@ in property <bool> panel-visible: true;
Library.library-filter-unjudged-changed(on);
}
filter-flag-changed(f) => { Library.library-filter-flag-changed(f); }
filter-person-cleared(id) => { root.library-filter-person-cleared(id); }
filter-people-mode-toggled() => { root.library-filter-people-mode-toggled(); }
filter-eyes-open-toggled() => { root.library-filter-eyes-open-toggled(); }
people-listed() => { root.library-people-listed(); }
filter-person-toggled(id) => { root.library-filter-person-toggled(id); }
filter-person-cleared(id) => { Library.library-filter-person-cleared(id); }
filter-people-mode-toggled() => { Library.library-filter-people-mode-toggled(); }
filter-eyes-open-toggled() => { Library.library-filter-eyes-open-toggled(); }
people-listed() => { Library.library-people-listed(); }
filter-person-toggled(id) => { Library.library-filter-person-toggled(id); }
}
}
}
+48
View File
@@ -646,6 +646,54 @@ export global Library {
callback library-filter-min-rating-changed(int);
callback library-filter-unjudged-changed(bool);
callback library-filter-flag-changed(int);
/// Whether the grid is narrowed to the timeline's visible span.
in-out property <bool> library-range-active: false;
callback library-toggle-date-range();
/// TRACES: FR-CAT-6
in property <string> library-range-from;
in property <string> library-range-to;
in property <bool> library-range-invalid: false;
callback library-range-edited(string, string);
/// The same range as a band on the capture-time axis, and the ends of it
/// dragged. Fractions of the span the axis is drawn over — Rust owns the
/// conversion both ways, so the band lands where the finger left it.
in property <float> library-range-from-fraction: -1;
in property <float> library-range-to-fraction: -1;
callback library-timeline-range-changed(float, float);
in property <string> library-root-label: "";
in-out property <[TimelineBar]> library-timeline;
in property <string> library-timeline-label: "";
in property <string> library-window-label: "";
in property <int> library-offset: 0;
in property <int> library-sweep-done: 0;
in property <int> library-sweep-total: 0;
in property <int> library-current-bucket: 0;
in property <float> library-current-fraction: -1;
in property <bool> library-timeline-anchored: false;
callback library-scrub-fraction(float);
callback library-timeline-pinch(float);
callback library-timeline-pan(float);
callback library-timeline-zoom(int);
/// Whose photographs the grid is narrowed to, one entry each.
in property <[PersonChip]> library-filter-people;
/// Whether those people are an intersection rather than a union.
in property <bool> library-filter-people-all: false;
callback library-filter-person-cleared(int);
callback library-filter-people-mode-toggled();
/// TRACES: FR-CULL-8a | FR-CULL-13
/// Whether the grid is narrowed to photographs with nobody blinking.
in property <bool> library-filter-eyes-open: false;
callback library-filter-eyes-open-toggled();
/// Everyone the library knows, for the filter bar's people tray.
/// TRACES: FR-UI-4
/// The gesture reference's rows, read from the generated table.
in property <[GestureRow]> library-gestures;
in property <[PersonChip]> library-people;
callback library-people-listed();
callback library-filter-person-toggled(int);
}
// The photo roll: the grid's loaded window along the foot of the develop view.