diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 00c483b9..667cb19e 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -111,9 +111,10 @@ impl Group { } } - /// Tabs that lay out their own scrolling list and footer (they fill the group). + /// Tabs that lay out their own scrolling list and footer, or size themselves to the group + /// (the Navigator's preview grows with it, as in Photoshop: #2726). pub fn scrolls_itself(self, tab: usize) -> bool { - matches!((self, tab), (Group::Layers, 0) | (Group::History, 0)) + matches!((self, tab), (Group::Layers, 0) | (Group::History, 0) | (Group::Navigator, 0)) } fn tab_mut(self, tabs: &mut DockTabs) -> &mut usize { @@ -481,6 +482,9 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod if t.pro && g == Group::Color && tab == 0 { ui.data_mut(|d| d.insert_temp(crate::panels::color_field_fill_id(), inner)); } + // No scroll fade: it darkens real colours, swatches and previews at the edge + // (#2726); the scroll bar already shows there is more. + ui.spacing_mut().scroll.fade.strength = 0.0; egui::ScrollArea::vertical() .id_salt(("dock-scroll", g.key(), tab)) .max_height(inner) diff --git a/crates/ui-egui/src/dock_tests.rs b/crates/ui-egui/src/dock_tests.rs index 94c33311..d1417aef 100644 --- a/crates/ui-egui/src/dock_tests.rs +++ b/crates/ui-egui/src/dock_tests.rs @@ -821,6 +821,43 @@ fn tab_hides_all_panels_and_shift_tab_only_the_dock() { assert!(serde_json::from_value::(v).unwrap().dock); } +/// #2726: the Navigator sizes its own preview to the group (no outer scroll area, so no scroll +/// fade over the thumbnail), keeping the document's aspect, with the zoom controls below it. +#[test] +fn navigator_preview_fills_its_group_and_keeps_aspect() { + assert!(Group::Navigator.scrolls_itself(0), "the Navigator tab lays itself out"); + assert!(!Group::Navigator.scrolls_itself(1) && !Group::Navigator.scrolls_itself(2), "Histogram and Info still scroll"); + for theme in [ThemeKind::ProMedium, ThemeKind::Studio] { + let mut sizes = Vec::new(); + for tall in [150.0, 260.0, 420.0] { + let mut h = Harness::builder().with_size(vec2(1200.0, 1000.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, theme); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.theme = theme; + app.ui.panels.navigator = true; + app.ui.dock.heights.insert(Group::Navigator, tall); + app.run("file.new", json!({"width": 200, "height": 300})).unwrap(); + app.sync_views(); + app + }); + h.run_steps(8); + let group = rect_of(&h, Group::Navigator); + let preview = crate::panels::last_navigator_preview(&h.ctx).unwrap_or_else(|| panic!("{theme:?} {tall}: no preview drawn")); + assert!((group.height() - tall).abs() < 1.0, "{theme:?}: group {group:?}"); + assert!(group.contains_rect(preview), "{theme:?} {tall}: preview {preview:?} outside {group:?}"); + assert!((preview.width() / preview.height() - 2.0 / 3.0).abs() < 0.02, "{theme:?} {tall}: aspect {preview:?}"); + // The zoom controls stay inside the group, below the preview. + let controls = crate::panels::last_navigator_controls(&h.ctx).unwrap_or_else(|| panic!("{theme:?} {tall}: no controls")); + assert!(controls.top() >= preview.bottom() && controls.bottom() <= group.bottom() + 0.5, "{theme:?} {tall}: {controls:?} {preview:?} {group:?}"); + sizes.push(preview); + } + // A portrait document grows with every extra point of height the group gets. + for pair in sizes.windows(2) { + assert!(pair[1].height() > pair[0].height() + 80.0, "{theme:?}: {sizes:?}"); + } + } +} + fn strip_tabs(h: &Harness<'static, PhotocraftApp>, g: Group) -> Vec<(usize, Rect)> { last_strips(&h.ctx).into_iter().find(|s| s.group == g).map(|s| s.tabs).unwrap_or_else(|| panic!("{g:?} strip not drawn")) } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 6b03ce7b..291c1601 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1645,15 +1645,18 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let Some(tex) = crate::canvas::navigator_texture(app, &ctx, idx) else { return }; let size = app.session.documents()[idx].doc.size; let avail = ui.available_width(); - let box_h = 150.0; + // The preview fills the group's height above the zoom controls (their height as measured + // last frame), so it grows when the group is made taller, as in Photoshop (#2726). + let controls_h = ui.data(|d| d.get_temp::(navigator_controls_id())).unwrap_or(48.0); + let left = ui.available_height() - controls_h; + let box_h = if left.is_finite() { left.max(40.0) } else { 150.0 }; let (frame, resp) = ui.allocate_exact_size(vec2(avail, box_h), Sense::click_and_drag()); ui.painter().rect_filled(frame, t.radius_sm, t.canvas); - let aspect = size.width as f32 / size.height.max(1) as f32; - let (w, h) = if aspect > avail / box_h { (avail - 16.0, (avail - 16.0) / aspect) } else { ((box_h - 16.0) * aspect, box_h - 16.0) }; - let rect = Rect::from_center_size(frame.center(), vec2(w, h)); + let rect = navigator_fit(frame, size.width, size.height); + ui.data_mut(|d| d.insert_temp(navigator_preview_id(), rect)); widgets::checker(ui.painter(), rect, 6.0); ui.painter().image(tex, rect, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); - let s = w / size.width as f32; + let s = rect.width() / size.width.max(1) as f32; if (resp.clicked() || resp.dragged()) && let Some(pp) = resp.interact_pointer_pos() { @@ -1685,28 +1688,69 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let c = pos2(rect.min.x + v.center[0] * s, rect.min.y + v.center[1] * s); let vr = Rect::from_center_size(c, vec2(vw, vh)).intersect(frame.shrink(1.0)); ui.painter().rect_stroke(vr, 2.0, Stroke::new(1.5, Color32::from_rgb(255, 84, 84)), StrokeKind::Middle); - ui.add_space(4.0); - ui.horizontal(|ui| { - label(ui, tl!("Zoom")); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - for (lbl, z) in [("200%", 2.0), ("100%", 1.0)] { - if widgets::pill_tab(ui, lbl, (v.zoom - z).abs() < 1e-3).clicked() { - app.ui.views[idx].zoom = z; - app.ui.views[idx].fit_pending = false; + let controls = ui.vertical(|ui| { + ui.add_space(4.0); + ui.horizontal(|ui| { + label(ui, tl!("Zoom")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + for (lbl, z) in [("200%", 2.0), ("100%", 1.0)] { + if widgets::pill_tab(ui, lbl, (v.zoom - z).abs() < 1e-3).clicked() { + app.ui.views[idx].zoom = z; + app.ui.views[idx].fit_pending = false; + } } - } - if widgets::pill_tab(ui, tl!("Fit"), false).clicked() { - app.ui.views[idx].fit_pending = true; - } + if widgets::pill_tab(ui, tl!("Fit"), false).clicked() { + app.ui.views[idx].fit_pending = true; + } + }); }); + // The whole zoom range, and always the current zoom: a narrower slider would pull it back. + let (lo, hi) = (crate::zoom_levels::min(v.doc_size).min(v.zoom).log2(), crate::zoom_levels::MAX.max(v.zoom).log2()); + let mut lz = v.zoom.log2(); + if lz.is_finite() && widgets::slider(ui, &mut lz, lo..=hi, None).changed() { + app.ui.views[idx].zoom = crate::zoom_levels::clamp(2f32.powf(lz), v.doc_size); + app.ui.views[idx].fit_pending = false; + } }); - // The whole zoom range, and always the current zoom: a narrower slider would pull it back. - let (lo, hi) = (crate::zoom_levels::min(v.doc_size).min(v.zoom).log2(), crate::zoom_levels::MAX.max(v.zoom).log2()); - let mut lz = v.zoom.log2(); - if lz.is_finite() && widgets::slider(ui, &mut lz, lo..=hi, None).changed() { - app.ui.views[idx].zoom = crate::zoom_levels::clamp(2f32.powf(lz), v.doc_size); - app.ui.views[idx].fit_pending = false; - } + // From the preview's bottom, so the spacing above the controls counts too. + let controls = Rect::from_min_max(pos2(frame.left(), frame.bottom()), pos2(frame.right(), controls.response.rect.bottom())); + ui.data_mut(|d| { + d.insert_temp(navigator_controls_id(), controls.height()); + d.insert_temp(navigator_controls_rect_id(), controls); + }); +} + +/// The document's `w` × `h` fitted inside the Navigator's `frame` with an 8 pt inset, centred and +/// keeping its aspect. +fn navigator_fit(frame: Rect, w: u32, h: u32) -> Rect { + let inner = (frame.size() - vec2(16.0, 16.0)).max(vec2(1.0, 1.0)); + let (w, h) = (w.max(1) as f32, h.max(1) as f32); + let s = (inner.x / w).min(inner.y / h); + Rect::from_center_size(frame.center(), vec2(w * s, h * s)) +} + +fn navigator_preview_id() -> egui::Id { + egui::Id::new("navigator-preview-rect") +} + +fn navigator_controls_id() -> egui::Id { + egui::Id::new("navigator-controls-height") +} + +fn navigator_controls_rect_id() -> egui::Id { + egui::Id::new("navigator-controls-rect") +} + +/// Where the Navigator drew its document preview last frame. +#[cfg(test)] +pub(crate) fn last_navigator_preview(ctx: &egui::Context) -> Option { + ctx.data(|d| d.get_temp(navigator_preview_id())) +} + +/// The Navigator's zoom controls (below the preview) last frame. +#[cfg(test)] +pub(crate) fn last_navigator_controls(ctx: &egui::Context) -> Option { + ctx.data(|d| d.get_temp(navigator_controls_rect_id())) } #[cfg(test)] @@ -1741,7 +1785,9 @@ mod navigator_wheel_tests { assert_eq!(app.ui.views[0].center, center, "Navigator wheel must not pan"); frame(&mut app, &ctx, pos2(50.0, 55.0), -1.0); assert!((app.ui.views[0].zoom - 1.0).abs() < 1e-4); - frame(&mut app, &ctx, pos2(50.0, 400.0), 1.0); + // Below the preview (it now fills the group, #2726): over the zoom controls. + let below = last_navigator_controls(&ctx).map_or(pos2(50.0, 455.0), |r| pos2(r.left() + 4.0, r.bottom() - 2.0)); + frame(&mut app, &ctx, below, 1.0); assert!((app.ui.views[0].zoom - 1.0).abs() < 1e-4, "wheel outside preview leaves zoom unchanged"); } }