mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 11:37:13 -06:00
LR-VIEW-PHOTOGRID: a mouse-wheel notch scrolls a third of the grid or filmstrip (#1481)
One notch scrolls the photo grid by a third of its visible height and the filmstrip by a third of its width, as in Lightroom Classic, instead of the fixed 100 points a side panel scrolls. It jumps at once instead of gliding. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b3d392b499
commit
8907f5f56f
@@ -2272,7 +2272,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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -123,12 +123,12 @@ 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` | |
|
||||
| LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen |
|
||||
| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid |
|
||||
| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid |
|
||||
| LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) |
|
||||
| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans |
|
||||
| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it |
|
||||
|
||||
Reference in New Issue
Block a user