From 1b8737cf0291a820449ec9ae84c703828148bee3 Mon Sep 17 00:00:00 2001 From: lucasmerlin Date: Thu, 19 Sep 2024 11:55:21 +0200 Subject: [PATCH] Interactive `Ui`:s: add `UiBuilder::sense` and `Ui::response` (#5054) * Closes #5053 * [x] I have followed the instructions in the PR template This fixes #5053 by adding a Sense parameter to UiBuilder, using that in Context::create_widget, so the Widget is registered with the right Sense / focusable. Additionally, I've added a ignore_focus param to create_widget, so the focus isn't surrendered / reregistered on Ui::interact_bg. The example from #5053 now works correctly: https://github.com/user-attachments/assets/a8a04b5e-7635-4e05-9ed8-e17d64910a35
Updated example code

```rust ui.button("I can focus"); ui.scope_builder( UiBuilder::new() .sense(Sense::click()) .id_source("focus_test"), |ui| { ui.label("I can focus for a single frame"); let response = ui.interact_bg(); let t = if response.has_focus() { "has focus" } else { "doesn't have focus" }; ui.label(t); }, ); ui.button("I can't focus :("); ```

--- Also, I've added `Ui::interact_scope` to make it easier to read a Ui's response in advance, without having to know about the internals of how the Ui Ids get created. This makes it really easy to created interactive container elements or custom buttons, without having to use Galleys or Painter::add(Shape::Noop) to style based on the interaction.
Example usage to create a simple button

```rust use eframe::egui; use eframe::egui::{Frame, InnerResponse, Label, RichText, UiBuilder, Widget}; use eframe::NativeOptions; use egui::{CentralPanel, Sense, WidgetInfo}; pub fn main() -> eframe::Result { eframe::run_simple_native("focus test", NativeOptions::default(), |ctx, _frame| { CentralPanel::default().show(ctx, |ui| { ui.button("Regular egui Button"); custom_button(ui, |ui| { ui.label("Custom Button"); }); if custom_button(ui, |ui| { ui.label("You can even have buttons inside buttons:"); if ui.button("button inside button").clicked() { println!("Button inside button clicked!"); } }) .response .clicked() { println!("Custom button clicked!"); } }); }) } fn custom_button( ui: &mut egui::Ui, content: impl FnOnce(&mut egui::Ui) -> R, ) -> InnerResponse { let auto_id = ui.next_auto_id(); ui.skip_ahead_auto_ids(1); let response = ui.interact_scope( Sense::click(), UiBuilder::new().id_source(auto_id), |ui, response| { ui.style_mut().interaction.selectable_labels = false; let visuals = response .map(|r| ui.style().interact(&r)) .unwrap_or(&ui.visuals().noninteractive()); let text_color = visuals.text_color(); Frame::none() .fill(visuals.bg_fill) .stroke(visuals.bg_stroke) .rounding(visuals.rounding) .inner_margin(ui.spacing().button_padding) .show(ui, |ui| { ui.visuals_mut().override_text_color = Some(text_color); content(ui) }) .inner }, ); response .response .widget_info(|| WidgetInfo::new(egui::WidgetType::Button)); response } ```

https://github.com/user-attachments/assets/281bd65f-f616-4621-9764-18fd0d07698b --------- Co-authored-by: Emil Ernerfeldt --- crates/egui/src/containers/area.rs | 19 +-- crates/egui/src/containers/window.rs | 19 +-- crates/egui/src/context.rs | 13 +- crates/egui/src/menu.rs | 2 +- crates/egui/src/response.rs | 19 +-- crates/egui/src/ui.rs | 136 ++++++++++++++---- crates/egui/src/ui_builder.rs | 15 +- crates/egui/src/widget_rect.rs | 4 +- .../src/demo/demo_app_windows.rs | 4 +- .../src/demo/interactive_container.rs | 87 +++++++++++ crates/egui_demo_lib/src/demo/mod.rs | 1 + 11 files changed, 258 insertions(+), 61 deletions(-) create mode 100644 crates/egui_demo_lib/src/demo/interactive_container.rs diff --git a/crates/egui/src/containers/area.rs b/crates/egui/src/containers/area.rs index 38d84ca7..fa8e5fc2 100644 --- a/crates/egui/src/containers/area.rs +++ b/crates/egui/src/containers/area.rs @@ -462,14 +462,17 @@ impl Area { } }); - let move_response = ctx.create_widget(WidgetRect { - id: interact_id, - layer_id, - rect: state.rect(), - interact_rect: state.rect(), - sense, - enabled, - }); + let move_response = ctx.create_widget( + WidgetRect { + id: interact_id, + layer_id, + rect: state.rect(), + interact_rect: state.rect(), + sense, + enabled, + }, + true, + ); if movable && move_response.dragged() { if let Some(pivot_pos) = &mut state.pivot_pos { diff --git a/crates/egui/src/containers/window.rs b/crates/egui/src/containers/window.rs index c183a4dc..438d562e 100644 --- a/crates/egui/src/containers/window.rs +++ b/crates/egui/src/containers/window.rs @@ -833,14 +833,17 @@ fn resize_interaction( } let is_dragging = |rect, id| { - let response = ctx.create_widget(WidgetRect { - layer_id, - id, - rect, - interact_rect: rect, - sense: Sense::drag(), - enabled: true, - }); + let response = ctx.create_widget( + WidgetRect { + layer_id, + id, + rect, + interact_rect: rect, + sense: Sense::drag(), + enabled: true, + }, + true, + ); SideResponse { hover: response.hovered(), drag: response.dragged(), diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 6f1a53db..b1bfb14b 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -1114,8 +1114,11 @@ impl Context { /// You should use [`Ui::interact`] instead. /// /// If the widget already exists, its state (sense, Rect, etc) will be updated. + /// + /// `allow_focus` should usually be true, unless you call this function multiple times with the + /// same widget, then `allow_focus` should only be true once (like in [`Ui::new`] (true) and [`Ui::remember_min_rect`] (false)). #[allow(clippy::too_many_arguments)] - pub(crate) fn create_widget(&self, w: WidgetRect) -> Response { + pub(crate) fn create_widget(&self, w: WidgetRect, allow_focus: bool) -> Response { // Remember this widget self.write(|ctx| { let viewport = ctx.viewport(); @@ -1125,12 +1128,12 @@ impl Context { // but also to know when we have reached the widget we are checking for cover. viewport.this_pass.widgets.insert(w.layer_id, w); - if w.sense.focusable { + if allow_focus && w.sense.focusable { ctx.memory.interested_in_focus(w.id); } }); - if !w.enabled || !w.sense.focusable || !w.layer_id.allow_interaction() { + if allow_focus && (!w.enabled || !w.sense.focusable || !w.layer_id.allow_interaction()) { // Not interested or allowed input: self.memory_mut(|mem| mem.surrender_focus(w.id)); } @@ -1143,7 +1146,7 @@ impl Context { let res = self.get_response(w); #[cfg(feature = "accesskit")] - if w.sense.focusable { + if allow_focus && w.sense.focusable { // Make sure anything that can receive focus has an AccessKit node. // TODO(mwcampbell): For nodes that are filled from widget info, // some information is written to the node twice. @@ -1179,7 +1182,7 @@ impl Context { } /// Do all interaction for an existing widget, without (re-)registering it. - fn get_response(&self, widget_rect: WidgetRect) -> Response { + pub(crate) fn get_response(&self, widget_rect: WidgetRect) -> Response { let WidgetRect { id, layer_id, diff --git a/crates/egui/src/menu.rs b/crates/egui/src/menu.rs index 0d586df2..8faf8e77 100644 --- a/crates/egui/src/menu.rs +++ b/crates/egui/src/menu.rs @@ -706,7 +706,7 @@ impl MenuState { self.open_submenu(sub_id, pos); } else if open - && ui.interact_bg(Sense::hover()).contains_pointer() + && ui.response().contains_pointer() && !button.hovered() && !self.hovering_current_submenu(&pointer) { diff --git a/crates/egui/src/response.rs b/crates/egui/src/response.rs index e2747dd7..dd4c631f 100644 --- a/crates/egui/src/response.rs +++ b/crates/egui/src/response.rs @@ -875,14 +875,17 @@ impl Response { return self.clone(); } - self.ctx.create_widget(WidgetRect { - layer_id: self.layer_id, - id: self.id, - rect: self.rect, - interact_rect: self.interact_rect, - sense: self.sense | sense, - enabled: self.enabled, - }) + self.ctx.create_widget( + WidgetRect { + layer_id: self.layer_id, + id: self.id, + rect: self.rect, + interact_rect: self.interact_rect, + sense: self.sense | sense, + enabled: self.enabled, + }, + true, + ) } /// Adjust the scroll position until this UI becomes visible. diff --git a/crates/egui/src/ui.rs b/crates/egui/src/ui.rs index eb2871ef..97a45ef7 100644 --- a/crates/egui/src/ui.rs +++ b/crates/egui/src/ui.rs @@ -90,6 +90,14 @@ pub struct Ui { /// The [`UiStack`] for this [`Ui`]. stack: Arc, + + /// The sense for the ui background. + sense: Sense, + + /// Whether [`Ui::remember_min_rect`] should be called when the [`Ui`] is dropped. + /// This is an optimization, so we don't call [`Ui::remember_min_rect`] multiple times at the + /// end of a [`Ui::scope`]. + min_rect_already_remembered: bool, } impl Ui { @@ -110,6 +118,7 @@ impl Ui { invisible, sizing_pass, style, + sense, } = ui_builder; debug_assert!( @@ -122,6 +131,7 @@ impl Ui { let layout = layout.unwrap_or_default(); let disabled = disabled || invisible; let style = style.unwrap_or_else(|| ctx.style()); + let sense = sense.unwrap_or(Sense::hover()); let placer = Placer::new(max_rect, layout); let ui_stack = UiStack { @@ -142,18 +152,23 @@ impl Ui { sizing_pass, menu_state: None, stack: Arc::new(ui_stack), + sense, + min_rect_already_remembered: false, }; // Register in the widget stack early, to ensure we are behind all widgets we contain: let start_rect = Rect::NOTHING; // This will be overwritten when/if `interact_bg` is called - ui.ctx().create_widget(WidgetRect { - id: ui.id, - layer_id: ui.layer_id(), - rect: start_rect, - interact_rect: start_rect, - sense: Sense::hover(), - enabled: ui.enabled, - }); + ui.ctx().create_widget( + WidgetRect { + id: ui.id, + layer_id: ui.layer_id(), + rect: start_rect, + interact_rect: start_rect, + sense, + enabled: ui.enabled, + }, + true, + ); if disabled { ui.disable(); @@ -217,6 +232,7 @@ impl Ui { invisible, sizing_pass, style, + sense, } = ui_builder; let mut painter = self.painter.clone(); @@ -230,6 +246,7 @@ impl Ui { } let sizing_pass = self.sizing_pass || sizing_pass; let style = style.unwrap_or_else(|| self.style.clone()); + let sense = sense.unwrap_or(Sense::hover()); if self.sizing_pass { // During the sizing pass we want widgets to use up as little space as possible, @@ -265,18 +282,23 @@ impl Ui { sizing_pass, menu_state: self.menu_state.clone(), stack: Arc::new(ui_stack), + sense, + min_rect_already_remembered: false, }; // Register in the widget stack early, to ensure we are behind all widgets we contain: let start_rect = Rect::NOTHING; // This will be overwritten when/if `interact_bg` is called - child_ui.ctx().create_widget(WidgetRect { - id: child_ui.id, - layer_id: child_ui.layer_id(), - rect: start_rect, - interact_rect: start_rect, - sense: Sense::hover(), - enabled: child_ui.enabled, - }); + child_ui.ctx().create_widget( + WidgetRect { + id: child_ui.id, + layer_id: child_ui.layer_id(), + rect: start_rect, + interact_rect: start_rect, + sense, + enabled: child_ui.enabled, + }, + true, + ); child_ui } @@ -972,14 +994,17 @@ impl Ui { impl Ui { /// Check for clicks, drags and/or hover on a specific region of this [`Ui`]. pub fn interact(&self, rect: Rect, id: Id, sense: Sense) -> Response { - self.ctx().create_widget(WidgetRect { - id, - layer_id: self.layer_id(), - rect, - interact_rect: self.clip_rect().intersect(rect), - sense, - enabled: self.enabled, - }) + self.ctx().create_widget( + WidgetRect { + id, + layer_id: self.layer_id(), + rect, + interact_rect: self.clip_rect().intersect(rect), + sense, + enabled: self.enabled, + }, + true, + ) } /// Deprecated: use [`Self::interact`] instead. @@ -994,10 +1019,62 @@ impl Ui { self.interact(rect, id, sense) } + /// Read the [`Ui`]s background [`Response`]. + /// It's [`Sense`] will be based on the [`UiBuilder::sense`] used to create this [`Ui`]. + /// + /// The rectangle of the [`Response`] (and interactive area) will be [`Self::min_rect`] + /// of the last frame. + /// + /// On the first frame, when the [`Ui`] is created, this will return a [`Response`] with a + /// [`Rect`] of [`Rect::NOTHING`]. + pub fn response(&self) -> Response { + // This is the inverse of Context::read_response. We prefer a response + // based on last frame's widget rect since the one from this frame is Rect::NOTHING until + // Ui::interact_bg is called or the Ui is dropped. + self.ctx() + .viewport(|viewport| { + viewport + .prev_frame + .widgets + .get(self.id) + .or_else(|| viewport.this_frame.widgets.get(self.id)) + .copied() + }) + .map(|widget_rect| self.ctx().get_response(widget_rect)) + .expect( + "Since we always call Context::create_widget in Ui::new, this should never be None", + ) + } + + /// Update the [`WidgetRect`] created in [`Ui::new`] or [`Ui::new_child`] with the current + /// [`Ui::min_rect`]. + fn remember_min_rect(&mut self) -> Response { + self.min_rect_already_remembered = true; + // We remove the id from used_ids to prevent a duplicate id warning from showing + // when the ui was created with `UiBuilder::sense`. + // This is a bit hacky, is there a better way? + self.ctx().frame_state_mut(|fs| { + fs.used_ids.remove(&self.id); + }); + // This will update the WidgetRect that was first created in `Ui::new`. + self.ctx().create_widget( + WidgetRect { + id: self.id, + layer_id: self.layer_id(), + rect: self.min_rect(), + interact_rect: self.clip_rect().intersect(self.min_rect()), + sense: self.sense, + enabled: self.enabled, + }, + false, + ) + } + /// Interact with the background of this [`Ui`], /// i.e. behind all the widgets. /// /// The rectangle of the [`Response`] (and interactive area) will be [`Self::min_rect`]. + #[deprecated = "Use UiBuilder::sense with Ui::response instead"] pub fn interact_bg(&self, sense: Sense) -> Response { // This will update the WidgetRect that was first created in `Ui::new`. self.interact(self.min_rect(), self.id, sense) @@ -1020,7 +1097,7 @@ impl Ui { /// /// Note that this tests against the _current_ [`Ui::min_rect`]. /// If you want to test against the final `min_rect`, - /// use [`Self::interact_bg`] instead. + /// use [`Self::response`] instead. pub fn ui_contains_pointer(&self) -> bool { self.rect_contains_pointer(self.min_rect()) } @@ -2168,7 +2245,8 @@ impl Ui { let mut child_ui = self.new_child(ui_builder); self.next_auto_id_salt = next_auto_id_salt; // HACK: we want `scope` to only increment this once, so that `ui.scope` is equivalent to `ui.allocate_space`. let ret = add_contents(&mut child_ui); - let response = self.allocate_rect(child_ui.min_rect(), Sense::hover()); + let response = child_ui.remember_min_rect(); + self.allocate_rect(child_ui.min_rect(), Sense::hover()); InnerResponse::new(ret, response) } @@ -2861,9 +2939,13 @@ impl Ui { } } -#[cfg(debug_assertions)] impl Drop for Ui { fn drop(&mut self) { + if !self.min_rect_already_remembered { + // Register our final `min_rect` + self.remember_min_rect(); + } + #[cfg(debug_assertions)] register_rect(self, self.min_rect()); } } diff --git a/crates/egui/src/ui_builder.rs b/crates/egui/src/ui_builder.rs index 5cc8200b..33a48dbd 100644 --- a/crates/egui/src/ui_builder.rs +++ b/crates/egui/src/ui_builder.rs @@ -1,6 +1,6 @@ use std::{hash::Hash, sync::Arc}; -use crate::{Id, Layout, Rect, Style, UiStackInfo}; +use crate::{Id, Layout, Rect, Sense, Style, UiStackInfo}; #[allow(unused_imports)] // Used for doclinks use crate::Ui; @@ -21,6 +21,7 @@ pub struct UiBuilder { pub invisible: bool, pub sizing_pass: bool, pub style: Option>, + pub sense: Option, } impl UiBuilder { @@ -114,4 +115,16 @@ impl UiBuilder { self.style = Some(style.into()); self } + + /// Set if you want sense clicks and/or drags. Default is [`Sense::hover`]. + /// The sense will be registered below the Senses of any widgets contained in this [`Ui`], so + /// if the user clicks a button contained within this [`Ui`], that button will receive the click + /// instead. + /// + /// The response can be read early with [`Ui::response`]. + #[inline] + pub fn sense(mut self, sense: Sense) -> Self { + self.sense = Some(sense); + self + } } diff --git a/crates/egui/src/widget_rect.rs b/crates/egui/src/widget_rect.rs index 7be3897d..e69badb8 100644 --- a/crates/egui/src/widget_rect.rs +++ b/crates/egui/src/widget_rect.rs @@ -44,8 +44,8 @@ pub struct WidgetRect { /// Stores the [`WidgetRect`]s of all widgets generated during a single egui update/frame. /// -/// All [`crate::Ui`]s have a [`WidgetRects`], but whether or not their rects are correct -/// depends on if [`crate::Ui::interact_bg`] was ever called. +/// All [`crate::Ui`]s have a [`WidgetRect`]. It is created in [`crate::Ui::new`] with [`Rect::NOTHING`] +/// and updated with the correct [`Rect`] when the [`crate::Ui`] is dropped. #[derive(Default, Clone)] pub struct WidgetRects { /// All widgets, in painting order. diff --git a/crates/egui_demo_lib/src/demo/demo_app_windows.rs b/crates/egui_demo_lib/src/demo/demo_app_windows.rs index 576a69e6..d55cc6af 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -31,6 +31,7 @@ impl Default for Demos { Box::::default(), Box::::default(), Box::::default(), + Box::::default(), Box::::default(), Box::::default(), Box::::default(), @@ -258,7 +259,8 @@ impl DemoWindows { fn desktop_ui(&mut self, ctx: &Context) { egui::SidePanel::right("egui_demo_panel") .resizable(false) - .default_width(150.0) + .default_width(160.0) + .min_width(160.0) .show(ctx, |ui| { ui.add_space(4.0); ui.vertical_centered(|ui| { diff --git a/crates/egui_demo_lib/src/demo/interactive_container.rs b/crates/egui_demo_lib/src/demo/interactive_container.rs new file mode 100644 index 00000000..11d8afa4 --- /dev/null +++ b/crates/egui_demo_lib/src/demo/interactive_container.rs @@ -0,0 +1,87 @@ +use egui::{Frame, Label, RichText, Sense, UiBuilder, Widget}; + +/// Showcase [`egui::Ui::response`]. +#[derive(PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(default))] +pub struct InteractiveContainerDemo { + count: usize, +} + +impl crate::Demo for InteractiveContainerDemo { + fn name(&self) -> &'static str { + "\u{20E3} Interactive Container" + } + + fn show(&mut self, ctx: &egui::Context, open: &mut bool) { + egui::Window::new(self.name()) + .open(open) + .resizable(false) + .default_width(250.0) + .show(ctx, |ui| { + use crate::View as _; + self.ui(ui); + }); + } +} + +impl crate::View for InteractiveContainerDemo { + fn ui(&mut self, ui: &mut egui::Ui) { + ui.vertical_centered(|ui| { + ui.add(crate::egui_github_link_file!()); + }); + + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.label("This demo showcases how to use "); + ui.code("Ui::response"); + ui.label(" to create interactive container widgets that may contain other widgets."); + }); + + let response = ui + .scope_builder( + UiBuilder::new() + .id_salt("interactive_container") + .sense(Sense::click()), + |ui| { + let response = ui.response(); + let visuals = ui.style().interact(&response); + let text_color = visuals.text_color(); + + Frame::canvas(ui.style()) + .fill(visuals.bg_fill.gamma_multiply(0.3)) + .stroke(visuals.bg_stroke) + .inner_margin(ui.spacing().menu_margin) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + + ui.add_space(32.0); + ui.vertical_centered(|ui| { + Label::new( + RichText::new(format!("{}", self.count)) + .color(text_color) + .size(32.0), + ) + .selectable(false) + .ui(ui); + }); + ui.add_space(32.0); + + ui.horizontal(|ui| { + if ui.button("Reset").clicked() { + self.count = 0; + } + if ui.button("+ 100").clicked() { + self.count += 100; + } + }); + }); + }, + ) + .response; + + if response.clicked() { + self.count += 1; + } + } +} diff --git a/crates/egui_demo_lib/src/demo/mod.rs b/crates/egui_demo_lib/src/demo/mod.rs index e5d7e595..828bdd89 100644 --- a/crates/egui_demo_lib/src/demo/mod.rs +++ b/crates/egui_demo_lib/src/demo/mod.rs @@ -15,6 +15,7 @@ pub mod extra_viewport; pub mod font_book; pub mod frame_demo; pub mod highlighting; +pub mod interactive_container; pub mod misc_demo_window; pub mod multi_touch; pub mod paint_bezier;