# Conflicts:
#	docs/parity-checklist.md
This commit is contained in:
Brandon Thomas
2026-10-14 22:33:07 -04:00
5 changed files with 109 additions and 16 deletions
+6
View File
@@ -2508,7 +2508,13 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
child.style_mut().always_scroll_the_only_direction = true;
// only a new active photo (or the strip appearing) scrolls it; see `grid::follow_active`
let follow = super::grid::follow_active(child.ctx(), egui::Id::new("film-follow-active"), active, app.session.source);
// a wheel notch scrolls a third of its width; see `grid::wheel_step`
register(ui.ctx(), "filmstrip", child.max_rect());
let wheel = super::grid::wheel_step(&child, egui::Id::new("film-wheel"), child.max_rect(), child.max_rect().width(), true);
egui::ScrollArea::horizontal().id_salt("filmstrip").auto_shrink([false, false]).show_viewport(&mut child, |ui, vp| {
if wheel != egui::Vec2::ZERO {
ui.scroll_with_delta_animation(wheel, egui::style::ScrollAnimation::none());
}
let (area, _) = ui.allocate_exact_size(vec2(ids.len() as f32 * cell_w, r.height() - 16.0), Sense::hover());
app.film_scroll = Some(vp.left());
for (i, id) in ids.iter().enumerate() {
+44
View File
@@ -236,7 +236,14 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
};
let mut visible_ids = HashSet::new();
let mut visited = 0u64;
// a wheel notch scrolls a third of the grid's height
let scroll_rect = ui.available_rect_before_wrap();
crate::widgets::register(ui.ctx(), "grid:scroll", scroll_rect);
let wheel = wheel_step(ui, egui::Id::new("grid-wheel"), scroll_rect, scroll_rect.height(), false);
egui::ScrollArea::vertical().id_salt("grid-scroll").auto_shrink([false, false]).show_viewport(ui, |ui, viewport| {
if wheel != egui::Vec2::ZERO {
ui.scroll_with_delta_animation(wheel, egui::style::ScrollAnimation::none());
}
let (area, _) = ui.allocate_exact_size(vec2(ui.available_width(), total_h), Sense::hover());
let origin = area.min;
app.grid_scroll = Some(viewport.top());
@@ -318,6 +325,43 @@ pub(crate) fn follow_changed(
was != active || where_was != source || drawn + 1 < pass
}
/// How far a mouse wheel scrolls a photo strip this frame (`key`: the grid, the filmstrip; `area`: its
/// rect on screen; `span`: its visible height, or width when `sideways`), for `Ui::scroll_with_delta_animation`
/// inside its `ScrollArea`. A notch scrolls [`crate::theme::WHEEL_STRIP_SHARE`] (a third) of `span`, as in
/// Lightroom (about two rows of a six-row grid), instead of the [`crate::theme::WHEEL_LINE_POINTS`] a side
/// panel scrolls. Not whole rows: the photos land somewhere else after each notch, so the move shows. It
/// jumps at once instead of gliding, so the photos stay sharp while scrolling; egui's glide of the notch is
/// swallowed until it ends. Trackpads report points and scroll as before. A vertical wheel scrolls a
/// `sideways` strip.
pub(crate) fn wheel_step(ui: &egui::Ui, key: egui::Id, area: Rect, span: f32, sideways: bool) -> egui::Vec2 {
if !ui.rect_contains_pointer(area) || !span.is_finite() {
ui.data_mut(|d| d.remove_temp::<bool>(key));
return egui::Vec2::ZERO;
}
let (notches, gliding) = ui.input(|i| {
let notches = i.events.iter().fold(egui::Vec2::ZERO, |sum, e| match e {
egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta, modifiers, .. }
if *modifiers == egui::Modifiers::NONE && delta.is_finite() =>
{
sum + *delta
}
_ => sum,
});
(notches, i.smooth_scroll_delta != egui::Vec2::ZERO)
});
if notches != egui::Vec2::ZERO {
ui.data_mut(|d| d.insert_temp(key, true));
} else if !gliding {
ui.data_mut(|d| d.remove_temp::<bool>(key));
}
if !ui.data(|d| d.get_temp::<bool>(key)).unwrap_or(false) {
return egui::Vec2::ZERO;
}
ui.ctx().input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO);
let step = span * crate::theme::WHEEL_STRIP_SHARE;
if sideways { egui::vec2((notches.x + notches.y) * step, 0.0) } else { egui::vec2(0.0, notches.y * step) }
}
/// Height of a date header row (points).
pub const HEADER_H: f32 = 40.0;
+54 -15
View File
@@ -209,24 +209,63 @@ fn filmstrip_shows_every_selected_photo() {
assert_eq!(fill(&mut h, second), active);
}
/// A mouse wheel notch (a line) scrolls `theme::WHEEL_LINE_POINTS`, not egui's default 40: long
/// panels took 50+ notches.
/// One notch of a mouse wheel (a line).
fn notch(h: &mut Headless, dy: f32) {
h.events.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: egui::vec2(0.0, dy),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
});
h.step();
}
/// A mouse wheel notch scrolls the grid a third of its height, as in Lightroom (two rows of six), not the
/// `theme::WHEEL_LINE_POINTS` a side panel scrolls: at big thumbnails that was a quarter of a row. Exactly
/// a third, not whole rows, so the photos don't land where the last ones were, and at once, without a
/// glide that blurs them. A trackpad still scrolls the points it reports.
#[test]
fn a_wheel_notch_scrolls_a_hundred_points() {
fn a_wheel_notch_scrolls_a_third_of_the_grid() {
let mut h = demo("photoGrid");
let canvas = h.app.canvas_rect.expect("grid canvas");
h.request("ui.move", json!({"x": canvas.center().x, "y": canvas.center().y}), T);
let view = widget(&h, "grid:scroll");
h.request("ui.move", json!({"x": view.center().x, "y": view.center().y}), T);
let y0 = grid_y(&h);
for _ in 0..3 {
h.events.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: egui::vec2(0.0, -1.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
});
h.step();
notch(&mut h, -1.0);
// at once, no glide: the frame after the notch draws it there
h.step();
h.step();
let moved = grid_y(&h) - y0;
assert!((moved - view.height() * crate::theme::WHEEL_STRIP_SHARE).abs() < 1.0, "a notch scrolled {moved} of {} points", view.height());
idle(&mut h, 120);
assert!((grid_y(&h) - y0 - moved).abs() < 0.5, "and no further: {} after the glide", grid_y(&h) - y0);
let y1 = grid_y(&h);
for _ in 0..2 {
notch(&mut h, -1.0);
}
idle(&mut h, 120);
let moved = grid_y(&h) - y0;
assert!(moved >= 3.0 * crate::theme::WHEEL_LINE_POINTS - 1.0, "three notches scrolled {moved} points");
let moved = grid_y(&h) - y1;
assert!((moved - 2.0 * view.height() * crate::theme::WHEEL_STRIP_SHARE).abs() < 1.0, "two notches scrolled {moved} of {} points", view.height());
// back up
let y2 = grid_y(&h);
notch(&mut h, 1.0);
idle(&mut h, 120);
assert!((y2 - grid_y(&h) - view.height() * crate::theme::WHEEL_STRIP_SHARE).abs() < 1.0, "a notch up scrolled back a third");
// a trackpad (points) scrolls what it reports
let y3 = grid_y(&h);
h.request("ui.scroll", json!({"dy": -60.0}), T);
idle(&mut h, 120);
assert!((grid_y(&h) - y3 - 60.0).abs() < 1.0, "a 60-point trackpad scroll moved {}", grid_y(&h) - y3);
}
/// The same in the filmstrip: a notch scrolls it sideways by a third of its width (about five photos).
#[test]
fn a_wheel_notch_scrolls_a_third_of_the_filmstrip() {
let mut h = demo("detail");
let strip = widget(&h, "filmstrip");
h.request("ui.move", json!({"x": strip.center().x, "y": strip.center().y}), T);
let x0 = film_x(&h);
notch(&mut h, -1.0);
idle(&mut h, 120);
let moved = film_x(&h) - x0;
assert!((moved - strip.width() * crate::theme::WHEEL_STRIP_SHARE).abs() < 1.0, "a notch scrolled {moved} of {} points", strip.width());
}
+4
View File
@@ -370,6 +370,10 @@ fn craft_font_name(f: &lightcraft_engine::CraftFont) -> String {
/// Points one mouse-wheel notch (a line) scrolls.
pub const WHEEL_LINE_POINTS: f32 = 100.0;
/// Share of its visible size one mouse-wheel notch scrolls the photo grid and the filmstrip (Lightroom
/// Classic: three notches move a photo from one edge to the other, at any window size).
pub const WHEEL_STRIP_SHARE: f32 = 1.0 / 3.0;
/// Zoom per wheel point (`exp(speed × points)`): egui's 1/200 at its stock 40-point notch, rescaled so
/// one notch still zooms the same amount now that it scrolls [`WHEEL_LINE_POINTS`].
pub const WHEEL_ZOOM_PER_POINT: f32 = 40.0 / 200.0 / WHEEL_LINE_POINTS;
+1 -1
View File
@@ -127,7 +127,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-VIEW-PHOTOGRID | Justified photo grid | P0 | ✅ | `cmd:view.photoGrid`, `cmd:library.groups`, `cmd:library.sort` (`group`), `crates/ui-egui/src/panels/grid.rs`, `crates/catalog/src/dates.rs` | grouped by capture / import / edit date (day headers “Wednesday, 30 September 2026 · 12 photos”, months or years when zoomed out, or a fixed day/month/year/none choice in the sort menu); sticky header; clicking a header selects its photos; stacks never split; scrolls to the active photo only when it changes (or the grid comes back on screen), never on its own |
| LR-VIEW-PHOTOGRID | Justified photo grid | P0 | ✅ | `cmd:view.photoGrid`, `cmd:library.groups`, `cmd:library.sort` (`group`), `crates/ui-egui/src/panels/grid.rs`, `crates/catalog/src/dates.rs` | grouped by capture / import / edit date (day headers “Wednesday, 30 September 2026 · 12 photos”, months or years when zoomed out, or a fixed day/month/year/none choice in the sort menu); sticky header; clicking a header selects its photos; stacks never split; scrolls to the active photo only when it changes (or the grid comes back on screen), never on its own; a mouse-wheel notch scrolls a third of the grid's height, in both grids, as in Lightroom Classic (`theme::WHEEL_STRIP_SHARE`; trackpads scroll the points they report) |
| LR-VIEW-SQUAREGRID | Square grid | P0 | ✅ | `cmd:view.squareGrid` | same date headers as the photo grid |
| LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | a “Rendering…” pill at the bottom of the photo (widget `loupe:rendering`; above the Navigator when that is in the way) once a render has been on its way for 0.5 s with nothing new on screen: over a stand-in (the raw's embedded JPEG, a thumbnail), the previous render after an edit, or, zoomed in, the magnified frame waiting for its window; Before views watch their own renders. Always on (Lightroom Classic makes it a View Options choice). When the render fails behind a stand-in, a caution pill says “This photo can't be opened” (“Original offline · no smart preview” when that is why) with the reason on hover and for screen readers (widget `loupe:renderFailed`; `ui.inspect` → `loupe.failed`) |
| LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | |