From 41f9df5cb3323eb33d58739eecf3247d6b826657 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Thu, 9 Nov 2023 18:41:58 +0100 Subject: [PATCH] Floating scroll bars (#3539) * Move scroll bar spacing settings to a `struct ScrollSpacing` * Add a demo for changing scroll bar appearance * Add setting for ScrollBarVisibility in demo * Add `#[inline]` to a `ScrollArea` builder methods * Refactor how scroll bar show/hide is computed * Add support for floating scroll bars * Tweak color and opacity of the scroll handle * Allow allocating a fixed size even for floating scroll bars * Add three pre-sets of scroll bars: solid, thin, floating * Use floating scroll bars as the default * Fix id-clash with bidir scroll areas * Improve demo * Fix doclink * Remove reset button from demo * Fix doclinks * Fix visual artifact with thin rounded rectangles * Fix doclink * typos --- crates/egui/src/animation_manager.rs | 2 +- crates/egui/src/containers/scroll_area.rs | 366 +++++++++++++----- crates/egui/src/containers/window.rs | 2 +- crates/egui/src/style.rs | 317 +++++++++++++-- .../src/demo/misc_demo_window.rs | 2 +- crates/egui_demo_lib/src/demo/scrolling.rs | 83 +++- crates/egui_extras/src/table.rs | 6 +- crates/emath/src/rect.rs | 28 ++ crates/emath/src/vec2.rs | 10 + crates/epaint/src/tessellator.rs | 1 + 10 files changed, 668 insertions(+), 149 deletions(-) diff --git a/crates/egui/src/animation_manager.rs b/crates/egui/src/animation_manager.rs index be181507962..b7b7d18beff 100644 --- a/crates/egui/src/animation_manager.rs +++ b/crates/egui/src/animation_manager.rs @@ -25,7 +25,7 @@ struct ValueAnim { } impl AnimationManager { - /// See `Context::animate_bool` for documentation + /// See [`crate::Context::animate_bool`] for documentation pub fn animate_bool( &mut self, input: &InputState, diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index 0c2cead7023..1cacef47808 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -1,8 +1,3 @@ -//! Coordinate system names: -//! * content: size of contents (generally large; that's why we want scroll bars) -//! * outer: size of scroll area including scroll bar(s) -//! * inner: excluding scroll bar(s). The area we clip the contents to. - #![allow(clippy::needless_range_loop)] use crate::*; @@ -20,6 +15,9 @@ pub struct State { /// The content were to large to fit large frame. content_is_too_large: [bool; 2], + /// Did the user interact (hover or drag) the scroll bars last frame? + scroll_bar_interaction: [bool; 2], + /// Momentum, used for kinetic scrolling #[cfg_attr(feature = "serde", serde(skip))] vel: Vec2, @@ -39,6 +37,7 @@ impl Default for State { offset: Vec2::ZERO, show_scroll: [false; 2], content_is_too_large: [false; 2], + scroll_bar_interaction: [false; 2], vel: Vec2::ZERO, scroll_start_offset_from_top_left: [None; 2], scroll_stuck_to_end: [true; 2], @@ -80,15 +79,61 @@ pub struct ScrollAreaOutput { } /// Indicate whether the horizontal and vertical scroll bars must be always visible, hidden or visible when needed. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub enum ScrollBarVisibility { - AlwaysVisible, - VisibleWhenNeeded, + /// Hide scroll bar even if they are needed. + /// + /// You can still scroll, with the scroll-wheel + /// and by dragging the contents, but there is no + /// visual indication of how far you have scrolled. AlwaysHidden, + + /// Show scroll bars only when the content size exceeds the container, + /// i.e. when there is any need to scroll. + /// + /// This is the default. + VisibleWhenNeeded, + + /// Always show the scroll bar, even if the contents fit in the container + /// and there is no need to scroll. + AlwaysVisible, +} + +impl Default for ScrollBarVisibility { + #[inline] + fn default() -> Self { + Self::VisibleWhenNeeded + } +} + +impl ScrollBarVisibility { + pub const ALL: [Self; 3] = [ + Self::AlwaysHidden, + Self::VisibleWhenNeeded, + Self::AlwaysVisible, + ]; } /// Add vertical and/or horizontal scrolling to a contained [`Ui`]. /// +/// By default, scroll bars only show up when needed, i.e. when the contents +/// is larger than the container. +/// This is controlled by [`Self::scroll_bar_visibility`]. +/// +/// There are two flavors of scroll areas: solid and floating. +/// Solid scroll bars use up space, reducing the amount of space available +/// to the contents. Floating scroll bars float on top of the contents, covering it. +/// You can change the scroll style by changing the [`crate::style::Spacing::scroll`]. +/// +/// ### Coordinate system +/// * content: size of contents (generally large; that's why we want scroll bars) +/// * outer: size of scroll area including scroll bar(s) +/// * inner: excluding scroll bar(s). The area we clip the contents to. +/// +/// If the floating scroll bars settings is turned on then `inner == outer`. +/// +/// ## Example /// ``` /// # egui::__run_test_ui(|ui| { /// egui::ScrollArea::vertical().show(ui, |ui| { @@ -101,8 +146,9 @@ pub enum ScrollBarVisibility { #[derive(Clone, Debug)] #[must_use = "You should call .show()"] pub struct ScrollArea { - /// Do we have horizontal/vertical scrolling? - has_bar: [bool; 2], + /// Do we have horizontal/vertical scrolling enabled? + scroll_enabled: [bool; 2], + auto_shrink: [bool; 2], max_size: Vec2, min_scrolled_size: Vec2, @@ -123,35 +169,39 @@ pub struct ScrollArea { impl ScrollArea { /// Create a horizontal scroll area. + #[inline] pub fn horizontal() -> Self { Self::new([true, false]) } /// Create a vertical scroll area. + #[inline] pub fn vertical() -> Self { Self::new([false, true]) } /// Create a bi-directional (horizontal and vertical) scroll area. + #[inline] pub fn both() -> Self { Self::new([true, true]) } /// Create a scroll area where both direction of scrolling is disabled. /// It's unclear why you would want to do this. + #[inline] pub fn neither() -> Self { Self::new([false, false]) } /// Create a scroll area where you decide which axis has scrolling enabled. /// For instance, `ScrollArea::new([true, false])` enables horizontal scrolling. - pub fn new(has_bar: [bool; 2]) -> Self { + pub fn new(scroll_enabled: [bool; 2]) -> Self { Self { - has_bar, + scroll_enabled, auto_shrink: [true; 2], max_size: Vec2::INFINITY, min_scrolled_size: Vec2::splat(64.0), - scroll_bar_visibility: ScrollBarVisibility::VisibleWhenNeeded, + scroll_bar_visibility: Default::default(), id_source: None, offset_x: None, offset_y: None, @@ -166,6 +216,7 @@ impl ScrollArea { /// Use `f32::INFINITY` if you want the scroll area to expand to fit the surrounding [`Ui`] (default). /// /// See also [`Self::auto_shrink`]. + #[inline] pub fn max_width(mut self, max_width: f32) -> Self { self.max_size.x = max_width; self @@ -176,6 +227,7 @@ impl ScrollArea { /// Use `f32::INFINITY` if you want the scroll area to expand to fit the surrounding [`Ui`] (default). /// /// See also [`Self::auto_shrink`]. + #[inline] pub fn max_height(mut self, max_height: f32) -> Self { self.max_size.y = max_height; self @@ -187,6 +239,7 @@ impl ScrollArea { /// (and so we don't require scroll bars). /// /// Default: `64.0`. + #[inline] pub fn min_scrolled_width(mut self, min_scrolled_width: f32) -> Self { self.min_scrolled_size.x = min_scrolled_width; self @@ -198,6 +251,7 @@ impl ScrollArea { /// (and so we don't require scroll bars). /// /// Default: `64.0`. + #[inline] pub fn min_scrolled_height(mut self, min_scrolled_height: f32) -> Self { self.min_scrolled_size.y = min_scrolled_height; self @@ -206,12 +260,14 @@ impl ScrollArea { /// Set the visibility of both horizontal and vertical scroll bars. /// /// With `ScrollBarVisibility::VisibleWhenNeeded` (default), the scroll bar will be visible only when needed. + #[inline] pub fn scroll_bar_visibility(mut self, scroll_bar_visibility: ScrollBarVisibility) -> Self { self.scroll_bar_visibility = scroll_bar_visibility; self } /// A source for the unique [`Id`], e.g. `.id_source("second_scroll_area")` or `.id_source(loop_index)`. + #[inline] pub fn id_source(mut self, id_source: impl std::hash::Hash) -> Self { self.id_source = Some(Id::new(id_source)); self @@ -224,6 +280,7 @@ impl ScrollArea { /// See also: [`Self::vertical_scroll_offset`], [`Self::horizontal_scroll_offset`], /// [`Ui::scroll_to_cursor`](crate::ui::Ui::scroll_to_cursor) and /// [`Response::scroll_to_me`](crate::Response::scroll_to_me) + #[inline] pub fn scroll_offset(mut self, offset: Vec2) -> Self { self.offset_x = Some(offset.x); self.offset_y = Some(offset.y); @@ -236,6 +293,7 @@ impl ScrollArea { /// /// See also: [`Self::scroll_offset`], [`Ui::scroll_to_cursor`](crate::ui::Ui::scroll_to_cursor) and /// [`Response::scroll_to_me`](crate::Response::scroll_to_me) + #[inline] pub fn vertical_scroll_offset(mut self, offset: f32) -> Self { self.offset_y = Some(offset); self @@ -247,26 +305,30 @@ impl ScrollArea { /// /// See also: [`Self::scroll_offset`], [`Ui::scroll_to_cursor`](crate::ui::Ui::scroll_to_cursor) and /// [`Response::scroll_to_me`](crate::Response::scroll_to_me) + #[inline] pub fn horizontal_scroll_offset(mut self, offset: f32) -> Self { self.offset_x = Some(offset); self } /// Turn on/off scrolling on the horizontal axis. + #[inline] pub fn hscroll(mut self, hscroll: bool) -> Self { - self.has_bar[0] = hscroll; + self.scroll_enabled[0] = hscroll; self } /// Turn on/off scrolling on the vertical axis. + #[inline] pub fn vscroll(mut self, vscroll: bool) -> Self { - self.has_bar[1] = vscroll; + self.scroll_enabled[1] = vscroll; self } /// Turn on/off scrolling on the horizontal/vertical axes. - pub fn scroll2(mut self, has_bar: [bool; 2]) -> Self { - self.has_bar = has_bar; + #[inline] + pub fn scroll2(mut self, scroll_enabled: [bool; 2]) -> Self { + self.scroll_enabled = scroll_enabled; self } @@ -279,6 +341,7 @@ impl ScrollArea { /// is typing text in a [`TextEdit`] widget contained within the scroll area. /// /// This controls both scrolling directions. + #[inline] pub fn enable_scrolling(mut self, enable: bool) -> Self { self.scrolling_enabled = enable; self @@ -291,6 +354,7 @@ impl ScrollArea { /// If `true`, the [`ScrollArea`] will sense drags. /// /// Default: `true`. + #[inline] pub fn drag_to_scroll(mut self, drag_to_scroll: bool) -> Self { self.drag_to_scroll = drag_to_scroll; self @@ -302,13 +366,15 @@ impl ScrollArea { /// * If `false`, egui will add blank space inside the scroll area. /// /// Default: `[true; 2]`. + #[inline] pub fn auto_shrink(mut self, auto_shrink: [bool; 2]) -> Self { self.auto_shrink = auto_shrink; self } - pub(crate) fn has_any_bar(&self) -> bool { - self.has_bar[0] || self.has_bar[1] + /// Is any scrolling enabled? + pub(crate) fn is_any_scroll_enabled(&self) -> bool { + self.scroll_enabled[0] || self.scroll_enabled[1] } /// The scroll handle will stick to the rightmost position even while the content size @@ -317,6 +383,7 @@ impl ScrollArea { /// it will remain focused on whatever content viewport the user left it on. If the scroll /// handle is dragged all the way to the right it will again become stuck and remain there /// until manually pulled from the end position. + #[inline] pub fn stick_to_right(mut self, stick: bool) -> Self { self.stick_to_end[0] = stick; self @@ -328,6 +395,7 @@ impl ScrollArea { /// it will remain focused on whatever content viewport the user left it on. If the scroll /// handle is dragged to the bottom it will again become stuck and remain there until manually /// pulled from the end position. + #[inline] pub fn stick_to_bottom(mut self, stick: bool) -> Self { self.stick_to_end[1] = stick; self @@ -337,11 +405,24 @@ impl ScrollArea { struct Prepared { id: Id, state: State, - has_bar: [bool; 2], + auto_shrink: [bool; 2], + /// Does this `ScrollArea` have horizontal/vertical scrolling enabled? + scroll_enabled: [bool; 2], + + /// Smoothly interpolated boolean of whether or not to show the scroll bars. + show_bars_factor: Vec2, + /// How much horizontal and vertical space are used up by the /// width of the vertical bar, and the height of the horizontal bar? + /// + /// This is always zero for floating scroll bars. + /// + /// Note that this is a `yx` swizzling of [`Self::show_bars_factor`] + /// times the maximum bar with. + /// That's because horizontal scroll uses up vertical space, + /// and vice versa. current_bar_use: Vec2, scroll_bar_visibility: ScrollBarVisibility, @@ -362,7 +443,7 @@ struct Prepared { impl ScrollArea { fn begin(self, ui: &mut Ui) -> Prepared { let Self { - has_bar, + scroll_enabled, auto_shrink, max_size, min_scrolled_size, @@ -379,7 +460,7 @@ impl ScrollArea { let id_source = id_source.unwrap_or_else(|| Id::new("scroll_area")); let id = ui.make_persistent_id(id_source); - ui.ctx().check_for_id_clash( + ctx.check_for_id_clash( id, Rect::from_min_size(ui.available_rect_before_wrap().min, Vec2::ZERO), "ScrollArea", @@ -389,25 +470,18 @@ impl ScrollArea { state.offset.x = offset_x.unwrap_or(state.offset.x); state.offset.y = offset_y.unwrap_or(state.offset.y); - let max_scroll_bar_width = max_scroll_bar_width_with_margin(ui); - - let current_hscroll_bar_height = if !has_bar[0] { - 0.0 - } else if scroll_bar_visibility == ScrollBarVisibility::AlwaysVisible { - max_scroll_bar_width - } else { - max_scroll_bar_width * ui.ctx().animate_bool(id.with("h"), state.show_scroll[0]) + let show_bars: [bool; 2] = match scroll_bar_visibility { + ScrollBarVisibility::AlwaysHidden => [false; 2], + ScrollBarVisibility::VisibleWhenNeeded => state.show_scroll, + ScrollBarVisibility::AlwaysVisible => scroll_enabled, }; - let current_vscroll_bar_width = if !has_bar[1] { - 0.0 - } else if scroll_bar_visibility == ScrollBarVisibility::AlwaysVisible { - max_scroll_bar_width - } else { - max_scroll_bar_width * ui.ctx().animate_bool(id.with("v"), state.show_scroll[1]) - }; + let show_bars_factor = Vec2::new( + ctx.animate_bool(id.with("h"), show_bars[0]), + ctx.animate_bool(id.with("v"), show_bars[1]), + ); - let current_bar_use = vec2(current_vscroll_bar_width, current_hscroll_bar_height); + let current_bar_use = show_bars_factor.yx() * ui.spacing().scroll.allocated_width(); let available_outer = ui.available_rect_before_wrap(); @@ -421,7 +495,7 @@ impl ScrollArea { // one shouldn't collapse into nothingness. // See https://github.com/emilk/egui/issues/1097 for d in 0..2 { - if has_bar[d] { + if scroll_enabled[d] { inner_size[d] = inner_size[d].max(min_scrolled_size[d]); } } @@ -438,7 +512,7 @@ impl ScrollArea { } else { // Tell the inner Ui to use as much space as possible, we can scroll to see it! for d in 0..2 { - if has_bar[d] { + if scroll_enabled[d] { content_max_size[d] = f32::INFINITY; } } @@ -452,7 +526,7 @@ impl ScrollArea { let clip_rect_margin = ui.visuals().clip_rect_margin; let mut content_clip_rect = ui.clip_rect(); for d in 0..2 { - if has_bar[d] { + if scroll_enabled[d] { if state.content_is_too_large[d] { content_clip_rect.min[d] = inner_rect.min[d] - clip_rect_margin; content_clip_rect.max[d] = inner_rect.max[d] + clip_rect_margin; @@ -479,7 +553,7 @@ impl ScrollArea { if content_response.dragged() { for d in 0..2 { - if has_bar[d] { + if scroll_enabled[d] { ui.input(|input| { state.offset[d] -= input.pointer.delta()[d]; state.vel[d] = input.pointer.velocity()[d]; @@ -502,7 +576,7 @@ impl ScrollArea { // Offset has an inverted coordinate system compared to // the velocity, so we subtract it instead of adding it state.offset -= state.vel * dt; - ui.ctx().request_repaint(); + ctx.request_repaint(); } } } @@ -510,8 +584,9 @@ impl ScrollArea { Prepared { id, state, - has_bar, auto_shrink, + scroll_enabled, + show_bars_factor, current_bar_use, scroll_bar_visibility, inner_rect, @@ -621,9 +696,10 @@ impl Prepared { id, mut state, inner_rect, - has_bar, auto_shrink, - mut current_bar_use, + scroll_enabled, + mut show_bars_factor, + current_bar_use, scroll_bar_visibility, content_ui, viewport: _, @@ -634,7 +710,7 @@ impl Prepared { let content_size = content_ui.min_size(); for d in 0..2 { - if has_bar[d] { + if scroll_enabled[d] { // We take the scroll target so only this ScrollArea will use it: let scroll_target = content_ui .ctx() @@ -680,7 +756,7 @@ impl Prepared { let mut inner_size = inner_rect.size(); for d in 0..2 { - inner_size[d] = match (has_bar[d], auto_shrink[d]) { + inner_size[d] = match (scroll_enabled[d], auto_shrink[d]) { (true, true) => inner_size[d].min(content_size[d]), // shrink scroll area if content is small (true, false) => inner_size[d], // let scroll area be larger than content; fill with blank space (false, true) => content_size[d], // Follow the content (expand/contract to fit it). @@ -694,14 +770,15 @@ impl Prepared { let outer_rect = Rect::from_min_size(inner_rect.min, inner_rect.size() + current_bar_use); let content_is_too_large = [ - content_size.x > inner_rect.width(), - content_size.y > inner_rect.height(), + scroll_enabled[0] && inner_rect.width() < content_size.x, + scroll_enabled[1] && inner_rect.height() < content_size.y, ]; let max_offset = content_size - inner_rect.size(); - if scrolling_enabled && ui.rect_contains_pointer(outer_rect) { + let is_hovering_outer_rect = ui.rect_contains_pointer(outer_rect); + if scrolling_enabled && is_hovering_outer_rect { for d in 0..2 { - if has_bar[d] { + if scroll_enabled[d] { let scroll_delta = ui.ctx().frame_state(|fs| fs.scroll_delta); let scrolling_up = state.offset[d] > 0.0 && scroll_delta[d] > 0.0; @@ -718,39 +795,69 @@ impl Prepared { } let show_scroll_this_frame = match scroll_bar_visibility { - ScrollBarVisibility::AlwaysVisible => [true, true], + ScrollBarVisibility::AlwaysHidden => [false, false], ScrollBarVisibility::VisibleWhenNeeded => { [content_is_too_large[0], content_is_too_large[1]] } - ScrollBarVisibility::AlwaysHidden => [false, false], + ScrollBarVisibility::AlwaysVisible => scroll_enabled, }; - let max_scroll_bar_width = max_scroll_bar_width_with_margin(ui); - // Avoid frame delay; start showing scroll bar right away: - if show_scroll_this_frame[0] && current_bar_use.y <= 0.0 { - current_bar_use.y = max_scroll_bar_width * ui.ctx().animate_bool(id.with("h"), true); + if show_scroll_this_frame[0] && show_bars_factor.x <= 0.0 { + show_bars_factor.x = ui.ctx().animate_bool(id.with("h"), true); } - if show_scroll_this_frame[1] && current_bar_use.x <= 0.0 { - current_bar_use.x = max_scroll_bar_width * ui.ctx().animate_bool(id.with("v"), true); + if show_scroll_this_frame[1] && show_bars_factor.y <= 0.0 { + show_bars_factor.y = ui.ctx().animate_bool(id.with("v"), true); } - for d in 0..2 { - let animation_t = current_bar_use[1 - d] / max_scroll_bar_width; + let scroll_style = ui.spacing().scroll; - if animation_t == 0.0 { + // Paint the bars: + for d in 0..2 { + let show_factor = show_bars_factor[d]; + if show_factor == 0.0 { + state.scroll_bar_interaction[d] = false; continue; } - // margin on either side of the scroll bar - let inner_margin = animation_t * ui.spacing().scroll_bar_inner_margin; - let outer_margin = animation_t * ui.spacing().scroll_bar_outer_margin; - let mut min_cross = inner_rect.max[1 - d] + inner_margin; // left of vertical scroll (d == 1) - let mut max_cross = outer_rect.max[1 - d] - outer_margin; // right of vertical scroll (d == 1) - let min_main = inner_rect.min[d]; // top of vertical scroll (d == 1) - let max_main = inner_rect.max[d]; // bottom of vertical scroll (d == 1) + // left/right of a horizontal scroll (d==1) + // top/bottom of vertical scroll (d == 1) + let main_range = Rangef::new(inner_rect.min[d], inner_rect.max[d]); - if ui.clip_rect().max[1 - d] < max_cross + outer_margin { + // 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 { + let max_bar_rect = if d == 0 { + outer_rect.with_min_y(outer_rect.max.y - scroll_style.allocated_width()) + } else { + outer_rect.with_min_x(outer_rect.max.x - scroll_style.allocated_width()) + }; + let is_hovering_bar_area = is_hovering_outer_rect + && ui.rect_contains_pointer(max_bar_rect) + || state.scroll_bar_interaction[d]; + let is_hovering_bar_area_t = ui + .ctx() + .animate_bool(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 { // 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, @@ -760,20 +867,20 @@ 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 = max_cross - min_cross; - max_cross = ui.clip_rect().max[1 - d] - outer_margin; - min_cross = max_cross - width; + let width = cross.max - cross.min; + cross.max = ui.clip_rect().max[1 - d] - outer_margin; + cross.min = cross.max - width; } let outer_scroll_rect = if d == 0 { Rect::from_min_max( - pos2(inner_rect.left(), min_cross), - pos2(inner_rect.right(), max_cross), + pos2(inner_rect.left(), cross.min), + pos2(inner_rect.right(), cross.max), ) } else { Rect::from_min_max( - pos2(min_cross, inner_rect.top()), - pos2(max_cross, inner_rect.bottom()), + pos2(cross.min, inner_rect.top()), + pos2(cross.max, inner_rect.bottom()), ) }; @@ -782,19 +889,18 @@ impl Prepared { state.offset[d] = content_size[d] - inner_rect.size()[d]; } - let from_content = - |content| remap_clamp(content, 0.0..=content_size[d], min_main..=max_main); + let from_content = |content| remap_clamp(content, 0.0..=content_size[d], main_range); let handle_rect = if d == 0 { Rect::from_min_max( - pos2(from_content(state.offset.x), min_cross), - pos2(from_content(state.offset.x + inner_rect.width()), max_cross), + pos2(from_content(state.offset.x), cross.min), + pos2(from_content(state.offset.x + inner_rect.width()), cross.max), ) } else { Rect::from_min_max( - pos2(min_cross, from_content(state.offset.y)), + pos2(cross.min, from_content(state.offset.y)), pos2( - max_cross, + cross.max, from_content(state.offset.y + inner_rect.height()), ), ) @@ -808,22 +914,24 @@ impl Prepared { }; let response = ui.interact(outer_scroll_rect, interact_id, sense); + state.scroll_bar_interaction[d] = response.hovered() || response.dragged(); + if let Some(pointer_pos) = response.interact_pointer_pos() { let scroll_start_offset_from_top_left = state.scroll_start_offset_from_top_left[d] .get_or_insert_with(|| { if handle_rect.contains(pointer_pos) { pointer_pos[d] - handle_rect.min[d] } else { - let handle_top_pos_at_bottom = max_main - handle_rect.size()[d]; + let handle_top_pos_at_bottom = main_range.max - handle_rect.size()[d]; // Calculate the new handle top position, centering the handle on the mouse. let new_handle_top_pos = (pointer_pos[d] - handle_rect.size()[d] / 2.0) - .clamp(min_main, handle_top_pos_at_bottom); + .clamp(main_range.min, handle_top_pos_at_bottom); pointer_pos[d] - new_handle_top_pos } }); let new_handle_top = pointer_pos[d] - *scroll_start_offset_from_top_left; - state.offset[d] = remap(new_handle_top, min_main..=max_main, 0.0..=content_size[d]); + state.offset[d] = remap(new_handle_top, main_range, 0.0..=content_size[d]); // some manual action taken, scroll not stuck state.scroll_stuck_to_end[d] = false; @@ -843,19 +951,19 @@ impl Prepared { // Avoid frame-delay by calculating a new handle rect: let mut handle_rect = if d == 0 { Rect::from_min_max( - pos2(from_content(state.offset.x), min_cross), - pos2(from_content(state.offset.x + inner_rect.width()), max_cross), + pos2(from_content(state.offset.x), cross.min), + pos2(from_content(state.offset.x + inner_rect.width()), cross.max), ) } else { Rect::from_min_max( - pos2(min_cross, from_content(state.offset.y)), + pos2(cross.min, from_content(state.offset.y)), pos2( - max_cross, + cross.max, from_content(state.offset.y + inner_rect.height()), ), ) }; - let min_handle_size = ui.spacing().scroll_handle_min_length; + let min_handle_size = scroll_style.handle_min_length; if handle_rect.size()[d] < min_handle_size { handle_rect = Rect::from_center_size( handle_rect.center(), @@ -868,21 +976,76 @@ impl Prepared { } let visuals = if scrolling_enabled { - ui.style().interact(&response) + // Pick visuals based on interaction with the handle. + // Remember that the response is for the whole scroll bar! + let is_hovering_handle = response.hovered() + && ui.input(|i| { + i.pointer + .latest_pos() + .map_or(false, |p| handle_rect.contains(p)) + }); + let visuals = ui.visuals(); + if response.is_pointer_button_down_on() { + &visuals.widgets.active + } else if is_hovering_handle { + &visuals.widgets.hovered + } else { + &visuals.widgets.inactive + } + } else { + &ui.visuals().widgets.inactive + }; + + let handle_opacity = if scroll_style.floating { + if response.hovered() || response.dragged() { + scroll_style.interact_handle_opacity + } else { + let is_hovering_outer_rect_t = ui.ctx().animate_bool( + id.with((d, "is_hovering_outer_rect")), + is_hovering_outer_rect, + ); + lerp( + scroll_style.dormant_handle_opacity + ..=scroll_style.active_handle_opacity, + is_hovering_outer_rect_t, + ) + } + } else { + 1.0 + }; + + let background_opacity = if scroll_style.floating { + if response.hovered() || response.dragged() { + scroll_style.interact_background_opacity + } else if is_hovering_outer_rect { + scroll_style.active_background_opacity + } else { + scroll_style.dormant_background_opacity + } + } else { + 1.0 + }; + + let handle_color = if scroll_style.foreground_color { + visuals.fg_stroke.color } else { - &ui.style().visuals.widgets.inactive + visuals.bg_fill }; + // Background: ui.painter().add(epaint::Shape::rect_filled( outer_scroll_rect, visuals.rounding, - ui.visuals().extreme_bg_color, + ui.visuals() + .extreme_bg_color + .gamma_multiply(background_opacity), )); + // Handle: ui.painter().add(epaint::Shape::rect_filled( handle_rect, visuals.rounding, - visuals.bg_fill, + handle_color.gamma_multiply(handle_opacity), )); } } @@ -904,9 +1067,9 @@ impl Prepared { // has appropriate effect. state.scroll_stuck_to_end = [ (state.offset[0] == available_offset[0]) - || (self.stick_to_end[0] && available_offset[0] < 0.), + || (self.stick_to_end[0] && available_offset[0] < 0.0), (state.offset[1] == available_offset[1]) - || (self.stick_to_end[1] && available_offset[1] < 0.), + || (self.stick_to_end[1] && available_offset[1] < 0.0), ]; state.show_scroll = show_scroll_this_frame; @@ -917,10 +1080,3 @@ impl Prepared { (content_size, state) } } - -/// Width of a vertical scrollbar, or height of a horizontal scroll bar -fn max_scroll_bar_width_with_margin(ui: &Ui) -> f32 { - ui.spacing().scroll_bar_inner_margin - + ui.spacing().scroll_bar_width - + ui.spacing().scroll_bar_outer_margin -} diff --git a/crates/egui/src/containers/window.rs b/crates/egui/src/containers/window.rs index c12199d40ac..6c385bbff39 100644 --- a/crates/egui/src/containers/window.rs +++ b/crates/egui/src/containers/window.rs @@ -419,7 +419,7 @@ impl<'open> Window<'open> { ui.add_space(title_content_spacing); } - if scroll.has_any_bar() { + if scroll.is_any_scroll_enabled() { scroll.show(ui, add_contents).inner } else { add_contents(ui) diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index 3c44b292a6c..ba0cc1b9e83 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -2,11 +2,13 @@ #![allow(clippy::if_same_then_else)] +use std::collections::BTreeMap; + +use epaint::{Rounding, Shadow, Stroke}; + use crate::{ ecolor::*, emath::*, ComboBox, CursorIcon, FontFamily, FontId, Response, RichText, WidgetText, }; -use epaint::{Rounding, Shadow, Stroke}; -use std::collections::BTreeMap; // ---------------------------------------------------------------------------- @@ -303,16 +305,8 @@ pub struct Spacing { /// Height of a combo-box before showing scroll bars. pub combo_height: f32, - pub scroll_bar_width: f32, - - /// Make sure the scroll handle is at least this big - pub scroll_handle_min_length: f32, - - /// Margin between contents and scroll bar. - pub scroll_bar_inner_margin: f32, - - /// Margin between scroll bar and the outer container (e.g. right of a vertical scroll bar). - pub scroll_bar_outer_margin: f32, + /// Controls the spacing of a [`crate::ScrollArea`]. + pub scroll: ScrollStyle, } impl Spacing { @@ -333,6 +327,277 @@ impl Spacing { // ---------------------------------------------------------------------------- +/// Controls the spacing and visuals of a [`crate::ScrollArea`]. +/// +/// There are three presets to chose from: +/// * [`Self::solid`] +/// * [`Self::thin`] +/// * [`Self::floating`] +#[derive(Clone, Copy, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(default))] +pub struct ScrollStyle { + /// If `true`, scroll bars float above the content, partially covering it. + /// + /// If `false`, the scroll bars allocate space, shrinking the area + /// available to the contents. + /// + /// This also changes the colors of the scroll-handle to make + /// it more promiment. + pub floating: bool, + + /// The width of the scroll bars at it largest. + pub bar_width: f32, + + /// Make sure the scroll handle is at least this big + pub handle_min_length: f32, + + /// Margin between contents and scroll bar. + pub bar_inner_margin: f32, + + /// Margin between scroll bar and the outer container (e.g. right of a vertical scroll bar). + /// Only makes sense for non-floating scroll bars. + pub bar_outer_margin: f32, + + /// The thin width of floating scroll bars that the user is NOT hovering. + /// + /// When the user hovers the scroll bars they expand to [`Self::bar_width`]. + pub floating_width: f32, + + /// How much space i allocated for a floating scroll bar? + /// + /// Normally this is zero, but you could set this to something small + /// like 4.0 and set [`Self::dormant_handle_opacity`] and + /// [`Self::dormant_background_opacity`] to e.g. 0.5 + /// so as to always show a thin scroll bar. + pub floating_allocated_width: f32, + + /// If true, use colors with more contrast. Good for floating scroll bars. + pub foreground_color: bool, + + /// The opaqueness of the background when the user is neither scrolling + /// nor hovering the scroll area. + /// + /// This is only for floating scroll bars. + /// Solid scroll bars are always opaque. + pub dormant_background_opacity: f32, + + /// The opaqueness of the background when the user is hovering + /// the scroll area, but not the scroll bar. + /// + /// This is only for floating scroll bars. + /// Solid scroll bars are always opaque. + pub active_background_opacity: f32, + + /// The opaqueness of the background when the user is hovering + /// over the scroll bars. + /// + /// This is only for floating scroll bars. + /// Solid scroll bars are always opaque. + pub interact_background_opacity: f32, + + /// The opaqueness of the handle when the user is neither scrolling + /// nor hovering the scroll area. + /// + /// This is only for floating scroll bars. + /// Solid scroll bars are always opaque. + pub dormant_handle_opacity: f32, + + /// The opaqueness of the handle when the user is hovering + /// the scroll area, but not the scroll bar. + /// + /// This is only for floating scroll bars. + /// Solid scroll bars are always opaque. + pub active_handle_opacity: f32, + + /// The opaqueness of the handle when the user is hovering + /// over the scroll bars. + /// + /// This is only for floating scroll bars. + /// Solid scroll bars are always opaque. + pub interact_handle_opacity: f32, +} + +impl Default for ScrollStyle { + fn default() -> Self { + Self::floating() + } +} + +impl ScrollStyle { + /// Solid scroll bars that always use up space + pub fn solid() -> Self { + Self { + floating: false, + bar_width: 6.0, + handle_min_length: 12.0, + bar_inner_margin: 4.0, + bar_outer_margin: 0.0, + floating_width: 2.0, + floating_allocated_width: 0.0, + + foreground_color: false, + + dormant_background_opacity: 0.0, + active_background_opacity: 0.4, + interact_background_opacity: 0.7, + + dormant_handle_opacity: 0.0, + active_handle_opacity: 0.6, + interact_handle_opacity: 1.0, + } + } + + /// Thin scroll bars that expand on hover + pub fn thin() -> Self { + Self { + floating: true, + bar_width: 12.0, + floating_allocated_width: 6.0, + foreground_color: false, + + dormant_background_opacity: 1.0, + dormant_handle_opacity: 1.0, + + active_background_opacity: 1.0, + active_handle_opacity: 1.0, + + // Be tranlucent when expanded so we can see the content + interact_background_opacity: 0.6, + interact_handle_opacity: 0.6, + + ..Self::solid() + } + } + + /// No scroll bars until you hover the scroll area, + /// at which time they appear faintly, and then expand + /// when you hover the scroll bars. + pub fn floating() -> Self { + Self { + floating: true, + bar_width: 12.0, + foreground_color: true, + floating_allocated_width: 0.0, + dormant_background_opacity: 0.0, + dormant_handle_opacity: 0.0, + ..Self::solid() + } + } + + /// Width of a solid vertical scrollbar, or height of a horizontal scroll bar, when it is at its widest. + pub fn allocated_width(&self) -> f32 { + if self.floating { + self.floating_allocated_width + } else { + self.bar_inner_margin + self.bar_width + self.bar_outer_margin + } + } + + pub fn ui(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Presets:"); + ui.selectable_value(self, Self::solid(), "Solid"); + ui.selectable_value(self, Self::thin(), "Thin"); + ui.selectable_value(self, Self::floating(), "Floating"); + }); + + ui.collapsing("Details", |ui| { + self.details_ui(ui); + }); + } + + pub fn details_ui(&mut self, ui: &mut Ui) { + let Self { + floating, + bar_width, + handle_min_length, + bar_inner_margin, + bar_outer_margin, + floating_width, + floating_allocated_width, + + foreground_color, + + dormant_background_opacity, + active_background_opacity, + interact_background_opacity, + dormant_handle_opacity, + active_handle_opacity, + interact_handle_opacity, + } = self; + + ui.horizontal(|ui| { + ui.label("Type:"); + ui.selectable_value(floating, false, "Solid"); + ui.selectable_value(floating, true, "Floating"); + }); + + ui.horizontal(|ui| { + ui.add(DragValue::new(bar_width).clamp_range(0.0..=32.0)); + ui.label("Full bar width"); + }); + if *floating { + ui.horizontal(|ui| { + ui.add(DragValue::new(floating_width).clamp_range(0.0..=32.0)); + ui.label("Thin bar width"); + }); + ui.horizontal(|ui| { + ui.add(DragValue::new(floating_allocated_width).clamp_range(0.0..=32.0)); + ui.label("Allocated width"); + }); + } + + ui.horizontal(|ui| { + ui.add(DragValue::new(handle_min_length).clamp_range(0.0..=32.0)); + ui.label("Minimum handle length"); + }); + ui.horizontal(|ui| { + ui.add(DragValue::new(bar_outer_margin).clamp_range(0.0..=32.0)); + ui.label("Outer margin"); + }); + + ui.horizontal(|ui| { + ui.label("Color:"); + ui.selectable_value(foreground_color, false, "Background"); + ui.selectable_value(foreground_color, true, "Foreground"); + }); + + if *floating { + crate::Grid::new("opacity").show(ui, |ui| { + fn opacity_ui(ui: &mut Ui, opacity: &mut f32) { + ui.add(DragValue::new(opacity).speed(0.01).clamp_range(0.0..=1.0)); + } + + ui.label("Opacity"); + ui.label("Dormant"); + ui.label("Active"); + ui.label("Interacting"); + ui.end_row(); + + ui.label("Background:"); + opacity_ui(ui, dormant_background_opacity); + opacity_ui(ui, active_background_opacity); + opacity_ui(ui, interact_background_opacity); + ui.end_row(); + + ui.label("Handle:"); + opacity_ui(ui, dormant_handle_opacity); + opacity_ui(ui, active_handle_opacity); + opacity_ui(ui, interact_handle_opacity); + ui.end_row(); + }); + } else { + ui.horizontal(|ui| { + ui.add(DragValue::new(bar_inner_margin).clamp_range(0.0..=32.0)); + ui.label("Inner margin"); + }); + } + } +} + +// ---------------------------------------------------------------------------- + #[derive(Clone, Copy, Debug, Default, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct Margin { @@ -807,10 +1072,7 @@ impl Default for Spacing { icon_spacing: 4.0, tooltip_width: 600.0, combo_height: 200.0, - scroll_bar_width: 8.0, - scroll_handle_min_length: 12.0, - scroll_bar_inner_margin: 4.0, - scroll_bar_outer_margin: 0.0, + scroll: Default::default(), indent_ends_with_horizontal_line: false, } } @@ -1146,10 +1408,7 @@ impl Spacing { tooltip_width, indent_ends_with_horizontal_line, combo_height, - scroll_bar_width, - scroll_handle_min_length, - scroll_bar_inner_margin, - scroll_bar_outer_margin, + scroll, } = self; ui.add(slider_vec2(item_spacing, 0.0..=20.0, "Item spacing")); @@ -1176,21 +1435,9 @@ impl Spacing { ui.add(DragValue::new(text_edit_width).clamp_range(0.0..=1000.0)); ui.label("TextEdit width"); }); - ui.horizontal(|ui| { - ui.add(DragValue::new(scroll_bar_width).clamp_range(0.0..=32.0)); - ui.label("Scroll-bar width"); - }); - ui.horizontal(|ui| { - ui.add(DragValue::new(scroll_handle_min_length).clamp_range(0.0..=32.0)); - ui.label("Scroll-bar handle min length"); - }); - ui.horizontal(|ui| { - ui.add(DragValue::new(scroll_bar_inner_margin).clamp_range(0.0..=32.0)); - ui.label("Scroll-bar inner margin"); - }); - ui.horizontal(|ui| { - ui.add(DragValue::new(scroll_bar_outer_margin).clamp_range(0.0..=32.0)); - ui.label("Scroll-bar outer margin"); + + ui.collapsing("Scroll Area", |ui| { + scroll.ui(ui); }); ui.horizontal(|ui| { diff --git a/crates/egui_demo_lib/src/demo/misc_demo_window.rs b/crates/egui_demo_lib/src/demo/misc_demo_window.rs index 195b79e223a..a90465cc19d 100644 --- a/crates/egui_demo_lib/src/demo/misc_demo_window.rs +++ b/crates/egui_demo_lib/src/demo/misc_demo_window.rs @@ -371,7 +371,7 @@ impl BoxPainting { ui.painter().rect( rect, self.rounding, - Color32::from_gray(64), + ui.visuals().text_color().gamma_multiply(0.5), Stroke::new(self.stroke_width, Color32::WHITE), ); } diff --git a/crates/egui_demo_lib/src/demo/scrolling.rs b/crates/egui_demo_lib/src/demo/scrolling.rs index f5833521b5b..ddcf95550a0 100644 --- a/crates/egui_demo_lib/src/demo/scrolling.rs +++ b/crates/egui_demo_lib/src/demo/scrolling.rs @@ -1,8 +1,9 @@ -use egui::*; +use egui::{scroll_area::ScrollBarVisibility, *}; #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[derive(Clone, Copy, Debug, PartialEq)] enum ScrollDemo { + ScrollAppearance, ScrollTo, ManyLines, LargeCanvas, @@ -12,7 +13,7 @@ enum ScrollDemo { impl Default for ScrollDemo { fn default() -> Self { - Self::ScrollTo + Self::ScrollAppearance } } @@ -20,6 +21,7 @@ impl Default for ScrollDemo { #[cfg_attr(feature = "serde", serde(default))] #[derive(Default, PartialEq)] pub struct Scrolling { + appearance: ScrollAppearance, demo: ScrollDemo, scroll_to: ScrollTo, scroll_stick_to: ScrollStickTo, @@ -33,7 +35,9 @@ impl super::Demo for Scrolling { fn show(&mut self, ctx: &egui::Context, open: &mut bool) { egui::Window::new(self.name()) .open(open) - .resizable(false) + .resizable(true) + .hscroll(false) + .vscroll(false) .show(ctx, |ui| { use super::View as _; self.ui(ui); @@ -44,6 +48,7 @@ impl super::Demo for Scrolling { impl super::View for Scrolling { fn ui(&mut self, ui: &mut Ui) { ui.horizontal(|ui| { + ui.selectable_value(&mut self.demo, ScrollDemo::ScrollAppearance, "Appearance"); ui.selectable_value(&mut self.demo, ScrollDemo::ScrollTo, "Scroll to"); ui.selectable_value( &mut self.demo, @@ -60,6 +65,9 @@ impl super::View for Scrolling { }); ui.separator(); match self.demo { + ScrollDemo::ScrollAppearance => { + self.appearance.ui(ui); + } ScrollDemo::ScrollTo => { self.scroll_to.ui(ui); } @@ -84,6 +92,75 @@ impl super::View for Scrolling { } } +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(default))] +#[derive(PartialEq)] +struct ScrollAppearance { + num_lorem_ipsums: usize, + visibility: ScrollBarVisibility, +} + +impl Default for ScrollAppearance { + fn default() -> Self { + Self { + num_lorem_ipsums: 2, + visibility: ScrollBarVisibility::default(), + } + } +} + +impl ScrollAppearance { + fn ui(&mut self, ui: &mut egui::Ui) { + let Self { + num_lorem_ipsums, + visibility, + } = self; + + let mut style: Style = (*ui.ctx().style()).clone(); + + style.spacing.scroll.ui(ui); + + ui.add_space(8.0); + + ui.horizontal(|ui| { + ui.label("ScrollBarVisibility:"); + for option in ScrollBarVisibility::ALL { + ui.selectable_value(visibility, option, format!("{option:?}")); + } + }); + ui.weak("When to show scroll bars; resize the window to see the effect."); + + ui.add_space(8.0); + + ui.ctx().set_style(style.clone()); + ui.set_style(style); + + ui.separator(); + + ui.add( + egui::Slider::new(num_lorem_ipsums, 1..=100) + .text("Content length") + .logarithmic(true), + ); + + ui.separator(); + + ScrollArea::vertical() + .auto_shrink([false; 2]) + .scroll_bar_visibility(*visibility) + .show(ui, |ui| { + ui.with_layout( + egui::Layout::top_down(egui::Align::LEFT).with_cross_justify(true), + |ui| { + for _ in 0..*num_lorem_ipsums { + ui.label(crate::LOREM_IPSUM_LONG); + } + }, + ); + }); + } +} + fn huge_content_lines(ui: &mut egui::Ui) { ui.label( "A lot of rows, but only the visible ones are laid out, so performance is still good:", diff --git a/crates/egui_extras/src/table.rs b/crates/egui_extras/src/table.rs index c687a7f1c35..ca09b385931 100644 --- a/crates/egui_extras/src/table.rs +++ b/crates/egui_extras/src/table.rs @@ -366,9 +366,9 @@ impl<'a> TableBuilder<'a> { fn available_width(&self) -> f32 { self.ui.available_rect_before_wrap().width() - if self.scroll_options.vscroll { - self.ui.spacing().scroll_bar_inner_margin - + self.ui.spacing().scroll_bar_width - + self.ui.spacing().scroll_bar_outer_margin + self.ui.spacing().scroll.bar_inner_margin + + self.ui.spacing().scroll.bar_width + + self.ui.spacing().scroll.bar_outer_margin } else { 0.0 } diff --git a/crates/emath/src/rect.rs b/crates/emath/src/rect.rs index f2d037cae72..8a5670fdf02 100644 --- a/crates/emath/src/rect.rs +++ b/crates/emath/src/rect.rs @@ -150,6 +150,34 @@ impl Rect { rect } + #[inline] + #[must_use] + pub fn with_min_x(mut self, min_x: f32) -> Self { + self.min.x = min_x; + self + } + + #[inline] + #[must_use] + pub fn with_min_y(mut self, min_y: f32) -> Self { + self.min.y = min_y; + self + } + + #[inline] + #[must_use] + pub fn with_max_x(mut self, max_x: f32) -> Self { + self.max.x = max_x; + self + } + + #[inline] + #[must_use] + pub fn with_max_y(mut self, max_y: f32) -> Self { + self.max.y = max_y; + self + } + /// Expand by this much in each direction, keeping the center #[must_use] pub fn expand(self, amnt: f32) -> Self { diff --git a/crates/emath/src/vec2.rs b/crates/emath/src/vec2.rs index 133c3fd3c20..0e0a9373572 100644 --- a/crates/emath/src/vec2.rs +++ b/crates/emath/src/vec2.rs @@ -274,6 +274,16 @@ impl Vec2 { self.x.max(self.y) } + /// Swizzle the axes. + #[inline] + #[must_use] + pub fn yx(self) -> Vec2 { + Vec2 { + x: self.y, + y: self.x, + } + } + #[must_use] #[inline] pub fn clamp(self, min: Self, max: Self) -> Self { diff --git a/crates/epaint/src/tessellator.rs b/crates/epaint/src/tessellator.rs index 49cbd31198f..9771362ba37 100644 --- a/crates/epaint/src/tessellator.rs +++ b/crates/epaint/src/tessellator.rs @@ -535,6 +535,7 @@ pub mod path { add_circle_quadrant(path, pos2(min.x + r.sw, max.y - r.sw), r.sw, 1.0); add_circle_quadrant(path, pos2(min.x + r.nw, min.y + r.nw), r.nw, 2.0); add_circle_quadrant(path, pos2(max.x - r.ne, min.y + r.ne), r.ne, 3.0); + path.dedup(); // We get duplicates for thin rectangles, producing visual artifats } }