Browse Source

Interactive `Ui`:s: add `UiBuilder::sense` and `Ui::response` (#5054)

<!--
Please read the "Making a PR" section of
[`CONTRIBUTING.md`](https://github.com/emilk/egui/blob/master/CONTRIBUTING.md)
before opening a Pull Request!

* Keep your PR:s small and focused.
* The PR title is what ends up in the changelog, so make it descriptive!
* If applicable, add a screenshot or gif.
* If it is a non-trivial addition, consider adding a demo for it to
`egui_demo_lib`, or a new example.
* Do NOT open PR:s from your `master` branch, as that makes it hard for
maintainers to test and add commits to your PR.
* Remember to run `cargo fmt` and `cargo clippy`.
* Open the PR as a draft until you have self-reviewed it and run
`./scripts/check.sh`.
* When you have addressed a PR comment, mark it as resolved.

Please be patient! I will review your PR, but my time is limited!
-->

* 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

<details><summary>Updated example code</summary>
<p>

```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 :(");
```

</p>
</details> 



---

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.

<details><summary>
Example usage to create a simple button
</summary>
<p>


```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<R>(
    ui: &mut egui::Ui,
    content: impl FnOnce(&mut egui::Ui) -> R,
) -> InnerResponse<R> {
    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
}
```

</p>
</details> 



https://github.com/user-attachments/assets/281bd65f-f616-4621-9764-18fd0d07698b

---------

Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
pull/5130/head
lucasmerlin 2 months ago
committed by GitHub
parent
commit
1b8737cf02
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 7
      crates/egui/src/containers/area.rs
  2. 7
      crates/egui/src/containers/window.rs
  3. 13
      crates/egui/src/context.rs
  4. 2
      crates/egui/src/menu.rs
  5. 7
      crates/egui/src/response.rs
  6. 104
      crates/egui/src/ui.rs
  7. 15
      crates/egui/src/ui_builder.rs
  8. 4
      crates/egui/src/widget_rect.rs
  9. 4
      crates/egui_demo_lib/src/demo/demo_app_windows.rs
  10. 87
      crates/egui_demo_lib/src/demo/interactive_container.rs
  11. 1
      crates/egui_demo_lib/src/demo/mod.rs

7
crates/egui/src/containers/area.rs

@ -462,14 +462,17 @@ impl Area {
}
});
let move_response = ctx.create_widget(WidgetRect {
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 {

7
crates/egui/src/containers/window.rs

@ -833,14 +833,17 @@ fn resize_interaction(
}
let is_dragging = |rect, id| {
let response = ctx.create_widget(WidgetRect {
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(),

13
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,

2
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)
{

7
crates/egui/src/response.rs

@ -875,14 +875,17 @@ impl Response {
return self.clone();
}
self.ctx.create_widget(WidgetRect {
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.

104
crates/egui/src/ui.rs

@ -90,6 +90,14 @@ pub struct Ui {
/// The [`UiStack`] for this [`Ui`].
stack: Arc<UiStack>,
/// 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 {
ui.ctx().create_widget(
WidgetRect {
id: ui.id,
layer_id: ui.layer_id(),
rect: start_rect,
interact_rect: start_rect,
sense: Sense::hover(),
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 {
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(),
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 {
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());
}
}

15
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<Arc<Style>>,
pub sense: Option<Sense>,
}
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
}
}

4
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.

4
crates/egui_demo_lib/src/demo/demo_app_windows.rs

@ -31,6 +31,7 @@ impl Default for Demos {
Box::<super::font_book::FontBook>::default(),
Box::<super::frame_demo::FrameDemo>::default(),
Box::<super::highlighting::Highlighting>::default(),
Box::<super::interactive_container::InteractiveContainerDemo>::default(),
Box::<super::MiscDemoWindow>::default(),
Box::<super::multi_touch::MultiTouch>::default(),
Box::<super::painting::Painting>::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| {

87
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;
}
}
}

1
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;

Loading…
Cancel
Save