From d27546970f5d1d1012c7ba71896ad491b28b4c83 Mon Sep 17 00:00:00 2001 From: ArchersIntel Date: Thu, 15 Oct 2026 13:37:57 -0500 Subject: [PATCH 1/2] fix(ui): panel footer buttons are no longer cut off at the bottom (part of #6238) panel_footer lays its 30 px bar over the body's bottom margin ("the bar runs on over the margin"), but the bar's Ui inherited the body's clip rect, which ends where the content does. The bottom of every footer button was clipped: a 26 px hover highlight was drawn about 21 px tall with a flat bottom edge. This is the "icon looks cut off when you hover over it" in the Paths panel, and it affects the Layers, Channels and History footers too, since they share the widget. The footer bar now extends its own clip to the whole bar. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/widgets.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 2acbdf3c8b..2719b39076 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -183,6 +183,9 @@ pub fn panel_footer(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { ui.painter().line_segment([pos2(r.left() - m.left as f32, y), pos2(r.right() + m.right as f32, y)], Stroke::new(1.0, t.separator)); let bar = Rect::from_min_size(pos2(r.left(), r.top() + 1.0), vec2(r.width(), FOOTER_BAR)); let mut bar_ui = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center))); + // The bar runs on over the body's bottom margin, past where the body's own clip ends: without + // this its buttons were cut off there, a 26 px hover highlight drawn about 21 px tall. + bar_ui.set_clip_rect(bar_ui.clip_rect().union(bar)); bar_ui.spacing_mut().item_spacing.x = 2.0; add(&mut bar_ui) } @@ -1863,6 +1866,32 @@ mod tests { } use egui_kittest::{Harness, kittest::Queryable}; + /// A panel footer's buttons are drawn whole. The bar runs on over the body's bottom margin, so a + /// body that clips where its content ends (as the panels do) cut the bottom off every button: + /// the hover highlight of a 26 px button was about 21 px tall in the Paths, Layers and Channels + /// panels (#6238). + #[test] + fn panel_footer_buttons_are_not_clipped_by_the_body() { + let mut h = Harness::new_ui_state( + |ui, s: &mut Option<(egui::Rect, egui::Rect, egui::Rect)>| { + // A body exactly as tall as the footer takes, clipped there. + let body = egui::Rect::from_min_size(ui.max_rect().min, egui::vec2(220.0, super::footer_height(ui))); + let mut body_ui = ui.new_child(egui::UiBuilder::new().max_rect(body)); + body_ui.set_clip_rect(body); + super::panel_footer(&mut body_ui, |bar| { + let b = crate::icons::button(bar, "square", 26.0, false, "Button"); + *s = Some((b.rect, bar.clip_rect(), body)); + }); + }, + None, + ); + crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run(); + let (button, clip, body) = h.state().expect("the footer drew its button"); + assert!(button.bottom() > body.bottom(), "the bar really overruns the body, or this test proves nothing: {button:?} vs {body:?}"); + assert!(clip.contains_rect(button), "the whole button is inside the clip: {button:?} not in {clip:?}"); + } + /// #3789: a plain text field is one boxed, outline-free input whose text-selection colour stays /// local, so it isn't recoloured for the rest of the frame (selected rows, slider trails). #[test] From 8f5d53027c07a052d99ab57192bbd50f2424a9f7 Mon Sep 17 00:00:00 2001 From: ArchersIntel Date: Thu, 15 Oct 2026 16:18:44 -0500 Subject: [PATCH 2/2] fix(ui): scrolling the Paths or Channels list no longer moves the panel's footer (part of #6238) The dock lists the tabs that lay out their own scrolling list and footer so it does not wrap them in a scroll area of its own (Layers, History, Navigator). Channels and Paths do exactly the same, but were left off, so each sat inside the dock's scroll area as well as its own, and the two nested areas shared the mouse wheel. At 125 % display scaling, for some window heights, the outer content overflowed by one physical pixel and scrolled with the list: the footer's buttons moved about a pixel while scrolling down and back while scrolling up. Group::scrolls_itself now covers all three tabs of the Layers group. Measured: with 30 paths, the footer moved at 31 of 723 window-height and scale combinations (all at 125 %); with the change, none. The reporter's video agrees: the footer strip shifts 1 px and back repeatedly while the tab strip never moves. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/dock.rs | 7 ++- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/paths_scroll_tests.rs | 69 ++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/paths_scroll_tests.rs diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index deaea4e783..ad8a67c992 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -120,9 +120,12 @@ impl 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). + /// (the Navigator's preview grows with it, as in Photoshop: #2726). Channels and Paths are + /// Layers' siblings and do the same: wrapped in the dock's scroll area as well, the two nested + /// areas shared the wheel, and where the outer content overflowed by one physical pixel (at + /// 125 % scaling for some window heights) the footer and list moved a pixel as you scrolled (#6238). pub fn scrolls_itself(self, tab: usize) -> bool { - matches!((self, tab), (Group::Layers, 0) | (Group::History, 0) | (Group::History, 1) | (Group::Navigator, 0)) + matches!((self, tab), (Group::Layers, 0..=2) | (Group::History, 0) | (Group::History, 1) | (Group::Navigator, 0)) } pub(crate) fn tab_mut(self, tabs: &mut DockTabs) -> &mut usize { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bde51ff27a..d935b93355 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2732,6 +2732,8 @@ mod warp_text_dialog_tests; #[cfg(test)] mod blend_dropdown_wheel_tests; +#[cfg(test)] +mod paths_scroll_tests; #[cfg(test)] mod options_bar_mode_wheel_tests; diff --git a/crates/ui-egui/src/paths_scroll_tests.rs b/crates/ui-egui/src/paths_scroll_tests.rs new file mode 100644 index 0000000000..924e6e6290 --- /dev/null +++ b/crates/ui-egui/src/paths_scroll_tests.rs @@ -0,0 +1,69 @@ +//! Scrolling the Paths panel moves only its list (#6238). + +use egui::vec2; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; + +fn harness(paths: usize, size: egui::Vec2, ppp: f32) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(size).with_pixels_per_point(ppp).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + for i in 0..paths { + let x = (i % 8) as i32 * 40; + app.run("select.rect", json!({"x": x, "y": 20, "width": 30, "height": 30})).unwrap(); + app.run("select.toWorkPath", json!({"tolerance": 2})).unwrap(); + app.run("path.rename", json!({"name": "work", "to": format!("Path {}", i + 1)})).unwrap(); + } + // Layers | Channels | Paths: the Paths tab. + app.ui.dock_tabs.layers = 2; + app + }); + h.run_steps(8); + h +} + +/// Scrolling the path list moves the rows and nothing else: the footer's buttons stay where they +/// are. The Paths panel lays out its own list and footer, but the dock also wrapped it in a +/// scroll area; at 125 % scaling and some window heights that outer area overflowed by one +/// physical pixel, took part of the wheel and moved the footer about a pixel down and back up. +#[test] +fn scrolling_the_paths_list_does_not_move_the_footer() { + // Heights that moved it at 125 % (two bands of window heights), and ones that never did. + for (ppp, height) in [(1.25, 520.0), (1.25, 900.0), (1.25, 960.0), (1.0, 900.0), (1.5, 900.0)] { + let mut h = harness(30, vec2(1440.0, height), ppp); + let footer = |h: &Harness<'_, PhotocraftApp>| h.query_by_label("Delete path").map(|r| r.rect().min.y); + let rest = footer(&h).unwrap_or_else(|| panic!("the footer shows at {ppp}x, {height} high")); + let first_row = h.query_by_label("Path 1").unwrap().rect(); + h.hover_at(first_row.center()); + h.run_steps(2); + for i in 0..40 { + // Down to the end of the list and past it, then back up. + let dir = if i < 30 { -1.0 } else { 1.0 }; + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, dir), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }); + h.run_steps(1); + assert_eq!(footer(&h), Some(rest), "{ppp}x, {height} high, notch {i}: the footer stayed put"); + } + // The list itself did scroll (the first row is out of view or has moved). + let moved = h.query_by_label("Path 1").map(|r| r.rect().min.y) != Some(first_row.min.y); + assert!(moved || h.query_by_label("Path 1").is_none(), "{ppp}x, {height} high: the list scrolled"); + } +} + +/// Channels, Paths and Layers are siblings with the same own-scroll layout, so none is wrapped in +/// the dock's scroll area. +#[test] +fn the_layers_group_tabs_all_scroll_themselves() { + use crate::dock::Group; + for tab in 0..=2 { + assert!(Group::Layers.scrolls_itself(tab), "Layers group tab {tab}"); + } + assert!(!Group::Layers.scrolls_itself(3), "a tab that does not exist is not special"); +}