diff --git a/crates/ui-egui/src/panels/side_panels.rs b/crates/ui-egui/src/panels/side_panels.rs index c62cf9df52..3676d9762b 100644 --- a/crates/ui-egui/src/panels/side_panels.rs +++ b/crates/ui-egui/src/panels/side_panels.rs @@ -390,6 +390,31 @@ fn open_panes(app: &PdfCraftApp) -> Vec { open } +/// Width of a pane's resize edge that takes the pointer, right of its border. +const RESIZE_GRIP: f32 = 4.0; + +/// Moves each open pane's resize edge to the right of its border (#5730). +/// +/// The host centres the edge on the border and widens it, so it reaches over whatever ends left +/// of the border. egui puts a vertical scrollbar at the right of its area, so that is the +/// scrollbar of the page beside a right-docked pane, or of the Inspector beside a right-docked +/// Tools pane, and its right half became the edge. Right of a border starts the next area, never +/// with a scrollbar: a right-docked pane's own scrollbar is at its far side, and a left-docked +/// pane keeps a gutter left of its border. +/// +/// The edge is the host's widget registered again with a smaller rect; egui keeps the last rect +/// for the next frame's hit test, and the layer is the one the host used. +fn keep_resize_edges_off_scrollbars(ui: &mut egui::Ui, host: egui::Id, open: &[Pane]) { + for pane in open { + let id = host.with(("pane", *pane)).with("resize"); + // An inert edge (bad geometry) senses no drag: leave it so. + let Some(edge) = ui.ctx().read_response(id).filter(|r| r.sense.senses_drag() && r.rect.is_positive()) else { continue }; + let border = edge.rect.center().x; + let grip = egui::Rect::from_x_y_ranges(border..=border + RESIZE_GRIP, edge.rect.y_range()); + ui.new_child(egui::UiBuilder::new().layer_id(edge.layer_id)).interact(grip, id, egui::Sense::drag()); + } +} + /// Draws the rail and the open panes. Call after the top bars and before the central panel. pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if let Some(panel) = app.right { @@ -411,7 +436,8 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let labels = PaneLabels { resize: crate::i18n::t("Resize panels").to_owned(), ..PaneLabels::default() }; let panels = app.side_panels; // Columns: both panes may share the right edge, Tools outermost, as `open` lists them. - let output = PaneHost::new(egui::Id::unique("pdfcraft-side-panels")) + let host = egui::Id::unique("pdfcraft-side-panels"); + let output = PaneHost::new(host) .policy(SidePolicy::Columns) .f6(false) .labels(labels) @@ -419,21 +445,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .body_margin(egui::Margin::ZERO) .show(ui, &open, |pane| Some(state_of(&panels, *pane)), &mut Content { app }); - // Ensure panel resize handles do not bleed inward over panel interiors and scrollbars. - // The handle's active target is kept to the divider strip centered on the border. - for pane in &open { - let host_id = egui::Id::unique("pdfcraft-side-panels"); - let s_id = host_id.with(("pane", *pane)); - let resize_id = s_id.with("resize"); - if let Some(resp) = ui.ctx().read_response(resize_id) { - let edge_layer = egui::LayerId::new(ui.layer_id().order, s_id.with("resize-layer")); - let edge_ui = ui.new_child(egui::UiBuilder::new().layer_id(edge_layer)); - let old_rect = resp.rect; - let border = (old_rect.min.x + old_rect.max.x) / 2.0; - let new_rect = egui::Rect::from_x_y_ranges((border - 2.0)..=(border + 2.0), old_rect.y_range()); - edge_ui.interact(new_rect, resize_id, egui::Sense::drag()); - } - } + keep_resize_edges_off_scrollbars(ui, host, &open); for request in output.requests { match request { PaneRequest::Resize(pane, width) => app.side_panels.set_width(pane, width), diff --git a/crates/ui-egui/tests/it/side_panels.rs b/crates/ui-egui/tests/it/side_panels.rs index 6febd26703..d61187c143 100644 --- a/crates/ui-egui/tests/it/side_panels.rs +++ b/crates/ui-egui/tests/it/side_panels.rs @@ -87,11 +87,11 @@ fn a_dragged_width_is_remembered_across_restarts() { // The side panel's resize edge: the one right of the page, left of the panel's text. let edge = h .get_all_by_label("Resize panels") - .map(|n| n.rect().center()) + .map(|n| n.rect().left_center()) .filter(|c| c.x < side_left && c.x > WIDTH / 2.0) .min_by(|a, b| (side_left - a.x).total_cmp(&(side_left - b.x))) .expect("the side panel has a resize edge") - // The pane's half of the edge: the page, drawn after the panes, takes the other half. + // 2 pt inside the panel: the edge lies right of the border, clear of the page's scrollbar. + egui::vec2(2.0, 0.0); h.hover_at(edge); h.run_steps(1); @@ -197,7 +197,8 @@ fn scrollbar_and_panel_resize_handle_do_not_collide_on_either_dock_side() { assert!(after_width > initial_width); assert!((after_width - (initial_width + 40.0)).abs() <= 5.0); - // 2. Right docking: tools panel on the right (resize handle on the left). + // 2. Right docking: tools panel on the right (resize handle on the left), next to the page's + // scrollbar (#5730). let mut h_right = harness_sized(egui::vec2(1200.0, 450.0), |app| { app.set_option("tools-side", "right").unwrap(); app.all_tools_expanded = true; @@ -207,6 +208,51 @@ fn scrollbar_and_panel_resize_handle_do_not_collide_on_either_dock_side() { let splitters_right: Vec<_> = h_right.get_all_by_label("Resize panels").collect(); assert!(!splitters_right.is_empty()); let right_splitter_rect = splitters_right[0].rect(); + let bars: Vec = h_right.get_all_by_role(Role::ScrollBar).map(|s| s.rect()).filter(|r| r.height() > 0.0).collect(); + // The page's scrollbar ends where the pane begins; the pane's own one is at its far side. + let page_bar = bars + .iter() + .copied() + .filter(|r| r.min.x < right_splitter_rect.center().x) + .max_by(|a, b| a.max.x.total_cmp(&b.max.x)) + .expect("the page has a scrollbar"); + assert!( + (right_splitter_rect.center().x - page_bar.max.x).abs() < 6.0, + "the page's scrollbar is beside the edge: {page_bar:?} {right_splitter_rect:?}" + ); + let tools_bar = bars.iter().copied().find(|r| r.min.x > right_splitter_rect.center().x).expect("Tools panel scrollbar should exist"); + let initial_right_width = h_right.state().side_panels.tools_width; + + // All of either scrollbar is the scrollbar, not the resize edge. + for bar in [page_bar, tools_bar] { + for x in [bar.min.x + 0.5, bar.center().x, bar.max.x - 0.5] { + h_right.hover_at(egui::pos2(x, bar.center().y)); + h_right.run_steps(1); + let cursor = h_right.output().platform_output.cursor_icon; + assert_ne!(cursor, egui::CursorIcon::ResizeHorizontal, "Hovering scrollbar {bar:?} at x={x} should not trigger resize handle"); + } + } + + // Dragging either scrollbar by its right edge scrolls its content and leaves the width alone. + let drag = |h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2| { + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.hover_at(at + egui::vec2(0.0, 60.0)); + h.run_steps(1); + h.drop_at(at + egui::vec2(0.0, 60.0)); + h.run_steps(2); + }; + let page_top = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].page_screen_rect(0).expect("page on screen").min.y; + let before = page_top(&h_right); + drag(&mut h_right, egui::pos2(page_bar.max.x - 1.0, page_bar.center().y)); + assert!(page_top(&h_right) < before, "dragging the page's scrollbar scrolls the page"); + assert_eq!(h_right.state().side_panels.tools_width, initial_right_width, "Dragging scrollbar must not resize the panel"); + let before = h_right.get_by_label("Edit a PDF").rect().top(); + drag(&mut h_right, egui::pos2(tools_bar.max.x - 1.0, tools_bar.center().y)); + assert!(h_right.get_by_label("Edit a PDF").rect().top() < before, "Content should scroll up when dragging scrollbar down"); + assert_eq!(h_right.state().side_panels.tools_width, initial_right_width, "Dragging scrollbar must not resize the panel"); // Splitter is at the left boundary of the right-docked pane. h_right.hover_at(right_splitter_rect.center()); @@ -214,7 +260,6 @@ fn scrollbar_and_panel_resize_handle_do_not_collide_on_either_dock_side() { assert_eq!(h_right.output().platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal); // Dragging the splitter on right-docked pane smoothly resizes. - let initial_right_width = h_right.state().side_panels.tools_width; h_right.hover_at(right_splitter_rect.center()); h_right.run_steps(1); h_right.drag_at(right_splitter_rect.center());