diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 508720a6e..8c4ee5d02 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -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() { diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index b15d24c11..1aaddbeea 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -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::(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::(key)); + } + if !ui.data(|d| d.get_temp::(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; diff --git a/crates/ui-egui/src/tests_scroll.rs b/crates/ui-egui/src/tests_scroll.rs index d04a818a4..dd999108a 100644 --- a/crates/ui-egui/src/tests_scroll.rs +++ b/crates/ui-egui/src/tests_scroll.rs @@ -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()); } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index e09db8810..b85c373d5 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -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; diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 12fa6b487..de3cc690f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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` | |