Allow overlapping interactive widgets (#2244)

* Turn off optimization for debug builds again

* Optimize rect_contains_pointer

* Fix for colorpicker: make popup immovable

* Area: interact first

* ScrollArea: do interaction first

* Window: shrink double-clickable area of titelbar

* Only the top-most (latest added) interactive widget gets `hovered=true`

* Add Frame::total_margin

* Update changelog

* Add debug-options to visualize what widgets cover which other widget
This commit is contained in:
Emil Ernerfeldt 2022-11-07 12:44:35 +01:00 committed by GitHub
parent 8c76b8caff
commit d5eb8779cb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 213 additions and 95 deletions

View file

@ -6,6 +6,7 @@ NOTE: [`epaint`](crates/epaint/CHANGELOG.md), [`eframe`](crates/eframe/CHANGELOG
## Unreleased ## Unreleased
* ⚠️ BREAKING: egui now expects integrations to do all color blending in gamma space ([#2071](https://github.com/emilk/egui/pull/2071)). * ⚠️ BREAKING: egui now expects integrations to do all color blending in gamma space ([#2071](https://github.com/emilk/egui/pull/2071)).
* ⚠️ BREAKING: if you have overlapping interactive widgets, only the top widget (last added) will be interactive ([#2244](https://github.com/emilk/egui/pull/2244)).
### Added ⭐ ### Added ⭐
* Added helper functions for animating panels that collapse/expand ([#2190](https://github.com/emilk/egui/pull/2190)). * Added helper functions for animating panels that collapse/expand ([#2190](https://github.com/emilk/egui/pull/2190)).
@ -15,7 +16,7 @@ NOTE: [`epaint`](crates/epaint/CHANGELOG.md), [`eframe`](crates/eframe/CHANGELOG
* Texture loading now takes a `TexureOptions` with minification and magnification filters ([#2224](https://github.com/emilk/egui/pull/2224)). * Texture loading now takes a `TexureOptions` with minification and magnification filters ([#2224](https://github.com/emilk/egui/pull/2224)).
* Added `Key::Minus` and `Key::Equals` ([#2239](https://github.com/emilk/egui/pull/2239)). * Added `Key::Minus` and `Key::Equals` ([#2239](https://github.com/emilk/egui/pull/2239)).
* Added `egui::gui_zoom` module with helpers for scaling the whole GUI of an app ([#2239](https://github.com/emilk/egui/pull/2239)). * Added `egui::gui_zoom` module with helpers for scaling the whole GUI of an app ([#2239](https://github.com/emilk/egui/pull/2239)).
* Implemented `Debug` for `egui::Context` ([#2248](https://github.com/emilk/egui/pull/2248)). * You can now put one interactive widget on top of another, and only one will get interaction at a time ([#2244](https://github.com/emilk/egui/pull/2244)).
### Fixed 🐛 ### Fixed 🐛
* ⚠️ BREAKING: Fix text being too small ([#2069](https://github.com/emilk/egui/pull/2069)). * ⚠️ BREAKING: Fix text being too small ([#2069](https://github.com/emilk/egui/pull/2069)).

View file

@ -27,7 +27,7 @@ opt-level = 2 # fast and small wasm, basically same as `opt-level = 's'`
[profile.dev] [profile.dev]
split-debuginfo = "unpacked" # faster debug builds on mac split-debuginfo = "unpacked" # faster debug builds on mac
opt-level = 1 # Make debug builds run faster # opt-level = 1 # Make debug builds run faster
# Optimize all dependencies even in debug builds (does not affect workspace packages): # Optimize all dependencies even in debug builds (does not affect workspace packages):
[profile.dev.package."*"] [profile.dev.package."*"]

View file

@ -169,7 +169,7 @@ impl Area {
pub(crate) struct Prepared { pub(crate) struct Prepared {
layer_id: LayerId, layer_id: LayerId,
state: State, state: State,
pub(crate) movable: bool, move_response: Response,
enabled: bool, enabled: bool,
drag_bounds: Option<Rect>, drag_bounds: Option<Rect>,
/// Set the first frame of new windows with anchors. /// Set the first frame of new windows with anchors.
@ -231,12 +231,53 @@ impl Area {
} }
} }
// interact right away to prevent frame-delay
let move_response = {
let interact_id = layer_id.id.with("move");
let sense = if movable {
Sense::click_and_drag()
} else {
Sense::click() // allow clicks to bring to front
};
let move_response = ctx.interact(
Rect::EVERYTHING,
ctx.style().spacing.item_spacing,
layer_id,
interact_id,
state.rect(),
sense,
enabled,
);
// Important check - don't try to move e.g. a combobox popup!
if movable {
if move_response.dragged() {
state.pos += ctx.input().pointer.delta();
}
state.pos = ctx
.constrain_window_rect_to_area(state.rect(), drag_bounds)
.min;
}
if (move_response.dragged() || move_response.clicked())
|| pointer_pressed_on_area(ctx, layer_id)
|| !ctx.memory().areas.visible_last_frame(&layer_id)
{
ctx.memory().areas.move_to_top(layer_id);
ctx.request_repaint();
}
move_response
};
state.pos = ctx.round_pos_to_pixels(state.pos); state.pos = ctx.round_pos_to_pixels(state.pos);
Prepared { Prepared {
layer_id, layer_id,
state, state,
movable, move_response,
enabled, enabled,
drag_bounds, drag_bounds,
temporarily_invisible, temporarily_invisible,
@ -330,49 +371,14 @@ impl Prepared {
let Prepared { let Prepared {
layer_id, layer_id,
mut state, mut state,
movable, move_response,
enabled, enabled: _,
drag_bounds, drag_bounds: _,
temporarily_invisible: _, temporarily_invisible: _,
} = self; } = self;
state.size = content_ui.min_rect().size(); state.size = content_ui.min_rect().size();
let interact_id = layer_id.id.with("move");
let sense = if movable {
Sense::click_and_drag()
} else {
Sense::click() // allow clicks to bring to front
};
let move_response = ctx.interact(
Rect::EVERYTHING,
ctx.style().spacing.item_spacing,
layer_id,
interact_id,
state.rect(),
sense,
enabled,
);
if move_response.dragged() && movable {
state.pos += ctx.input().pointer.delta();
}
// Important check - don't try to move e.g. a combobox popup!
if movable {
state.pos = ctx
.constrain_window_rect_to_area(state.rect(), drag_bounds)
.min;
}
if (move_response.dragged() || move_response.clicked())
|| pointer_pressed_on_area(ctx, layer_id)
|| !ctx.memory().areas.visible_last_frame(&layer_id)
{
ctx.memory().areas.move_to_top(layer_id);
ctx.request_repaint();
}
ctx.memory().areas.set_state(layer_id, state); ctx.memory().areas.set_state(layer_id, state);
move_response move_response

View file

@ -119,38 +119,45 @@ impl Frame {
} }
impl Frame { impl Frame {
#[inline]
pub fn fill(mut self, fill: Color32) -> Self { pub fn fill(mut self, fill: Color32) -> Self {
self.fill = fill; self.fill = fill;
self self
} }
#[inline]
pub fn stroke(mut self, stroke: Stroke) -> Self { pub fn stroke(mut self, stroke: Stroke) -> Self {
self.stroke = stroke; self.stroke = stroke;
self self
} }
#[inline]
pub fn rounding(mut self, rounding: impl Into<Rounding>) -> Self { pub fn rounding(mut self, rounding: impl Into<Rounding>) -> Self {
self.rounding = rounding.into(); self.rounding = rounding.into();
self self
} }
/// Margin within the painted frame. /// Margin within the painted frame.
#[inline]
pub fn inner_margin(mut self, inner_margin: impl Into<Margin>) -> Self { pub fn inner_margin(mut self, inner_margin: impl Into<Margin>) -> Self {
self.inner_margin = inner_margin.into(); self.inner_margin = inner_margin.into();
self self
} }
/// Margin outside the painted frame. /// Margin outside the painted frame.
#[inline]
pub fn outer_margin(mut self, outer_margin: impl Into<Margin>) -> Self { pub fn outer_margin(mut self, outer_margin: impl Into<Margin>) -> Self {
self.outer_margin = outer_margin.into(); self.outer_margin = outer_margin.into();
self self
} }
#[deprecated = "Renamed inner_margin in egui 0.18"] #[deprecated = "Renamed inner_margin in egui 0.18"]
#[inline]
pub fn margin(self, margin: impl Into<Margin>) -> Self { pub fn margin(self, margin: impl Into<Margin>) -> Self {
self.inner_margin(margin) self.inner_margin(margin)
} }
#[inline]
pub fn shadow(mut self, shadow: Shadow) -> Self { pub fn shadow(mut self, shadow: Shadow) -> Self {
self.shadow = shadow; self.shadow = shadow;
self self
@ -164,6 +171,16 @@ impl Frame {
} }
} }
impl Frame {
/// inner margin plus outer margin.
#[inline]
pub fn total_margin(&self) -> Margin {
self.inner_margin + self.outer_margin
}
}
// ----------------------------------------------------------------------------
pub struct Prepared { pub struct Prepared {
pub frame: Frame, pub frame: Frame,
where_to_put_background: ShapeIdx, where_to_put_background: ShapeIdx,

View file

@ -302,7 +302,7 @@ pub fn popup_below_widget<R>(
// Note: we use a separate clip-rect for this area, so the popup can be outside the parent. // Note: we use a separate clip-rect for this area, so the popup can be outside the parent.
// See https://github.com/emilk/egui/issues/825 // See https://github.com/emilk/egui/issues/825
let frame = Frame::popup(ui.style()); let frame = Frame::popup(ui.style());
let frame_margin = frame.inner_margin + frame.outer_margin; let frame_margin = frame.total_margin();
frame frame
.show(ui, |ui| { .show(ui, |ui| {
ui.with_layout(Layout::top_down_justified(Align::LEFT), |ui| { ui.with_layout(Layout::top_down_justified(Align::LEFT), |ui| {

View file

@ -14,8 +14,12 @@ pub struct State {
/// Positive offset means scrolling down/right /// Positive offset means scrolling down/right
pub offset: Vec2, pub offset: Vec2,
/// Were the scroll bars visible last frame?
show_scroll: [bool; 2], show_scroll: [bool; 2],
/// The content were to large to fit large frame.
content_is_too_large: [bool; 2],
/// Momentum, used for kinetic scrolling /// Momentum, used for kinetic scrolling
#[cfg_attr(feature = "serde", serde(skip))] #[cfg_attr(feature = "serde", serde(skip))]
vel: Vec2, vel: Vec2,
@ -34,6 +38,7 @@ impl Default for State {
Self { Self {
offset: Vec2::ZERO, offset: Vec2::ZERO,
show_scroll: [false; 2], show_scroll: [false; 2],
content_is_too_large: [false; 2],
vel: Vec2::ZERO, vel: Vec2::ZERO,
scroll_start_offset_from_top_left: [None; 2], scroll_start_offset_from_top_left: [None; 2],
scroll_stuck_to_end: [true; 2], scroll_stuck_to_end: [true; 2],
@ -406,6 +411,40 @@ impl ScrollArea {
let viewport = Rect::from_min_size(Pos2::ZERO + state.offset, inner_size); let viewport = Rect::from_min_size(Pos2::ZERO + state.offset, inner_size);
if scrolling_enabled && (state.content_is_too_large[0] || state.content_is_too_large[1]) {
// Drag contents to scroll (for touch screens mostly).
// We must do this BEFORE adding content to the `ScrollArea`,
// or we will steal input from the widgets we contain.
let content_response = ui.interact(inner_rect, id.with("area"), Sense::drag());
if content_response.dragged() {
for d in 0..2 {
if has_bar[d] {
state.offset[d] -= ui.input().pointer.delta()[d];
state.vel[d] = ui.input().pointer.velocity()[d];
state.scroll_stuck_to_end[d] = false;
} else {
state.vel[d] = 0.0;
}
}
} else {
let stop_speed = 20.0; // Pixels per second.
let friction_coeff = 1000.0; // Pixels per second squared.
let dt = ui.input().unstable_dt;
let friction = friction_coeff * dt;
if friction > state.vel.length() || state.vel.length() < stop_speed {
state.vel = Vec2::ZERO;
} else {
state.vel -= friction * state.vel.normalized();
// 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();
}
}
}
Prepared { Prepared {
id, id,
state, state,
@ -606,43 +645,6 @@ impl Prepared {
content_size.y > inner_rect.height(), content_size.y > inner_rect.height(),
]; ];
if content_is_too_large[0] || content_is_too_large[1] {
// Drag contents to scroll (for touch screens mostly):
let sense = if self.scrolling_enabled {
Sense::drag()
} else {
Sense::hover()
};
let content_response = ui.interact(inner_rect, id.with("area"), sense);
if content_response.dragged() {
for d in 0..2 {
if has_bar[d] {
state.offset[d] -= ui.input().pointer.delta()[d];
state.vel[d] = ui.input().pointer.velocity()[d];
state.scroll_stuck_to_end[d] = false;
} else {
state.vel[d] = 0.0;
}
}
} else {
let stop_speed = 20.0; // Pixels per second.
let friction_coeff = 1000.0; // Pixels per second squared.
let dt = ui.input().unstable_dt;
let friction = friction_coeff * dt;
if friction > state.vel.length() || state.vel.length() < stop_speed {
state.vel = Vec2::ZERO;
} else {
state.vel -= friction * state.vel.normalized();
// 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();
}
}
}
let max_offset = content_size - inner_rect.size(); let max_offset = content_size - inner_rect.size();
if scrolling_enabled && ui.rect_contains_pointer(outer_rect) { if scrolling_enabled && ui.rect_contains_pointer(outer_rect) {
for d in 0..2 { for d in 0..2 {
@ -837,6 +839,7 @@ impl Prepared {
]; ];
state.show_scroll = show_scroll_this_frame; state.show_scroll = show_scroll_this_frame;
state.content_is_too_large = content_is_too_large;
state.store(ui.ctx(), id); state.store(ui.ctx(), id);

View file

@ -881,8 +881,11 @@ impl TitleBar {
ui.painter().hline(outer_rect.x_range(), y, stroke); ui.painter().hline(outer_rect.x_range(), y, stroke);
} }
// Don't cover the close- and collapse buttons:
let double_click_rect = self.rect.shrink2(vec2(32.0, 0.0));
if ui if ui
.interact(self.rect, self.id, Sense::click()) .interact(double_click_rect, self.id, Sense::click())
.double_clicked() .double_clicked()
&& collapsible && collapsible
{ {

View file

@ -62,6 +62,11 @@ struct ContextImpl {
has_requested_repaint_this_frame: bool, has_requested_repaint_this_frame: bool,
requested_repaint_last_frame: bool, requested_repaint_last_frame: bool,
/// Written to during the frame.
layer_rects_this_frame: ahash::HashMap<LayerId, Vec<(Id, Rect)>>,
/// Read
layer_rects_prev_frame: ahash::HashMap<LayerId, Vec<(Id, Rect)>>,
} }
impl ContextImpl { impl ContextImpl {
@ -79,6 +84,8 @@ impl ContextImpl {
new_raw_input.screen_rect = Some(rect); new_raw_input.screen_rect = Some(rect);
} }
self.layer_rects_prev_frame = std::mem::take(&mut self.layer_rects_this_frame);
self.memory.begin_frame(&self.input, &new_raw_input); self.memory.begin_frame(&self.input, &new_raw_input);
self.input = std::mem::take(&mut self.input) self.input = std::mem::take(&mut self.input)
@ -328,8 +335,66 @@ impl Context {
(0.5 * item_spacing - Vec2::splat(gap)) (0.5 * item_spacing - Vec2::splat(gap))
.at_least(Vec2::splat(0.0)) .at_least(Vec2::splat(0.0))
.at_most(Vec2::splat(5.0)), .at_most(Vec2::splat(5.0)),
); // make it easier to click );
let hovered = self.rect_contains_pointer(layer_id, clip_rect.intersect(interact_rect));
// Respect clip rectangle when interacting
let interact_rect = clip_rect.intersect(interact_rect);
let mut hovered = self.rect_contains_pointer(layer_id, interact_rect);
// This solves the problem of overlapping widgets.
// Whichever widget is added LAST (=on top) gets the input:
if interact_rect.is_positive() && sense.interactive() {
if self.style().debug.show_interactive_widgets {
Self::layer_painter(self, LayerId::debug()).rect(
interact_rect,
0.0,
Color32::YELLOW.additive().linear_multiply(0.005),
Stroke::new(1.0, Color32::YELLOW.additive().linear_multiply(0.05)),
);
}
let mut slf = self.write();
slf.layer_rects_this_frame
.entry(layer_id)
.or_default()
.push((id, interact_rect));
if hovered {
let pointer_pos = slf.input.pointer.interact_pos();
if let Some(pointer_pos) = pointer_pos {
if let Some(rects) = slf.layer_rects_prev_frame.get(&layer_id) {
for &(prev_id, prev_rect) in rects.iter().rev() {
if prev_id == id {
break; // there is no other interactive widget covering us at the pointer position.
}
if prev_rect.contains(pointer_pos) {
// Another interactive widget is covering us at the pointer position,
// so we aren't hovered.
if slf.memory.options.style.debug.show_blocking_widget {
drop(slf);
Self::layer_painter(self, LayerId::debug()).debug_rect(
interact_rect,
Color32::GREEN,
"Covered",
);
Self::layer_painter(self, LayerId::debug()).debug_rect(
prev_rect,
Color32::LIGHT_BLUE,
"On top",
);
}
hovered = false;
break;
}
}
}
}
}
}
self.interact_with_hovered(layer_id, id, rect, sense, enabled, hovered) self.interact_with_hovered(layer_id, id, rect, sense, enabled, hovered)
} }
@ -1095,11 +1160,13 @@ impl Context {
} }
pub(crate) fn rect_contains_pointer(&self, layer_id: LayerId, rect: Rect) -> bool { pub(crate) fn rect_contains_pointer(&self, layer_id: LayerId, rect: Rect) -> bool {
let pointer_pos = self.input().pointer.interact_pos(); rect.is_positive() && {
if let Some(pointer_pos) = pointer_pos { let pointer_pos = self.input().pointer.interact_pos();
rect.contains(pointer_pos) && self.layer_id_at(pointer_pos) == Some(layer_id) if let Some(pointer_pos) = pointer_pos {
} else { rect.contains(pointer_pos) && self.layer_id_at(pointer_pos) == Some(layer_id)
false } else {
false
}
} }
} }

View file

@ -592,11 +592,20 @@ impl WidgetVisuals {
pub struct DebugOptions { pub struct DebugOptions {
/// However over widgets to see their rectangles /// However over widgets to see their rectangles
pub debug_on_hover: bool, pub debug_on_hover: bool,
/// Show which widgets make their parent wider /// Show which widgets make their parent wider
pub show_expand_width: bool, pub show_expand_width: bool,
/// Show which widgets make their parent higher /// Show which widgets make their parent higher
pub show_expand_height: bool, pub show_expand_height: bool,
pub show_resize: bool, pub show_resize: bool,
/// Show an overlay on all interactive widgets.
pub show_interactive_widgets: bool,
/// Show what widget blocks the interaction of another widget.
pub show_blocking_widget: bool,
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@ -1255,21 +1264,33 @@ impl DebugOptions {
pub fn ui(&mut self, ui: &mut crate::Ui) { pub fn ui(&mut self, ui: &mut crate::Ui) {
let Self { let Self {
debug_on_hover, debug_on_hover,
show_expand_width: debug_expand_width, show_expand_width,
show_expand_height: debug_expand_height, show_expand_height,
show_resize: debug_resize, show_resize,
show_interactive_widgets,
show_blocking_widget,
} = self; } = self;
ui.checkbox(debug_on_hover, "Show debug info on hover"); ui.checkbox(debug_on_hover, "Show debug info on hover");
ui.checkbox( ui.checkbox(
debug_expand_width, show_expand_width,
"Show which widgets make their parent wider", "Show which widgets make their parent wider",
); );
ui.checkbox( ui.checkbox(
debug_expand_height, show_expand_height,
"Show which widgets make their parent higher", "Show which widgets make their parent higher",
); );
ui.checkbox(debug_resize, "Debug Resize"); ui.checkbox(show_resize, "Debug Resize");
ui.checkbox(
show_interactive_widgets,
"Show an overlay on all interactive widgets",
);
ui.checkbox(
show_blocking_widget,
"Show wha widget blocks the interaction of another widget",
);
ui.vertical_centered(|ui| reset_button(ui, self)); ui.vertical_centered(|ui| reset_button(ui, self));
} }

View file

@ -354,7 +354,7 @@ pub fn color_edit_button_hsva(ui: &mut Ui, hsva: &mut Hsva, alpha: Alpha) -> Res
if ui.memory().is_popup_open(popup_id) { if ui.memory().is_popup_open(popup_id) {
let area_response = Area::new(popup_id) let area_response = Area::new(popup_id)
.order(Order::Foreground) .order(Order::Foreground)
.current_pos(button_response.rect.max) .fixed_pos(button_response.rect.max)
.show(ui.ctx(), |ui| { .show(ui.ctx(), |ui| {
ui.spacing_mut().slider_width = 210.0; ui.spacing_mut().slider_width = 210.0;
Frame::popup(ui.style()).show(ui, |ui| { Frame::popup(ui.style()).show(ui, |ui| {