From 4f6eabffb850e8519e210789953372429daaaf18 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 22 Dec 2025 21:13:24 +0100 Subject: [PATCH] Fix jitter when hovering edge of scroll area close to resize splitter (#7803) * Closes https://github.com/emilk/egui/issues/7749 ## What In our hit test code we have special handling for thin widgets, to make them easier to hit. The widths of our scroll bars are animated. Together, the heuristic would trigger as the scroll bars were shrinking, leading to the scroll bars being _hovered_ which lead them being too wide, making the heuristic fail, which would again make them shrink, causing the bug. ## Bonus Now the scroll bar is only shown as hovered if the mouse is actually hovering them and nothing else. Previously they would show as long as the cursor were in the general area (but maybe actually hovering something else). --- crates/egui/src/containers/scroll_area.rs | 109 +++++++++++----------- 1 file changed, 54 insertions(+), 55 deletions(-) diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index a9753b04b..aa084f650 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -1241,46 +1241,17 @@ impl Prepared { continue; } + let interact_id = id.with(d); + // Margin on either side of the scroll bar: let inner_margin = show_factor * scroll_style.bar_inner_margin; let outer_margin = show_factor * scroll_style.bar_outer_margin; - // top/bottom of a horizontal scroll (d==0). - // left/rigth of a vertical scroll (d==1). - let mut cross = if scroll_style.floating { - // The bounding rect of a fully visible bar. - // When we hover this area, we should show the full bar: - let max_bar_rect = if d == 0 { - outer_rect.with_min_y(outer_rect.max.y - outer_margin - scroll_style.bar_width) - } else { - outer_rect.with_min_x(outer_rect.max.x - outer_margin - scroll_style.bar_width) - }; + // bottom of a horizontal scroll (d==0). + // right of a vertical scroll (d==1). + let mut max_cross = outer_rect.max[1 - d] - outer_margin; - let is_hovering_bar_area = is_hovering_outer_rect - && ui.rect_contains_pointer(max_bar_rect) - && !is_dragging_background - || state.scroll_bar_interaction[d]; - - let is_hovering_bar_area_t = ui - .ctx() - .animate_bool_responsive(id.with((d, "bar_hover")), is_hovering_bar_area); - - let width = show_factor - * lerp( - scroll_style.floating_width..=scroll_style.bar_width, - is_hovering_bar_area_t, - ); - - let max_cross = outer_rect.max[1 - d] - outer_margin; - let min_cross = max_cross - width; - Rangef::new(min_cross, max_cross) - } else { - let min_cross = inner_rect.max[1 - d] + inner_margin; - let max_cross = outer_rect.max[1 - d] - outer_margin; - Rangef::new(min_cross, max_cross) - }; - - if ui.clip_rect().max[1 - d] < cross.max + outer_margin { + if ui.clip_rect().max[1 - d] - outer_margin < max_cross { // Move the scrollbar so it is visible. This is needed in some cases. // For instance: // * When we have a vertical-only scroll area in a top level panel, @@ -1290,21 +1261,57 @@ impl Prepared { // is outside the clip rectangle. // Really this should use the tighter clip_rect that ignores clip_rect_margin, but we don't store that. // clip_rect_margin is quite a hack. It would be nice to get rid of it. - let width = cross.max - cross.min; - cross.max = ui.clip_rect().max[1 - d] - outer_margin; - cross.min = cross.max - width; + max_cross = ui.clip_rect().max[1 - d] - outer_margin; } - let outer_scroll_bar_rect = if d == 0 { - Rect::from_min_max( - pos2(scroll_bar_rect.left(), cross.min), - pos2(scroll_bar_rect.right(), cross.max), - ) + let full_width = scroll_style.bar_width; + + // The bounding rect of a fully visible bar. + // When we hover this area, we should show the full bar: + let max_bar_rect = if d == 0 { + outer_rect.with_min_y(max_cross - full_width) } else { - Rect::from_min_max( - pos2(cross.min, scroll_bar_rect.top()), - pos2(cross.max, scroll_bar_rect.bottom()), - ) + outer_rect.with_min_x(max_cross - full_width) + }; + + let sense = if scroll_source.scroll_bar && ui.is_enabled() { + Sense::click_and_drag() + } else { + Sense::hover() + }; + + // We always sense interaction with the full width, even if we antimate it growing/shrinking. + // This is to present a more consistent target for our hit test code, + // and to avoid producing jitter in "thin widget" heuristics there. + // Also: it make sense to detect any hover where the scroll bar _will_ be. + let response = ui.interact(max_bar_rect, interact_id, sense); + + // top/bottom of a horizontal scroll (d==0). + // left/rigth of a vertical scroll (d==1). + let cross = if scroll_style.floating { + let is_hovering_bar_area = response.hovered() || state.scroll_bar_interaction[d]; + + let is_hovering_bar_area_t = ui + .ctx() + .animate_bool_responsive(id.with((d, "bar_hover")), is_hovering_bar_area); + + let width = show_factor + * lerp( + scroll_style.floating_width..=full_width, + is_hovering_bar_area_t, + ); + + let min_cross = max_cross - width; + Rangef::new(min_cross, max_cross) + } else { + let min_cross = inner_rect.max[1 - d] + inner_margin; + Rangef::new(min_cross, max_cross) + }; + + let outer_scroll_bar_rect = if d == 0 { + Rect::from_x_y_ranges(scroll_bar_rect.x_range(), cross) + } else { + Rect::from_x_y_ranges(cross, scroll_bar_rect.y_range()) }; let from_content = |content| { @@ -1344,14 +1351,6 @@ impl Prepared { let handle_rect = calculate_handle_rect(d, &state.offset); - let interact_id = id.with(d); - let sense = if scroll_source.scroll_bar && ui.is_enabled() { - Sense::click_and_drag() - } else { - Sense::hover() - }; - let response = ui.interact(outer_scroll_bar_rect, interact_id, sense); - state.scroll_bar_interaction[d] = response.hovered() || response.dragged(); if let Some(pointer_pos) = response.interact_pointer_pos() {