From 54d00d7d6990dab35b4ec8083d08b42d247c952a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Pakalns?= Date: Thu, 6 Feb 2025 22:37:32 +0200 Subject: [PATCH 001/379] Fix panic when rendering thin textured rectangles (#5692) * Closes https://github.com/emilk/egui/issues/5664 * [x] I have followed the instructions in the PR template --- crates/epaint/src/tessellator.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/epaint/src/tessellator.rs b/crates/epaint/src/tessellator.rs index e8f8ad52c..6d953f257 100644 --- a/crates/epaint/src/tessellator.rs +++ b/crates/epaint/src/tessellator.rs @@ -1810,7 +1810,7 @@ impl Tessellator { } } - if stroke.is_empty() { + if stroke.is_empty() && out.texture_id == TextureId::default() { // Approximate thin rectangles with line segments. // This is important so that thin rectangles look good. if rect.width() <= 2.0 * self.feathering { From 81806c4b86677c691b74dc04ef9af9f20555682d Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Fri, 7 Feb 2025 09:22:10 +0100 Subject: [PATCH 002/379] Add badges to kittest README.md --- crates/egui_kittest/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/egui_kittest/README.md b/crates/egui_kittest/README.md index a9c1286bf..1cef1b0d6 100644 --- a/crates/egui_kittest/README.md +++ b/crates/egui_kittest/README.md @@ -1,5 +1,10 @@ # egui_kittest +[![Latest version](https://img.shields.io/crates/v/egui_kittest.svg)](https://crates.io/crates/egui_kittest) +[![Documentation](https://docs.rs/egui_kittest/badge.svg)](https://docs.rs/egui_kittest) +![MIT](https://img.shields.io/badge/license-MIT-blue.svg) +![Apache](https://img.shields.io/badge/license-Apache-blue.svg) + Ui testing library for egui, based on [kittest](https://github.com/rerun-io/kittest) (an [AccessKit](https://github.com/AccessKit/accesskit) based testing library). ## Example usage From 1c6e7b1bd0546ec6a7f354077e3e2e5c0d8b145a Mon Sep 17 00:00:00 2001 From: lucasmerlin Date: Mon, 10 Feb 2025 09:33:36 +0100 Subject: [PATCH 003/379] Fix modifiers not working in kittest (#5693) * Closes * [x] I have followed the instructions in the PR template It still isn't ideal, since you have to remember to call key_up on a separate frame. --------- Co-authored-by: Emil Ernerfeldt --- RELEASES.md | 4 ++- crates/egui/src/data/input.rs | 7 +++++ crates/egui_kittest/src/event.rs | 34 ++++++++++++++------- crates/egui_kittest/src/lib.rs | 34 ++++++++++++++++----- crates/egui_kittest/tests/tests.rs | 47 +++++++++++++++++++++++++++--- 5 files changed, 104 insertions(+), 22 deletions(-) diff --git a/RELEASES.md b/RELEASES.md index a8e629a03..34ef11463 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -74,13 +74,15 @@ I usually do this all on the `master` branch, but doing it in a release branch i (cd crates/egui && cargo publish --quiet) && echo "✅ egui" (cd crates/egui-winit && cargo publish --quiet) && echo "✅ egui-winit" (cd crates/egui-wgpu && cargo publish --quiet) && echo "✅ egui-wgpu" +(cd crates/eframe && cargo publish --quiet) && echo "✅ eframe" (cd crates/egui_kittest && cargo publish --quiet) && echo "✅ egui_kittest" (cd crates/egui_extras && cargo publish --quiet) && echo "✅ egui_extras" (cd crates/egui_demo_lib && cargo publish --quiet) && echo "✅ egui_demo_lib" (cd crates/egui_glow && cargo publish --quiet) && echo "✅ egui_glow" -(cd crates/eframe && cargo publish --quiet) && echo "✅ eframe" ``` +\ + ## Announcements * [ ] [Bluesky](https://bsky.app/profile/ernerfeldt.bsky.social) * [ ] egui discord diff --git a/crates/egui/src/data/input.rs b/crates/egui/src/data/input.rs index 0bced4270..d781d243e 100644 --- a/crates/egui/src/data/input.rs +++ b/crates/egui/src/data/input.rs @@ -943,6 +943,13 @@ impl std::ops::BitOr for Modifiers { } } +impl std::ops::BitOrAssign for Modifiers { + #[inline] + fn bitor_assign(&mut self, rhs: Self) { + *self = *self | rhs; + } +} + // ---------------------------------------------------------------------------- /// Names of different modifier keys. diff --git a/crates/egui_kittest/src/event.rs b/crates/egui_kittest/src/event.rs index 5ac07488d..c3045d623 100644 --- a/crates/egui_kittest/src/event.rs +++ b/crates/egui_kittest/src/event.rs @@ -4,12 +4,26 @@ use kittest::{ElementState, MouseButton, SimulatedEvent}; #[derive(Default)] pub(crate) struct EventState { - modifiers: Modifiers, last_mouse_pos: Pos2, } impl EventState { - pub fn kittest_event_to_egui(&mut self, event: kittest::Event) -> Option { + /// Map the kittest events to egui events, add them to the input and update the modifiers. + /// This function accesses `egui::RawInput::modifiers`. Make sure it is not reset after each + /// frame (Since we use [`egui::RawInput::take`], this should be fine). + pub fn update(&mut self, events: Vec, input: &mut egui::RawInput) { + for event in events { + if let Some(event) = self.kittest_event_to_egui(&mut input.modifiers, event) { + input.events.push(event); + } + } + } + + fn kittest_event_to_egui( + &mut self, + modifiers: &mut Modifiers, + event: kittest::Event, + ) -> Option { match event { kittest::Event::ActionRequest(e) => Some(Event::AccessKitActionRequest(e)), kittest::Event::Simulated(e) => match e { @@ -23,7 +37,7 @@ impl EventState { SimulatedEvent::MouseInput { state, button } => { pointer_button_to_egui(button).map(|button| PointerButton { button, - modifiers: self.modifiers, + modifiers: *modifiers, pos: self.last_mouse_pos, pressed: matches!(state, ElementState::Pressed), }) @@ -32,22 +46,22 @@ impl EventState { SimulatedEvent::KeyInput { state, key } => { match key { kittest::Key::Alt => { - self.modifiers.alt = matches!(state, ElementState::Pressed); + modifiers.alt = matches!(state, ElementState::Pressed); } kittest::Key::Command => { - self.modifiers.command = matches!(state, ElementState::Pressed); + modifiers.command = matches!(state, ElementState::Pressed); } kittest::Key::Control => { - self.modifiers.ctrl = matches!(state, ElementState::Pressed); + modifiers.ctrl = matches!(state, ElementState::Pressed); } kittest::Key::Shift => { - self.modifiers.shift = matches!(state, ElementState::Pressed); + modifiers.shift = matches!(state, ElementState::Pressed); } _ => {} } kittest_key_to_egui(key).map(|key| Event::Key { key, - modifiers: self.modifiers, + modifiers: *modifiers, pressed: matches!(state, ElementState::Pressed), repeat: false, physical_key: None, @@ -58,7 +72,7 @@ impl EventState { } } -pub fn kittest_key_to_egui(value: kittest::Key) -> Option { +fn kittest_key_to_egui(value: kittest::Key) -> Option { use egui::Key as EKey; use kittest::Key; match value { @@ -170,7 +184,7 @@ pub fn kittest_key_to_egui(value: kittest::Key) -> Option { } } -pub fn pointer_button_to_egui(value: MouseButton) -> Option { +fn pointer_button_to_egui(value: MouseButton) -> Option { match value { MouseButton::Left => Some(egui::PointerButton::Primary), MouseButton::Right => Some(egui::PointerButton::Secondary), diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index 661cb92c3..9bccd263e 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -227,11 +227,8 @@ impl<'a, State> Harness<'a, State> { } fn _step(&mut self, sizing_pass: bool) { - for event in self.kittest.take_events() { - if let Some(event) = self.event_state.kittest_event_to_egui(event) { - self.input.events.push(event); - } - } + self.event_state + .update(self.kittest.take_events(), &mut self.input); self.input.predicted_dt = self.step_dt; @@ -376,12 +373,32 @@ impl<'a, State> Harness<'a, State> { /// Press a key. /// This will create a key down event and a key up event. pub fn press_key(&mut self, key: egui::Key) { - self.press_key_modifiers(Modifiers::default(), key); + self.input.events.push(egui::Event::Key { + key, + pressed: true, + modifiers: self.input.modifiers, + repeat: false, + physical_key: None, + }); + self.input.events.push(egui::Event::Key { + key, + pressed: false, + modifiers: self.input.modifiers, + repeat: false, + physical_key: None, + }); } /// Press a key with modifiers. - /// This will create a key down event and a key up event. + /// This will create a key-down event, a key-up event, and update the modifiers. + /// + /// NOTE: In contrast to the event fns on [`Node`], this will call [`Harness::step`], in + /// order to properly update modifiers. pub fn press_key_modifiers(&mut self, modifiers: Modifiers, key: egui::Key) { + // Combine the modifiers with the current modifiers + let previous_modifiers = self.input.modifiers; + self.input.modifiers |= modifiers; + self.input.events.push(egui::Event::Key { key, pressed: true, @@ -389,6 +406,7 @@ impl<'a, State> Harness<'a, State> { repeat: false, physical_key: None, }); + self.step(); self.input.events.push(egui::Event::Key { key, pressed: false, @@ -396,6 +414,8 @@ impl<'a, State> Harness<'a, State> { repeat: false, physical_key: None, }); + + self.input.modifiers = previous_modifiers; } /// Render the last output to an image. diff --git a/crates/egui_kittest/tests/tests.rs b/crates/egui_kittest/tests/tests.rs index 29b4c7b11..4fa1239a7 100644 --- a/crates/egui_kittest/tests/tests.rs +++ b/crates/egui_kittest/tests/tests.rs @@ -1,4 +1,6 @@ -use egui_kittest::{Harness, SnapshotResults}; +use egui::Modifiers; +use egui_kittest::Harness; +use kittest::{Key, Queryable}; #[test] fn test_shrink() { @@ -10,8 +12,45 @@ fn test_shrink() { harness.fit_contents(); - let mut results = SnapshotResults::new(); - #[cfg(all(feature = "snapshot", feature = "wgpu"))] - results.add(harness.try_snapshot("test_shrink")); + harness.snapshot("test_shrink"); +} + +#[test] +fn test_modifiers() { + #[derive(Default)] + struct State { + cmd_clicked: bool, + cmd_z_pressed: bool, + } + let mut harness = Harness::new_ui_state( + |ui, state| { + if ui.button("Click me").clicked() && ui.input(|i| i.modifiers.command) { + state.cmd_clicked = true; + } + if ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Z)) { + state.cmd_z_pressed = true; + } + }, + State::default(), + ); + + harness.get_by_label("Click me").key_down(Key::Command); + // This run isn't necessary, but allows us to test whether modifiers are remembered between frames + harness.run(); + harness.get_by_label("Click me").click(); + // TODO(lucasmerlin): Right now the key_up needs to happen on a separate frame or it won't register. + // This should be more intuitive + harness.run(); + harness.get_by_label("Click me").key_up(Key::Command); + + harness.run(); + + harness.press_key_modifiers(Modifiers::COMMAND, egui::Key::Z); + // TODO(lucasmerlin): This should also work (Same problem as above) + // harness.node().key_combination(&[Key::Command, Key::Z]); + + let state = harness.state(); + assert!(state.cmd_clicked, "The button wasn't command-clicked"); + assert!(state.cmd_z_pressed, "Cmd+Z wasn't pressed"); } From 510b3cdf489500e68d5027e45934a0f68ed3b1fa Mon Sep 17 00:00:00 2001 From: lucasmerlin Date: Tue, 11 Feb 2025 11:23:59 +0100 Subject: [PATCH 004/379] Rename `Marginf` to `MarginF32` for consistency with `CornerRadiusF32` (#5677) * [x] I have followed the instructions in the PR template --- RELEASES.md | 1 + crates/egui/src/containers/frame.rs | 16 ++-- crates/epaint/src/lib.rs | 4 +- crates/epaint/src/margin.rs | 2 +- .../epaint/src/{marginf.rs => margin_f32.rs} | 79 ++++++++++--------- crates/epaint/src/shadow.rs | 6 +- 6 files changed, 56 insertions(+), 52 deletions(-) rename crates/epaint/src/{marginf.rs => margin_f32.rs} (79%) diff --git a/RELEASES.md b/RELEASES.md index 34ef11463..9d87988b7 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -45,6 +45,7 @@ We don't update the MSRV in a patch release, unless we really, really need to. * [ ] check that CI is green ## Preparation +* [ ] make sure there are no important unmerged PRs * [ ] run `scripts/generate_example_screenshots.sh` if needed * [ ] write a short release note that fits in a bluesky post * [ ] record gif for `CHANGELOG.md` release note (and later bluesky post) diff --git a/crates/egui/src/containers/frame.rs b/crates/egui/src/containers/frame.rs index 343897dcc..1fdbbca92 100644 --- a/crates/egui/src/containers/frame.rs +++ b/crates/egui/src/containers/frame.rs @@ -4,7 +4,7 @@ use crate::{ epaint, layers::ShapeIdx, InnerResponse, Response, Sense, Style, Ui, UiBuilder, UiKind, UiStackInfo, }; -use epaint::{Color32, CornerRadius, Margin, Marginf, Rect, Shadow, Shape, Stroke}; +use epaint::{Color32, CornerRadius, Margin, MarginF32, Rect, Shadow, Shape, Stroke}; /// A frame around some content, including margin, colors, etc. /// @@ -337,10 +337,10 @@ impl Frame { /// /// [`Self::inner_margin`] + [`Self.stroke`]`.width` + [`Self::outer_margin`]. #[inline] - pub fn total_margin(&self) -> Marginf { - Marginf::from(self.inner_margin) - + Marginf::from(self.stroke.width) - + Marginf::from(self.outer_margin) + pub fn total_margin(&self) -> MarginF32 { + MarginF32::from(self.inner_margin) + + MarginF32::from(self.stroke.width) + + MarginF32::from(self.outer_margin) } /// Calculate the `fill_rect` from the `content_rect`. @@ -354,14 +354,14 @@ impl Frame { /// /// This is the visible and interactive rectangle. pub fn widget_rect(&self, content_rect: Rect) -> Rect { - content_rect + self.inner_margin + Marginf::from(self.stroke.width) + content_rect + self.inner_margin + MarginF32::from(self.stroke.width) } /// Calculate the `outer_rect` from the `content_rect`. /// /// This is what is allocated in the outer [`Ui`], and is what is returned by [`Response::rect`]. pub fn outer_rect(&self, content_rect: Rect) -> Rect { - content_rect + self.inner_margin + Marginf::from(self.stroke.width) + self.outer_margin + content_rect + self.inner_margin + MarginF32::from(self.stroke.width) + self.outer_margin } } @@ -463,7 +463,7 @@ impl Prepared { let content_rect = self.content_ui.min_rect(); content_rect + self.frame.inner_margin - + Marginf::from(self.frame.stroke.width) + + MarginF32::from(self.frame.stroke.width) + self.frame.outer_margin } diff --git a/crates/epaint/src/lib.rs b/crates/epaint/src/lib.rs index ac0a90c60..f84a8caff 100644 --- a/crates/epaint/src/lib.rs +++ b/crates/epaint/src/lib.rs @@ -29,7 +29,7 @@ mod corner_radius; mod corner_radius_f32; pub mod image; mod margin; -mod marginf; +mod margin_f32; mod mesh; pub mod mutex; mod shadow; @@ -52,7 +52,7 @@ pub use self::{ corner_radius_f32::CornerRadiusF32, image::{ColorImage, FontImage, ImageData, ImageDelta}, margin::Margin, - marginf::Marginf, + margin_f32::*, mesh::{Mesh, Mesh16, Vertex}, shadow::Shadow, shapes::{ diff --git a/crates/epaint/src/margin.rs b/crates/epaint/src/margin.rs index e8fc530aa..2e87b7b30 100644 --- a/crates/epaint/src/margin.rs +++ b/crates/epaint/src/margin.rs @@ -9,7 +9,7 @@ use emath::{vec2, Rect, Vec2}; /// Use with care. /// /// All values are stored as [`i8`] to keep the size of [`Margin`] small. -/// If you want floats, use [`crate::Marginf`] instead. +/// If you want floats, use [`crate::MarginF32`] instead. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct Margin { diff --git a/crates/epaint/src/marginf.rs b/crates/epaint/src/margin_f32.rs similarity index 79% rename from crates/epaint/src/marginf.rs rename to crates/epaint/src/margin_f32.rs index eb3a1fbc9..fd88611d0 100644 --- a/crates/epaint/src/marginf.rs +++ b/crates/epaint/src/margin_f32.rs @@ -10,14 +10,17 @@ use crate::Margin; /// For storage, use [`crate::Margin`] instead. #[derive(Clone, Copy, Debug, Default, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub struct Marginf { +pub struct MarginF32 { pub left: f32, pub right: f32, pub top: f32, pub bottom: f32, } -impl From for Marginf { +#[deprecated = "Renamed to MarginF32"] +pub type Marginf = MarginF32; + +impl From for MarginF32 { #[inline] fn from(margin: Margin) -> Self { Self { @@ -29,9 +32,9 @@ impl From for Marginf { } } -impl From for Margin { +impl From for Margin { #[inline] - fn from(marginf: Marginf) -> Self { + fn from(marginf: MarginF32) -> Self { Self { left: marginf.left as _, right: marginf.right as _, @@ -41,7 +44,7 @@ impl From for Margin { } } -impl Marginf { +impl MarginF32 { pub const ZERO: Self = Self { left: 0.0, right: 0.0, @@ -108,22 +111,22 @@ impl Marginf { } } -impl From for Marginf { +impl From for MarginF32 { #[inline] fn from(v: f32) -> Self { Self::same(v) } } -impl From for Marginf { +impl From for MarginF32 { #[inline] fn from(v: Vec2) -> Self { Self::symmetric(v.x, v.y) } } -/// `Marginf + Marginf` -impl std::ops::Add for Marginf { +/// `MarginF32 + MarginF32` +impl std::ops::Add for MarginF32 { type Output = Self; #[inline] @@ -137,8 +140,8 @@ impl std::ops::Add for Marginf { } } -/// `Marginf + f32` -impl std::ops::Add for Marginf { +/// `MarginF32 + f32` +impl std::ops::Add for MarginF32 { type Output = Self; #[inline] @@ -153,7 +156,7 @@ impl std::ops::Add for Marginf { } /// `Margind += f32` -impl std::ops::AddAssign for Marginf { +impl std::ops::AddAssign for MarginF32 { #[inline] fn add_assign(&mut self, v: f32) { self.left += v; @@ -163,8 +166,8 @@ impl std::ops::AddAssign for Marginf { } } -/// `Marginf * f32` -impl std::ops::Mul for Marginf { +/// `MarginF32 * f32` +impl std::ops::Mul for MarginF32 { type Output = Self; #[inline] @@ -178,8 +181,8 @@ impl std::ops::Mul for Marginf { } } -/// `Marginf *= f32` -impl std::ops::MulAssign for Marginf { +/// `MarginF32 *= f32` +impl std::ops::MulAssign for MarginF32 { #[inline] fn mul_assign(&mut self, v: f32) { self.left *= v; @@ -189,8 +192,8 @@ impl std::ops::MulAssign for Marginf { } } -/// `Marginf / f32` -impl std::ops::Div for Marginf { +/// `MarginF32 / f32` +impl std::ops::Div for MarginF32 { type Output = Self; #[inline] @@ -204,8 +207,8 @@ impl std::ops::Div for Marginf { } } -/// `Marginf /= f32` -impl std::ops::DivAssign for Marginf { +/// `MarginF32 /= f32` +impl std::ops::DivAssign for MarginF32 { #[inline] fn div_assign(&mut self, v: f32) { self.left /= v; @@ -215,8 +218,8 @@ impl std::ops::DivAssign for Marginf { } } -/// `Marginf - Marginf` -impl std::ops::Sub for Marginf { +/// `MarginF32 - MarginF32` +impl std::ops::Sub for MarginF32 { type Output = Self; #[inline] @@ -230,8 +233,8 @@ impl std::ops::Sub for Marginf { } } -/// `Marginf - f32` -impl std::ops::Sub for Marginf { +/// `MarginF32 - f32` +impl std::ops::Sub for MarginF32 { type Output = Self; #[inline] @@ -245,8 +248,8 @@ impl std::ops::Sub for Marginf { } } -/// `Marginf -= f32` -impl std::ops::SubAssign for Marginf { +/// `MarginF32 -= f32` +impl std::ops::SubAssign for MarginF32 { #[inline] fn sub_assign(&mut self, v: f32) { self.left -= v; @@ -256,12 +259,12 @@ impl std::ops::SubAssign for Marginf { } } -/// `Rect + Marginf` -impl std::ops::Add for Rect { +/// `Rect + MarginF32` +impl std::ops::Add for Rect { type Output = Self; #[inline] - fn add(self, margin: Marginf) -> Self { + fn add(self, margin: MarginF32) -> Self { Self::from_min_max( self.min - margin.left_top(), self.max + margin.right_bottom(), @@ -269,20 +272,20 @@ impl std::ops::Add for Rect { } } -/// `Rect += Marginf` -impl std::ops::AddAssign for Rect { +/// `Rect += MarginF32` +impl std::ops::AddAssign for Rect { #[inline] - fn add_assign(&mut self, margin: Marginf) { + fn add_assign(&mut self, margin: MarginF32) { *self = *self + margin; } } -/// `Rect - Marginf` -impl std::ops::Sub for Rect { +/// `Rect - MarginF32` +impl std::ops::Sub for Rect { type Output = Self; #[inline] - fn sub(self, margin: Marginf) -> Self { + fn sub(self, margin: MarginF32) -> Self { Self::from_min_max( self.min + margin.left_top(), self.max - margin.right_bottom(), @@ -290,10 +293,10 @@ impl std::ops::Sub for Rect { } } -/// `Rect -= Marginf` -impl std::ops::SubAssign for Rect { +/// `Rect -= MarginF32` +impl std::ops::SubAssign for Rect { #[inline] - fn sub_assign(&mut self, margin: Marginf) { + fn sub_assign(&mut self, margin: MarginF32) { *self = *self - margin; } } diff --git a/crates/epaint/src/shadow.rs b/crates/epaint/src/shadow.rs index e05cbdbe4..ee010ee99 100644 --- a/crates/epaint/src/shadow.rs +++ b/crates/epaint/src/shadow.rs @@ -1,4 +1,4 @@ -use crate::{Color32, CornerRadius, Marginf, Rect, RectShape, Vec2}; +use crate::{Color32, CornerRadius, MarginF32, Rect, RectShape, Vec2}; /// The color and fuzziness of a fuzzy shape. /// @@ -64,7 +64,7 @@ impl Shadow { } /// How much larger than the parent rect are we in each direction? - pub fn margin(&self) -> Marginf { + pub fn margin(&self) -> MarginF32 { let Self { offset, blur, @@ -74,7 +74,7 @@ impl Shadow { let spread = spread as f32; let blur = blur as f32; let [offset_x, offset_y] = offset; - Marginf { + MarginF32 { left: spread + 0.5 * blur - offset_x as f32, right: spread + 0.5 * blur + offset_x as f32, top: spread + 0.5 * blur - offset_y as f32, From 982b2580f40bd300b7570b7ec8b5cc2889c4b25c Mon Sep 17 00:00:00 2001 From: YgorSouza <43298013+YgorSouza@users.noreply.github.com> Date: Wed, 12 Feb 2025 14:20:15 +0100 Subject: [PATCH 005/379] Enable all features for egui_kittest docs (#5711) - Enable all-features when generating docs - Add x11 feature so it builds on Linux - Add double hashes to the feature comments so document-features includes them in the docs * Closes * [x] I have followed the instructions in the PR template --- crates/egui_kittest/Cargo.toml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/egui_kittest/Cargo.toml b/crates/egui_kittest/Cargo.toml index 1e4af19e6..ef38ebd3b 100644 --- a/crates/egui_kittest/Cargo.toml +++ b/crates/egui_kittest/Cargo.toml @@ -18,8 +18,12 @@ include = ["../LICENSE-APACHE", "../LICENSE-MIT", "**/*.rs", "Cargo.toml"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--generate-link-to-definition"] + [features] -# Adds a wgpu-based test renderer. +## Adds a wgpu-based test renderer. wgpu = [ "dep:egui-wgpu", "dep:pollster", @@ -28,12 +32,15 @@ wgpu = [ "eframe?/wgpu", ] -# Adds a dify-based image snapshot utility. +## Adds a dify-based image snapshot utility. snapshot = ["dep:dify", "dep:image", "image/png"] -# Allows testing eframe::App +## Allows testing eframe::App eframe = ["dep:eframe", "eframe/accesskit"] +# This is just so it compiles with `--all-features` on Linux +x11 = ["eframe?/x11"] + [dependencies] kittest.workspace = true @@ -50,7 +57,7 @@ wgpu = { workspace = true, features = ["metal", "dx12"], optional = true } # snapshot dependencies dify = { workspace = true, optional = true } -## Enable this when generating docs. +# Enable this when generating docs. document-features = { workspace = true, optional = true } [dev-dependencies] From 08c5a641a17580fb6cfac947aaf95634018abeb7 Mon Sep 17 00:00:00 2001 From: lucasmerlin Date: Wed, 12 Feb 2025 14:20:50 +0100 Subject: [PATCH 006/379] Run a frame per queued event in egui_kittest (#5704) This should fix the remaining problems with the modifiers * [x] I have followed the instructions in the PR template --- crates/egui_kittest/src/event.rs | 10 ++++------ crates/egui_kittest/src/lib.rs | 17 +++++++++++------ crates/egui_kittest/tests/tests.rs | 15 +++++++++------ 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/crates/egui_kittest/src/event.rs b/crates/egui_kittest/src/event.rs index c3045d623..e756d4dc9 100644 --- a/crates/egui_kittest/src/event.rs +++ b/crates/egui_kittest/src/event.rs @@ -8,14 +8,12 @@ pub(crate) struct EventState { } impl EventState { - /// Map the kittest events to egui events, add them to the input and update the modifiers. + /// Map the kittest event to an egui event, add it to the input and update the modifiers. /// This function accesses `egui::RawInput::modifiers`. Make sure it is not reset after each /// frame (Since we use [`egui::RawInput::take`], this should be fine). - pub fn update(&mut self, events: Vec, input: &mut egui::RawInput) { - for event in events { - if let Some(event) = self.kittest_event_to_egui(&mut input.modifiers, event) { - input.events.push(event); - } + pub fn update(&mut self, event: kittest::Event, input: &mut egui::RawInput) { + if let Some(event) = self.kittest_event_to_egui(&mut input.modifiers, event) { + input.events.push(event); } } diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index 9bccd263e..59bd5c056 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -219,17 +219,22 @@ impl<'a, State> Harness<'a, State> { self } - /// Run a frame. - /// This will call the app closure with the queued events and current context and + /// Run a frame for each queued event (or a single frame if there are no events). + /// This will call the app closure with each queued event and /// update the Harness. pub fn step(&mut self) { - self._step(false); + let events = self.kittest.take_events(); + if events.is_empty() { + self._step(false); + } + for event in events { + self.event_state.update(event, &mut self.input); + self._step(false); + } } + /// Run a single step. This will not process any events. fn _step(&mut self, sizing_pass: bool) { - self.event_state - .update(self.kittest.take_events(), &mut self.input); - self.input.predicted_dt = self.step_dt; let mut output = self.ctx.run(self.input.take(), |ctx| { diff --git a/crates/egui_kittest/tests/tests.rs b/crates/egui_kittest/tests/tests.rs index 4fa1239a7..52f455c7b 100644 --- a/crates/egui_kittest/tests/tests.rs +++ b/crates/egui_kittest/tests/tests.rs @@ -22,6 +22,7 @@ fn test_modifiers() { struct State { cmd_clicked: bool, cmd_z_pressed: bool, + cmd_y_pressed: bool, } let mut harness = Harness::new_ui_state( |ui, state| { @@ -31,6 +32,9 @@ fn test_modifiers() { if ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Z)) { state.cmd_z_pressed = true; } + if ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Y)) { + state.cmd_y_pressed = true; + } }, State::default(), ); @@ -39,18 +43,17 @@ fn test_modifiers() { // This run isn't necessary, but allows us to test whether modifiers are remembered between frames harness.run(); harness.get_by_label("Click me").click(); - // TODO(lucasmerlin): Right now the key_up needs to happen on a separate frame or it won't register. - // This should be more intuitive - harness.run(); harness.get_by_label("Click me").key_up(Key::Command); - harness.run(); harness.press_key_modifiers(Modifiers::COMMAND, egui::Key::Z); - // TODO(lucasmerlin): This should also work (Same problem as above) - // harness.node().key_combination(&[Key::Command, Key::Z]); + harness.run(); + + harness.node().key_combination(&[Key::Command, Key::Y]); + harness.run(); let state = harness.state(); assert!(state.cmd_clicked, "The button wasn't command-clicked"); assert!(state.cmd_z_pressed, "Cmd+Z wasn't pressed"); + assert!(state.cmd_y_pressed, "Cmd+Y wasn't pressed"); } From 40f002fe3ff86061a92e8d1a96f3a7a6d4e8747c Mon Sep 17 00:00:00 2001 From: Andreas Reich Date: Tue, 18 Feb 2025 09:52:24 +0100 Subject: [PATCH 007/379] Add guidelines for image comparison tests (#5714) Guidelines & why images may differ Based on (but slightly altered): * https://github.com/rerun-io/rerun/pull/8989 --- crates/egui_kittest/README.md | 59 +++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/egui_kittest/README.md b/crates/egui_kittest/README.md index 1cef1b0d6..ff071c9f4 100644 --- a/crates/egui_kittest/README.md +++ b/crates/egui_kittest/README.md @@ -55,3 +55,62 @@ You should add the following to your `.gitignore`: **/tests/snapshots/**/*.diff.png **/tests/snapshots/**/*.new.png ``` + +### Guidelines for writing snapshot tests + +* Whenever **possible** prefer regular Rust tests or `insta` snapshot tests over image comparison tests because… + * …compared to regular Rust tests, they can be relatively slow to run + * …they are brittle since unrelated side effects (like a change in color) can cause the test to fail + * …images take up repo space +* images should… + * …be checked in or otherwise be available (egui use [git LFS](https://git-lfs.com/) files for this purpose) + * …depict exactly what's tested and nothing else + * …have a low resolution to avoid growth in repo size + * …have a low comparison threshold to avoid the test passing despite unwanted differences (the default threshold should be fine for most usecases!) + +### What do do when CI / another computer produces a different image? + +The default tolerance settings should be fine for almost all gui comparison tests. +However, especially when you're using custom rendering, you may observe images difference with different setups leading to unexpected test failures. + +First check whether the difference is due to a change in enabled rendering features, potentially due to difference in hardware (/software renderer) capabilitites. +Generally you should carefully enforcing the same set of features for all test runs, but this may happen nonetheless. + +Once you validated that the differences are miniscule and hard to avoid, you can try to _carefully_ adjust the comparison tolerance setting (`SnapshotOptions::threshold`, TODO([#5683](https://github.com/emilk/egui/issues/5683)): as well as number of pixels allowed to differ) for the specific test. + +⚠️ **WARNING** ⚠️ +Picking too high tolerances may mean that you are missing actual test failures. +It is recommended to manually verify that the tests still break under the right circumstances as expected after adjusting the tolerances. + +--- + +In order to avoid image differences, it can be useful to form an understanding of how they occur in the first place. + +Discrepancies can be caused by a variety of implementation details that depend on the concrete GPU, OS, rendering backend (Metal/Vulkan/DX12 etc.) or graphics driver (even between different versions of the same driver). + +Common issues include: +* multi-sample anti-aliasing + * sample placement and sample resolve steps are implementation defined + * alpha-to-coverage algorithm/pattern can wary wildly between implementations +* texture filtering + * different implementations may apply different optimizations *even* for simple linear texture filtering +* out of bounds texture access (via `textureLoad`) + * implementations are free to return indeterminate values instead of clamping +* floating point evaluation, for details see [WGSL spec § 15.7. Floating Point Evaluation](https://www.w3.org/TR/WGSL/#floating-point-evaluation). Notably: + * rounding mode may be inconsistent + * floating point math "optimizations" may occur + * depending on output shading language, different arithmetic optimizations may be performed upon floating point operations even if they change the result + * floating point denormal flush + * even on modern implementations, denormal float values may be flushed to zero + * `NaN`/`Inf` handling + * whenever the result of a function should yield `NaN`/`Inf`, implementations may free to yield an indeterminate value instead + * builtin-function function precision & error handling (trigonometric functions and others) +* [partial derivatives (dpdx/dpdx)](https://www.w3.org/TR/WGSL/#dpdx-builtin) + * implementations are free to use either `dpdxFine` or `dpdxCoarse` +* [...] + +From this follow a few simple recommendations (these may or may not apply as they may impose unwanted restrictions on your rendering setup): +* avoid enabling mult-sample anti-aliasing whenever it's not explicitly tested or needed +* do not rely on NaN, Inf and denormal float values +* consider dedicated test paths for texture sampling +* prefer explicit partial derivative functions From 66c73b9cbfbd4d44489fc6f6a840d7d82bc34389 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 18 Feb 2025 12:01:06 +0100 Subject: [PATCH 008/379] Set hint_text in WidgetInfo (#5724) The placeholder in kittest is currently not set for TextEdit Fields. This resolves it * [x] I have followed the instructions in the PR template --- crates/egui/src/data/output.rs | 12 ++++++++++++ crates/egui/src/response.rs | 3 +++ crates/egui/src/widgets/text_edit/builder.rs | 3 +++ 3 files changed, 18 insertions(+) diff --git a/crates/egui/src/data/output.rs b/crates/egui/src/data/output.rs index e1f9086a2..2fdaec1e1 100644 --- a/crates/egui/src/data/output.rs +++ b/crates/egui/src/data/output.rs @@ -539,6 +539,9 @@ pub struct WidgetInfo { /// Selected range of characters in [`Self::current_text_value`]. pub text_selection: Option>, + + /// The hint text for text edit fields. + pub hint_text: Option, } impl std::fmt::Debug for WidgetInfo { @@ -552,6 +555,7 @@ impl std::fmt::Debug for WidgetInfo { selected, value, text_selection, + hint_text, } = self; let mut s = f.debug_struct("WidgetInfo"); @@ -580,6 +584,9 @@ impl std::fmt::Debug for WidgetInfo { if let Some(text_selection) = text_selection { s.field("text_selection", text_selection); } + if let Some(hint_text) = hint_text { + s.field("hint_text", hint_text); + } s.finish() } @@ -596,6 +603,7 @@ impl WidgetInfo { selected: None, value: None, text_selection: None, + hint_text: None, } } @@ -643,9 +651,11 @@ impl WidgetInfo { enabled: bool, prev_text_value: impl ToString, text_value: impl ToString, + hint_text: impl ToString, ) -> Self { let text_value = text_value.to_string(); let prev_text_value = prev_text_value.to_string(); + let hint_text = hint_text.to_string(); let prev_text_value = if text_value == prev_text_value { None } else { @@ -655,6 +665,7 @@ impl WidgetInfo { enabled, current_text_value: Some(text_value), prev_text_value, + hint_text: Some(hint_text), ..Self::new(WidgetType::TextEdit) } } @@ -684,6 +695,7 @@ impl WidgetInfo { selected, value, text_selection: _, + hint_text: _, } = self; // TODO(emilk): localization diff --git a/crates/egui/src/response.rs b/crates/egui/src/response.rs index f5861f4f1..131a420da 100644 --- a/crates/egui/src/response.rs +++ b/crates/egui/src/response.rs @@ -1059,6 +1059,9 @@ impl Response { // Indeterminate state builder.set_toggled(Toggled::Mixed); } + if let Some(hint_text) = info.hint_text { + builder.set_placeholder(hint_text); + } } /// Associate a label with a control for accessibility. diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index 2f685bbc1..465f5568c 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -505,6 +505,7 @@ impl TextEdit<'_> { .unwrap_or_else(|| ui.visuals().widgets.inactive.text_color()); let prev_text = text.as_str().to_owned(); + let hint_text_str = hint_text.text().to_owned(); let font_id = font_selection.resolve(ui.style()); let row_height = ui.fonts(|f| f.row_height(&font_id)); @@ -807,6 +808,7 @@ impl TextEdit<'_> { ui.is_enabled(), mask_if_password(password, prev_text.as_str()), mask_if_password(password, text.as_str()), + hint_text_str.as_str(), ) }); } else if selection_changed { @@ -825,6 +827,7 @@ impl TextEdit<'_> { ui.is_enabled(), mask_if_password(password, prev_text.as_str()), mask_if_password(password, text.as_str()), + hint_text_str.as_str(), ) }); } From a8e98d3f9bb5e773d2e8a1c59b2448b2e32242a8 Mon Sep 17 00:00:00 2001 From: lucasmerlin Date: Tue, 18 Feb 2025 15:53:07 +0100 Subject: [PATCH 009/379] Add `Popup` and `Tooltip`, unifying the previous behaviours (#5713) This introduces new `Tooltip` and `Popup` structs that unify and extend the old popups and tooltips. `Popup` handles the positioning and optionally stores state on whether the popup is open (for click based popups like `ComboBox`, menus, context menus). `Tooltip` is based on `Popup` and handles state of whether the tooltip should be shown (which turns out to be quite complex to handles all the edge cases). Both `Popup` and `Tooltip` can easily be constructed from a `Response` and then customized via builder methods. This also introduces `PositionAlign`, for aligning something outside of a `Rect` (in contrast to `Align2` for aligning inside a `Rect`). But I don't like the name, any suggestions? Inspired by [mui's tooltip positioning](https://mui.com/material-ui/react-tooltip/#positioned-tooltips). * Part of #4607 * [x] I have followed the instructions in the PR template TODOs: - [x] Automatic tooltip positioning based on available space - [x] Review / fix / remove all code TODOs - [x] ~Update the helper fns on `Response` to be consistent in naming and parameters (Some use tooltip, some hover_ui, some take &self, some take self)~ actually, I think the naming and parameter make sense on second thought - [x] Make sure all old code is marked deprecated For discussion during review: - the following check in `show_tooltip_for` still necessary?: ```rust let is_touch_screen = ctx.input(|i| i.any_touches()); let allow_placing_below = !is_touch_screen; // There is a finger below. TODO: Needed? ``` --- crates/egui/src/containers/area.rs | 19 +- crates/egui/src/containers/combo_box.rs | 98 +- crates/egui/src/containers/mod.rs | 6 +- crates/egui/src/containers/old_popup.rs | 211 ++++ crates/egui/src/containers/popup.rs | 931 ++++++++++-------- crates/egui/src/containers/tooltip.rs | 376 +++++++ crates/egui/src/lib.rs | 3 +- crates/egui/src/memory/mod.rs | 21 +- crates/egui/src/response.rs | 215 +--- crates/egui_demo_lib/src/demo/context_menu.rs | 17 + .../src/demo/demo_app_windows.rs | 1 + crates/egui_demo_lib/src/demo/mod.rs | 1 + crates/egui_demo_lib/src/demo/popups.rs | 181 ++++ crates/egui_demo_lib/src/demo/tooltips.rs | 3 + .../tests/snapshots/demos/Context Menus.png | 4 +- .../tests/snapshots/demos/Popups.png | 3 + .../tests/snapshots/demos/Tooltips.png | 4 +- crates/egui_kittest/tests/popup.rs | 31 + crates/emath/src/align.rs | 28 + crates/emath/src/lib.rs | 2 + crates/emath/src/rect_align.rs | 279 ++++++ examples/popups/src/main.rs | 19 +- 22 files changed, 1738 insertions(+), 715 deletions(-) create mode 100644 crates/egui/src/containers/old_popup.rs create mode 100644 crates/egui/src/containers/tooltip.rs create mode 100644 crates/egui_demo_lib/src/demo/popups.rs create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Popups.png create mode 100644 crates/egui_kittest/tests/popup.rs create mode 100644 crates/emath/src/rect_align.rs diff --git a/crates/egui/src/containers/area.rs b/crates/egui/src/containers/area.rs index 6af762561..147e4426f 100644 --- a/crates/egui/src/containers/area.rs +++ b/crates/egui/src/containers/area.rs @@ -5,8 +5,8 @@ use emath::GuiRounding as _; use crate::{ - emath, pos2, Align2, Context, Id, InnerResponse, LayerId, NumExt, Order, Pos2, Rect, Response, - Sense, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, WidgetRect, WidgetWithState, + emath, pos2, Align2, Context, Id, InnerResponse, LayerId, Layout, NumExt, Order, Pos2, Rect, + Response, Sense, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, WidgetRect, WidgetWithState, }; /// State of an [`Area`] that is persisted between frames. @@ -120,6 +120,7 @@ pub struct Area { anchor: Option<(Align2, Vec2)>, new_pos: Option, fade_in: bool, + layout: Layout, } impl WidgetWithState for Area { @@ -145,6 +146,7 @@ impl Area { pivot: Align2::LEFT_TOP, anchor: None, fade_in: true, + layout: Layout::default(), } } @@ -339,6 +341,13 @@ impl Area { self.fade_in = fade_in; self } + + /// Set the layout for the child Ui. + #[inline] + pub fn layout(mut self, layout: Layout) -> Self { + self.layout = layout; + self + } } pub(crate) struct Prepared { @@ -358,6 +367,7 @@ pub(crate) struct Prepared { sizing_pass: bool, fade_in: bool, + layout: Layout, } impl Area { @@ -390,6 +400,7 @@ impl Area { constrain, constrain_rect, fade_in, + layout, } = self; let constrain_rect = constrain_rect.unwrap_or_else(|| ctx.screen_rect()); @@ -516,6 +527,7 @@ impl Area { constrain_rect, sizing_pass, fade_in, + layout, } } } @@ -543,7 +555,8 @@ impl Prepared { let mut ui_builder = UiBuilder::new() .ui_stack_info(UiStackInfo::new(self.kind)) .layer_id(self.layer_id) - .max_rect(max_rect); + .max_rect(max_rect) + .layout(self.layout); if !self.enabled { ui_builder = ui_builder.disabled(); diff --git a/crates/egui/src/containers/combo_box.rs b/crates/egui/src/containers/combo_box.rs index 98cf0182e..884a9c36d 100644 --- a/crates/egui/src/containers/combo_box.rs +++ b/crates/egui/src/containers/combo_box.rs @@ -1,7 +1,7 @@ use epaint::Shape; use crate::{ - epaint, style::WidgetVisuals, vec2, Align2, Context, Id, InnerResponse, NumExt, Painter, + epaint, style::WidgetVisuals, vec2, Align2, Context, Id, InnerResponse, NumExt, Painter, Popup, PopupCloseBehavior, Rect, Response, ScrollArea, Sense, Stroke, TextStyle, TextWrapMode, Ui, UiBuilder, Vec2, WidgetInfo, WidgetText, WidgetType, }; @@ -9,15 +9,8 @@ use crate::{ #[allow(unused_imports)] // Documentation use crate::style::Spacing; -/// Indicate whether a popup will be shown above or below the box. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum AboveOrBelow { - Above, - Below, -} - /// A function that paints the [`ComboBox`] icon -pub type IconPainter = Box; +pub type IconPainter = Box; /// A drop-down selection menu with a descriptive label. /// @@ -135,7 +128,6 @@ impl ComboBox { /// rect: egui::Rect, /// visuals: &egui::style::WidgetVisuals, /// _is_open: bool, - /// _above_or_below: egui::AboveOrBelow, /// ) { /// let rect = egui::Rect::from_center_size( /// rect.center(), @@ -154,10 +146,8 @@ impl ComboBox { /// .show_ui(ui, |_ui| {}); /// # }); /// ``` - pub fn icon( - mut self, - icon_fn: impl FnOnce(&Ui, Rect, &WidgetVisuals, bool, AboveOrBelow) + 'static, - ) -> Self { + #[inline] + pub fn icon(mut self, icon_fn: impl FnOnce(&Ui, Rect, &WidgetVisuals, bool) + 'static) -> Self { self.icon = Some(Box::new(icon_fn)); self } @@ -322,22 +312,6 @@ fn combo_box_dyn<'c, R>( let is_popup_open = ui.memory(|m| m.is_popup_open(popup_id)); - let popup_height = ui.memory(|m| { - m.areas() - .get(popup_id) - .and_then(|state| state.size) - .map_or(100.0, |size| size.y) - }); - - let above_or_below = - if ui.next_widget_position().y + ui.spacing().interact_size.y + popup_height - < ui.ctx().screen_rect().bottom() - { - AboveOrBelow::Below - } else { - AboveOrBelow::Above - }; - let wrap_mode = wrap_mode.unwrap_or_else(|| ui.wrap_mode()); let close_behavior = close_behavior.unwrap_or(PopupCloseBehavior::CloseOnClick); @@ -385,15 +359,9 @@ fn combo_box_dyn<'c, R>( icon_rect.expand(visuals.expansion), visuals, is_popup_open, - above_or_below, ); } else { - paint_default_icon( - ui.painter(), - icon_rect.expand(visuals.expansion), - visuals, - above_or_below, - ); + paint_default_icon(ui.painter(), icon_rect.expand(visuals.expansion), visuals); } let text_rect = Align2::LEFT_CENTER.align_size_within_rect(galley.size(), rect); @@ -402,19 +370,15 @@ fn combo_box_dyn<'c, R>( } }); - if button_response.clicked() { - ui.memory_mut(|mem| mem.toggle_popup(popup_id)); - } - let height = height.unwrap_or_else(|| ui.spacing().combo_height); - let inner = crate::popup::popup_above_or_below_widget( - ui, - popup_id, - &button_response, - above_or_below, - close_behavior, - |ui| { + let inner = Popup::menu(&button_response) + .id(popup_id) + .width(button_response.rect.width()) + .close_behavior(close_behavior) + .show(|ui| { + ui.set_min_width(ui.available_width()); + ScrollArea::vertical() .max_height(height) .show(ui, |ui| { @@ -427,8 +391,8 @@ fn combo_box_dyn<'c, R>( menu_contents(ui) }) .inner - }, - ); + }) + .map(|r| r.inner); InnerResponse { inner, @@ -484,33 +448,19 @@ fn button_frame( response } -fn paint_default_icon( - painter: &Painter, - rect: Rect, - visuals: &WidgetVisuals, - above_or_below: AboveOrBelow, -) { +fn paint_default_icon(painter: &Painter, rect: Rect, visuals: &WidgetVisuals) { let rect = Rect::from_center_size( rect.center(), vec2(rect.width() * 0.7, rect.height() * 0.45), ); - match above_or_below { - AboveOrBelow::Above => { - // Upward pointing triangle - painter.add(Shape::convex_polygon( - vec![rect.left_bottom(), rect.right_bottom(), rect.center_top()], - visuals.fg_stroke.color, - Stroke::NONE, - )); - } - AboveOrBelow::Below => { - // Downward pointing triangle - painter.add(Shape::convex_polygon( - vec![rect.left_top(), rect.right_top(), rect.center_bottom()], - visuals.fg_stroke.color, - Stroke::NONE, - )); - } - } + // Downward pointing triangle + // Previously, we would show an up arrow when we expected the popup to open upwards + // (due to lack of space below the button), but this could look weird in edge cases, so this + // feature was removed. (See https://github.com/emilk/egui/pull/5713#issuecomment-2654420245) + painter.add(Shape::convex_polygon( + vec![rect.left_top(), rect.right_top(), rect.center_bottom()], + visuals.fg_stroke.color, + Stroke::NONE, + )); } diff --git a/crates/egui/src/containers/mod.rs b/crates/egui/src/containers/mod.rs index abb444598..0d9587e62 100644 --- a/crates/egui/src/containers/mod.rs +++ b/crates/egui/src/containers/mod.rs @@ -7,12 +7,14 @@ pub mod collapsing_header; mod combo_box; pub mod frame; pub mod modal; +pub mod old_popup; pub mod panel; -pub mod popup; +mod popup; pub(crate) mod resize; mod scene; pub mod scroll_area; mod sides; +mod tooltip; pub(crate) mod window; pub use { @@ -21,11 +23,13 @@ pub use { combo_box::*, frame::Frame, modal::{Modal, ModalResponse}, + old_popup::*, panel::{CentralPanel, SidePanel, TopBottomPanel}, popup::*, resize::Resize, scene::Scene, scroll_area::ScrollArea, sides::Sides, + tooltip::*, window::Window, }; diff --git a/crates/egui/src/containers/old_popup.rs b/crates/egui/src/containers/old_popup.rs new file mode 100644 index 000000000..c803ecf42 --- /dev/null +++ b/crates/egui/src/containers/old_popup.rs @@ -0,0 +1,211 @@ +//! Old and deprecated API for popups. Use [`Popup`] instead. +#![allow(deprecated)] + +use crate::containers::tooltip::Tooltip; +use crate::{ + Align, Context, Id, LayerId, Layout, Popup, PopupAnchor, PopupCloseBehavior, Pos2, Rect, + Response, Ui, Widget, WidgetText, +}; +use emath::RectAlign; +// ---------------------------------------------------------------------------- + +/// Show a tooltip at the current pointer position (if any). +/// +/// Most of the time it is easier to use [`Response::on_hover_ui`]. +/// +/// See also [`show_tooltip_text`]. +/// +/// Returns `None` if the tooltip could not be placed. +/// +/// ``` +/// # egui::__run_test_ui(|ui| { +/// # #[allow(deprecated)] +/// if ui.ui_contains_pointer() { +/// egui::show_tooltip(ui.ctx(), ui.layer_id(), egui::Id::new("my_tooltip"), |ui| { +/// ui.label("Helpful text"); +/// }); +/// } +/// # }); +/// ``` +#[deprecated = "Use `egui::Tooltip` instead"] +pub fn show_tooltip( + ctx: &Context, + parent_layer: LayerId, + widget_id: Id, + add_contents: impl FnOnce(&mut Ui) -> R, +) -> Option { + show_tooltip_at_pointer(ctx, parent_layer, widget_id, add_contents) +} + +/// Show a tooltip at the current pointer position (if any). +/// +/// Most of the time it is easier to use [`Response::on_hover_ui`]. +/// +/// See also [`show_tooltip_text`]. +/// +/// Returns `None` if the tooltip could not be placed. +/// +/// ``` +/// # egui::__run_test_ui(|ui| { +/// if ui.ui_contains_pointer() { +/// egui::show_tooltip_at_pointer(ui.ctx(), ui.layer_id(), egui::Id::new("my_tooltip"), |ui| { +/// ui.label("Helpful text"); +/// }); +/// } +/// # }); +/// ``` +#[deprecated = "Use `egui::Tooltip` instead"] +pub fn show_tooltip_at_pointer( + ctx: &Context, + parent_layer: LayerId, + widget_id: Id, + add_contents: impl FnOnce(&mut Ui) -> R, +) -> Option { + Tooltip::new(widget_id, ctx.clone(), PopupAnchor::Pointer, parent_layer) + .gap(12.0) + .show(add_contents) + .map(|response| response.inner) +} + +/// Show a tooltip under the given area. +/// +/// If the tooltip does not fit under the area, it tries to place it above it instead. +#[deprecated = "Use `egui::Tooltip` instead"] +pub fn show_tooltip_for( + ctx: &Context, + parent_layer: LayerId, + widget_id: Id, + widget_rect: &Rect, + add_contents: impl FnOnce(&mut Ui) -> R, +) -> Option { + Tooltip::new(widget_id, ctx.clone(), *widget_rect, parent_layer) + .show(add_contents) + .map(|response| response.inner) +} + +/// Show a tooltip at the given position. +/// +/// Returns `None` if the tooltip could not be placed. +#[deprecated = "Use `egui::Tooltip` instead"] +pub fn show_tooltip_at( + ctx: &Context, + parent_layer: LayerId, + widget_id: Id, + suggested_position: Pos2, + add_contents: impl FnOnce(&mut Ui) -> R, +) -> Option { + Tooltip::new(widget_id, ctx.clone(), suggested_position, parent_layer) + .show(add_contents) + .map(|response| response.inner) +} + +/// Show some text at the current pointer position (if any). +/// +/// Most of the time it is easier to use [`Response::on_hover_text`]. +/// +/// See also [`show_tooltip`]. +/// +/// Returns `None` if the tooltip could not be placed. +/// +/// ``` +/// # egui::__run_test_ui(|ui| { +/// if ui.ui_contains_pointer() { +/// egui::show_tooltip_text(ui.ctx(), ui.layer_id(), egui::Id::new("my_tooltip"), "Helpful text"); +/// } +/// # }); +/// ``` +#[deprecated = "Use `egui::Tooltip` instead"] +pub fn show_tooltip_text( + ctx: &Context, + parent_layer: LayerId, + widget_id: Id, + text: impl Into, +) -> Option<()> { + show_tooltip(ctx, parent_layer, widget_id, |ui| { + crate::widgets::Label::new(text).ui(ui); + }) +} + +/// Was this tooltip visible last frame? +#[deprecated = "Use `Tooltip::was_tooltip_open_last_frame` instead"] +pub fn was_tooltip_open_last_frame(ctx: &Context, widget_id: Id) -> bool { + Tooltip::was_tooltip_open_last_frame(ctx, widget_id) +} + +/// Indicate whether a popup will be shown above or below the box. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum AboveOrBelow { + Above, + Below, +} + +/// Helper for [`popup_above_or_below_widget`]. +#[deprecated = "Use `egui::Popup` instead"] +pub fn popup_below_widget( + ui: &Ui, + popup_id: Id, + widget_response: &Response, + close_behavior: PopupCloseBehavior, + add_contents: impl FnOnce(&mut Ui) -> R, +) -> Option { + popup_above_or_below_widget( + ui, + popup_id, + widget_response, + AboveOrBelow::Below, + close_behavior, + add_contents, + ) +} + +/// Shows a popup above or below another widget. +/// +/// Useful for drop-down menus (combo boxes) or suggestion menus under text fields. +/// +/// The opened popup will have a minimum width matching its parent. +/// +/// You must open the popup with [`crate::Memory::open_popup`] or [`crate::Memory::toggle_popup`]. +/// +/// Returns `None` if the popup is not open. +/// +/// ``` +/// # egui::__run_test_ui(|ui| { +/// let response = ui.button("Open popup"); +/// let popup_id = ui.make_persistent_id("my_unique_id"); +/// if response.clicked() { +/// ui.memory_mut(|mem| mem.toggle_popup(popup_id)); +/// } +/// let below = egui::AboveOrBelow::Below; +/// let close_on_click_outside = egui::PopupCloseBehavior::CloseOnClickOutside; +/// # #[allow(deprecated)] +/// egui::popup_above_or_below_widget(ui, popup_id, &response, below, close_on_click_outside, |ui| { +/// ui.set_min_width(200.0); // if you want to control the size +/// ui.label("Some more info, or things you can select:"); +/// ui.label("…"); +/// }); +/// # }); +/// ``` +#[deprecated = "Use `egui::Popup` instead"] +pub fn popup_above_or_below_widget( + _parent_ui: &Ui, + popup_id: Id, + widget_response: &Response, + above_or_below: AboveOrBelow, + close_behavior: PopupCloseBehavior, + add_contents: impl FnOnce(&mut Ui) -> R, +) -> Option { + let response = Popup::from_response(widget_response) + .layout(Layout::top_down_justified(Align::LEFT)) + .open_memory(None, close_behavior) + .id(popup_id) + .align(match above_or_below { + AboveOrBelow::Above => RectAlign::TOP_START, + AboveOrBelow::Below => RectAlign::BOTTOM_START, + }) + .width(widget_response.rect.width()) + .show(|ui| { + ui.set_min_width(ui.available_width()); + add_contents(ui) + })?; + Some(response.inner) +} diff --git a/crates/egui/src/containers/popup.rs b/crates/egui/src/containers/popup.rs index 81bf84a2f..877ee4202 100644 --- a/crates/egui/src/containers/popup.rs +++ b/crates/egui/src/containers/popup.rs @@ -1,334 +1,75 @@ -//! Show popup windows, tooltips, context menus etc. - -use pass_state::PerWidgetTooltipState; - use crate::{ - pass_state, vec2, AboveOrBelow, Align, Align2, Area, AreaState, Context, Frame, Id, - InnerResponse, Key, LayerId, Layout, Order, Pos2, Rect, Response, Sense, Ui, UiKind, Vec2, - Widget, WidgetText, + Area, AreaState, Context, Frame, Id, InnerResponse, Key, LayerId, Layout, Order, Response, + Sense, Ui, UiKind, }; +use emath::{vec2, Align, Pos2, Rect, RectAlign, Vec2}; +use std::iter::once; -// ---------------------------------------------------------------------------- +/// What should we anchor the popup to? +/// The final position for the popup will be calculated based on [`RectAlign`] +/// and can be customized with [`Popup::align`] and [`Popup::align_alternatives`]. +/// [`PopupAnchor`] is the parent rect of [`RectAlign`]. +/// +/// For [`PopupAnchor::Pointer`], [`PopupAnchor::PointerFixed`] and [`PopupAnchor::Position`], +/// the rect will be derived via [`Rect::from_pos`] (so a zero-sized rect at the given position). +/// +/// The rect should be in global coordinates. `PopupAnchor::from(&response)` will automatically +/// do this conversion. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PopupAnchor { + /// Show the popup relative to some parent [`Rect`]. + ParentRect(Rect), -fn when_was_a_toolip_last_shown_id() -> Id { - Id::new("when_was_a_toolip_last_shown") + /// Show the popup relative to the mouse pointer. + Pointer, + + /// Remember the mouse position and show the popup relative to that (like a context menu). + PointerFixed, + + /// Show the popup relative to a specific position. + Position(Pos2), } -pub fn seconds_since_last_tooltip(ctx: &Context) -> f32 { - let when_was_a_toolip_last_shown = - ctx.data(|d| d.get_temp::(when_was_a_toolip_last_shown_id())); - - if let Some(when_was_a_toolip_last_shown) = when_was_a_toolip_last_shown { - let now = ctx.input(|i| i.time); - (now - when_was_a_toolip_last_shown) as f32 - } else { - f32::INFINITY +impl From for PopupAnchor { + fn from(rect: Rect) -> Self { + Self::ParentRect(rect) } } -fn remember_that_tooltip_was_shown(ctx: &Context) { - let now = ctx.input(|i| i.time); - ctx.data_mut(|data| data.insert_temp::(when_was_a_toolip_last_shown_id(), now)); +impl From for PopupAnchor { + fn from(pos: Pos2) -> Self { + Self::Position(pos) + } } -// ---------------------------------------------------------------------------- - -/// Show a tooltip at the current pointer position (if any). -/// -/// Most of the time it is easier to use [`Response::on_hover_ui`]. -/// -/// See also [`show_tooltip_text`]. -/// -/// Returns `None` if the tooltip could not be placed. -/// -/// ``` -/// # egui::__run_test_ui(|ui| { -/// if ui.ui_contains_pointer() { -/// egui::show_tooltip(ui.ctx(), ui.layer_id(), egui::Id::new("my_tooltip"), |ui| { -/// ui.label("Helpful text"); -/// }); -/// } -/// # }); -/// ``` -pub fn show_tooltip( - ctx: &Context, - parent_layer: LayerId, - widget_id: Id, - add_contents: impl FnOnce(&mut Ui) -> R, -) -> Option { - show_tooltip_at_pointer(ctx, parent_layer, widget_id, add_contents) -} - -/// Show a tooltip at the current pointer position (if any). -/// -/// Most of the time it is easier to use [`Response::on_hover_ui`]. -/// -/// See also [`show_tooltip_text`]. -/// -/// Returns `None` if the tooltip could not be placed. -/// -/// ``` -/// # egui::__run_test_ui(|ui| { -/// if ui.ui_contains_pointer() { -/// egui::show_tooltip_at_pointer(ui.ctx(), ui.layer_id(), egui::Id::new("my_tooltip"), |ui| { -/// ui.label("Helpful text"); -/// }); -/// } -/// # }); -/// ``` -pub fn show_tooltip_at_pointer( - ctx: &Context, - parent_layer: LayerId, - widget_id: Id, - add_contents: impl FnOnce(&mut Ui) -> R, -) -> Option { - ctx.input(|i| i.pointer.hover_pos()).map(|pointer_pos| { - let allow_placing_below = true; - - // Add a small exclusion zone around the pointer to avoid tooltips - // covering what we're hovering over. - let mut pointer_rect = Rect::from_center_size(pointer_pos, Vec2::splat(24.0)); - - // Keep the left edge of the tooltip in line with the cursor: - pointer_rect.min.x = pointer_pos.x; - - // Transform global coords to layer coords: - if let Some(from_global) = ctx.layer_transform_from_global(parent_layer) { - pointer_rect = from_global * pointer_rect; +impl From<&Response> for PopupAnchor { + fn from(response: &Response) -> Self { + let mut widget_rect = response.rect; + if let Some(to_global) = response.ctx.layer_transform_to_global(response.layer_id) { + widget_rect = to_global * widget_rect; } - - show_tooltip_at_dyn( - ctx, - parent_layer, - widget_id, - allow_placing_below, - &pointer_rect, - Box::new(add_contents), - ) - }) -} - -/// Show a tooltip under the given area. -/// -/// If the tooltip does not fit under the area, it tries to place it above it instead. -pub fn show_tooltip_for( - ctx: &Context, - parent_layer: LayerId, - widget_id: Id, - widget_rect: &Rect, - add_contents: impl FnOnce(&mut Ui) -> R, -) -> R { - let is_touch_screen = ctx.input(|i| i.any_touches()); - let allow_placing_below = !is_touch_screen; // There is a finger below. - show_tooltip_at_dyn( - ctx, - parent_layer, - widget_id, - allow_placing_below, - widget_rect, - Box::new(add_contents), - ) -} - -/// Show a tooltip at the given position. -/// -/// Returns `None` if the tooltip could not be placed. -pub fn show_tooltip_at( - ctx: &Context, - parent_layer: LayerId, - widget_id: Id, - suggested_position: Pos2, - add_contents: impl FnOnce(&mut Ui) -> R, -) -> R { - let allow_placing_below = true; - let rect = Rect::from_center_size(suggested_position, Vec2::ZERO); - show_tooltip_at_dyn( - ctx, - parent_layer, - widget_id, - allow_placing_below, - &rect, - Box::new(add_contents), - ) -} - -fn show_tooltip_at_dyn<'c, R>( - ctx: &Context, - parent_layer: LayerId, - widget_id: Id, - allow_placing_below: bool, - widget_rect: &Rect, - add_contents: Box R + 'c>, -) -> R { - // Transform layer coords to global coords: - let mut widget_rect = *widget_rect; - if let Some(to_global) = ctx.layer_transform_to_global(parent_layer) { - widget_rect = to_global * widget_rect; + Self::ParentRect(widget_rect) } - - remember_that_tooltip_was_shown(ctx); - - let mut state = ctx.pass_state_mut(|fs| { - // Remember that this is the widget showing the tooltip: - fs.layers - .entry(parent_layer) - .or_default() - .widget_with_tooltip = Some(widget_id); - - fs.tooltips - .widget_tooltips - .get(&widget_id) - .copied() - .unwrap_or(PerWidgetTooltipState { - bounding_rect: widget_rect, - tooltip_count: 0, - }) - }); - - let tooltip_area_id = tooltip_id(widget_id, state.tooltip_count); - let expected_tooltip_size = AreaState::load(ctx, tooltip_area_id) - .and_then(|area| area.size) - .unwrap_or(vec2(64.0, 32.0)); - - let screen_rect = ctx.screen_rect(); - - let (pivot, anchor) = find_tooltip_position( - screen_rect, - state.bounding_rect, - allow_placing_below, - expected_tooltip_size, - ); - - let InnerResponse { inner, response } = Area::new(tooltip_area_id) - .kind(UiKind::Popup) - .order(Order::Tooltip) - .pivot(pivot) - .fixed_pos(anchor) - .default_width(ctx.style().spacing.tooltip_width) - .sense(Sense::hover()) // don't click to bring to front - .show(ctx, |ui| { - // By default the text in tooltips aren't selectable. - // This means that most tooltips aren't interactable, - // which also mean they won't stick around so you can click them. - // Only tooltips that have actual interactive stuff (buttons, links, …) - // will stick around when you try to click them. - ui.style_mut().interaction.selectable_labels = false; - - Frame::popup(&ctx.style()).show_dyn(ui, add_contents).inner - }); - - state.tooltip_count += 1; - state.bounding_rect = state.bounding_rect.union(response.rect); - ctx.pass_state_mut(|fs| fs.tooltips.widget_tooltips.insert(widget_id, state)); - - inner } -/// What is the id of the next tooltip for this widget? -pub fn next_tooltip_id(ctx: &Context, widget_id: Id) -> Id { - let tooltip_count = ctx.pass_state(|fs| { - fs.tooltips - .widget_tooltips - .get(&widget_id) - .map_or(0, |state| state.tooltip_count) - }); - tooltip_id(widget_id, tooltip_count) -} - -pub fn tooltip_id(widget_id: Id, tooltip_count: usize) -> Id { - widget_id.with(tooltip_count) -} - -/// Returns `(PIVOT, POS)` to mean: put the `PIVOT` corner of the tooltip at `POS`. -/// -/// Note: the position might need to be constrained to the screen, -/// (e.g. moved sideways if shown under the widget) -/// but the `Area` will take care of that. -fn find_tooltip_position( - screen_rect: Rect, - widget_rect: Rect, - allow_placing_below: bool, - tooltip_size: Vec2, -) -> (Align2, Pos2) { - let spacing = 4.0; - - // Does it fit below? - if allow_placing_below - && widget_rect.bottom() + spacing + tooltip_size.y <= screen_rect.bottom() - { - return ( - Align2::LEFT_TOP, - widget_rect.left_bottom() + spacing * Vec2::DOWN, - ); +impl PopupAnchor { + /// Get the rect the popup should be shown relative to. + /// Returns `Rect::from_pos` for [`PopupAnchor::Pointer`], [`PopupAnchor::PointerFixed`] + /// and [`PopupAnchor::Position`] (so the rect will be zero-sized). + pub fn rect(self, popup_id: Id, ctx: &Context) -> Option { + match self { + Self::ParentRect(rect) => Some(rect), + Self::Pointer => ctx.pointer_hover_pos().map(Rect::from_pos), + Self::PointerFixed => ctx + .memory(|mem| mem.popup_position(popup_id)) + .map(Rect::from_pos), + Self::Position(pos) => Some(Rect::from_pos(pos)), + } } - - // Does it fit above? - if screen_rect.top() + tooltip_size.y + spacing <= widget_rect.top() { - return ( - Align2::LEFT_BOTTOM, - widget_rect.left_top() + spacing * Vec2::UP, - ); - } - - // Does it fit to the right? - if widget_rect.right() + spacing + tooltip_size.x <= screen_rect.right() { - return ( - Align2::LEFT_TOP, - widget_rect.right_top() + spacing * Vec2::RIGHT, - ); - } - - // Does it fit to the left? - if screen_rect.left() + tooltip_size.x + spacing <= widget_rect.left() { - return ( - Align2::RIGHT_TOP, - widget_rect.left_top() + spacing * Vec2::LEFT, - ); - } - - // It doesn't fit anywhere :( - - // Just show it anyway: - (Align2::LEFT_TOP, screen_rect.left_top()) -} - -/// Show some text at the current pointer position (if any). -/// -/// Most of the time it is easier to use [`Response::on_hover_text`]. -/// -/// See also [`show_tooltip`]. -/// -/// Returns `None` if the tooltip could not be placed. -/// -/// ``` -/// # egui::__run_test_ui(|ui| { -/// if ui.ui_contains_pointer() { -/// egui::show_tooltip_text(ui.ctx(), ui.layer_id(), egui::Id::new("my_tooltip"), "Helpful text"); -/// } -/// # }); -/// ``` -pub fn show_tooltip_text( - ctx: &Context, - parent_layer: LayerId, - widget_id: Id, - text: impl Into, -) -> Option<()> { - show_tooltip(ctx, parent_layer, widget_id, |ui| { - crate::widgets::Label::new(text).ui(ui); - }) -} - -/// Was this popup visible last frame? -pub fn was_tooltip_open_last_frame(ctx: &Context, widget_id: Id) -> bool { - let primary_tooltip_area_id = tooltip_id(widget_id, 0); - ctx.memory(|mem| { - mem.areas() - .visible_last_frame(&LayerId::new(Order::Tooltip, primary_tooltip_area_id)) - }) } /// Determines popup's close behavior -#[derive(Clone, Copy)] +#[derive(Clone, Copy, PartialEq, Eq)] pub enum PopupCloseBehavior { /// Popup will be closed on click anywhere, inside or outside the popup. /// @@ -344,114 +85,480 @@ pub enum PopupCloseBehavior { IgnoreClicks, } -/// Helper for [`popup_above_or_below_widget`]. -pub fn popup_below_widget( - ui: &Ui, - popup_id: Id, - widget_response: &Response, - close_behavior: PopupCloseBehavior, - add_contents: impl FnOnce(&mut Ui) -> R, -) -> Option { - popup_above_or_below_widget( - ui, - popup_id, - widget_response, - AboveOrBelow::Below, - close_behavior, - add_contents, - ) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SetOpenCommand { + /// Set the open state to the given value + Bool(bool), + + /// Toggle the open state + Toggle, } -/// Shows a popup above or below another widget. -/// -/// Useful for drop-down menus (combo boxes) or suggestion menus under text fields. -/// -/// The opened popup will have a minimum width matching its parent. -/// -/// You must open the popup with [`crate::Memory::open_popup`] or [`crate::Memory::toggle_popup`]. -/// -/// Returns `None` if the popup is not open. -/// -/// ``` -/// # egui::__run_test_ui(|ui| { -/// let response = ui.button("Open popup"); -/// let popup_id = ui.make_persistent_id("my_unique_id"); -/// if response.clicked() { -/// ui.memory_mut(|mem| mem.toggle_popup(popup_id)); -/// } -/// let below = egui::AboveOrBelow::Below; -/// let close_on_click_outside = egui::popup::PopupCloseBehavior::CloseOnClickOutside; -/// egui::popup::popup_above_or_below_widget(ui, popup_id, &response, below, close_on_click_outside, |ui| { -/// ui.set_min_width(200.0); // if you want to control the size -/// ui.label("Some more info, or things you can select:"); -/// ui.label("…"); -/// }); -/// # }); -/// ``` -pub fn popup_above_or_below_widget( - parent_ui: &Ui, - popup_id: Id, - widget_response: &Response, - above_or_below: AboveOrBelow, - close_behavior: PopupCloseBehavior, - add_contents: impl FnOnce(&mut Ui) -> R, -) -> Option { - if !parent_ui.memory(|mem| mem.is_popup_open(popup_id)) { - return None; +impl From for SetOpenCommand { + fn from(b: bool) -> Self { + Self::Bool(b) } +} - let (mut pos, pivot) = match above_or_below { - AboveOrBelow::Above => (widget_response.rect.left_top(), Align2::LEFT_BOTTOM), - AboveOrBelow::Below => (widget_response.rect.left_bottom(), Align2::LEFT_TOP), - }; +/// How do we determine if the popup should be open or closed +enum OpenKind<'a> { + /// Always open + Open, - if let Some(to_global) = parent_ui - .ctx() - .layer_transform_to_global(parent_ui.layer_id()) - { - pos = to_global * pos; - } + /// Always closed + Closed, - let frame = Frame::popup(parent_ui.style()); - let frame_margin = frame.total_margin(); - let inner_width = (widget_response.rect.width() - frame_margin.sum().x).max(0.0); + /// Open if the bool is true + Bool(&'a mut bool, PopupCloseBehavior), - parent_ui.ctx().pass_state_mut(|fs| { - fs.layers - .entry(parent_ui.layer_id()) - .or_default() - .open_popups - .insert(popup_id) - }); + /// Store the open state via [`crate::Memory`] + Memory { + set: Option, + close_behavior: PopupCloseBehavior, + }, +} - let response = Area::new(popup_id) - .kind(UiKind::Popup) - .order(Order::Foreground) - .fixed_pos(pos) - .default_width(inner_width) - .pivot(pivot) - .show(parent_ui.ctx(), |ui| { - frame - .show(ui, |ui| { - ui.with_layout(Layout::top_down_justified(Align::LEFT), |ui| { - ui.set_min_width(inner_width); - add_contents(ui) - }) - .inner - }) - .inner - }); - - let should_close = match close_behavior { - PopupCloseBehavior::CloseOnClick => widget_response.clicked_elsewhere(), - PopupCloseBehavior::CloseOnClickOutside => { - widget_response.clicked_elsewhere() && response.response.clicked_elsewhere() +impl<'a> OpenKind<'a> { + /// Returns `true` if the popup should be open + fn is_open(&self, id: Id, ctx: &Context) -> bool { + match self { + OpenKind::Open => true, + OpenKind::Closed => false, + OpenKind::Bool(open, _) => **open, + OpenKind::Memory { .. } => ctx.memory(|mem| mem.is_popup_open(id)), } - PopupCloseBehavior::IgnoreClicks => false, - }; - - if parent_ui.input(|i| i.key_pressed(Key::Escape)) || should_close { - parent_ui.memory_mut(|mem| mem.close_popup()); } - Some(response.inner) +} + +/// Is the popup a popup, tooltip or menu? +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PopupKind { + Popup, + Tooltip, + Menu, +} + +pub struct Popup<'a> { + id: Id, + ctx: Context, + anchor: PopupAnchor, + rect_align: RectAlign, + alternative_aligns: Option<&'a [RectAlign]>, + layer_id: LayerId, + open_kind: OpenKind<'a>, + kind: PopupKind, + + /// Gap between the anchor and the popup + gap: f32, + + /// Used later depending on close behavior + widget_clicked_elsewhere: bool, + + /// Default width passed to the Area + width: Option, + sense: Sense, + layout: Layout, + frame: Option, +} + +impl<'a> Popup<'a> { + /// Create a new popup + pub fn new(id: Id, ctx: Context, anchor: impl Into, layer_id: LayerId) -> Self { + Self { + id, + ctx, + anchor: anchor.into(), + open_kind: OpenKind::Open, + kind: PopupKind::Popup, + layer_id, + rect_align: RectAlign::BOTTOM_START, + alternative_aligns: None, + gap: 0.0, + widget_clicked_elsewhere: false, + width: None, + sense: Sense::click(), + layout: Layout::default(), + frame: None, + } + } + + /// Set the kind of the popup. Used for [`Area::kind`] and [`Area::order`]. + #[inline] + pub fn kind(mut self, kind: PopupKind) -> Self { + self.kind = kind; + self + } + + /// Set the [`RectAlign`] of the popup relative to the [`PopupAnchor`]. + /// This is the default position, and will be used if it fits. + /// See [`Self::align_alternatives`] for more on this. + #[inline] + pub fn align(mut self, position_align: RectAlign) -> Self { + self.rect_align = position_align; + self + } + + /// Set alternative positions to try if the default one doesn't fit. Set to an empty slice to + /// always use the position you set with [`Self::align`]. + /// By default, this will try [`RectAlign::symmetries`] and then [`RectAlign::MENU_ALIGNS`]. + #[inline] + pub fn align_alternatives(mut self, alternatives: &'a [RectAlign]) -> Self { + self.alternative_aligns = Some(alternatives); + self + } + + /// Show a popup relative to some widget. + /// The popup will be always open. + /// + /// See [`Self::menu`] and [`Self::context_menu`] for common use cases. + pub fn from_response(response: &Response) -> Self { + let mut popup = Self::new( + response.id.with("popup"), + response.ctx.clone(), + response, + response.layer_id, + ); + popup.widget_clicked_elsewhere = response.clicked_elsewhere(); + popup + } + + /// Show a popup when the widget was clicked. + /// Sets the layout to `Layout::top_down_justified(Align::Min)`. + pub fn menu(response: &Response) -> Self { + Self::from_response(response) + .open_memory( + if response.clicked() { + Some(SetOpenCommand::Toggle) + } else { + None + }, + PopupCloseBehavior::CloseOnClick, + ) + .layout(Layout::top_down_justified(Align::Min)) + } + + /// Show a context menu when the widget was secondary clicked. + /// Sets the layout to `Layout::top_down_justified(Align::Min)`. + /// In contrast to [`Self::menu`], this will open at the pointer position. + pub fn context_menu(response: &Response) -> Self { + Self::from_response(response) + .open_memory( + response + .secondary_clicked() + .then_some(SetOpenCommand::Bool(true)), + PopupCloseBehavior::CloseOnClick, + ) + .layout(Layout::top_down_justified(Align::Min)) + .at_pointer_fixed() + } + + /// Force the popup to be open or closed. + #[inline] + pub fn open(mut self, open: bool) -> Self { + self.open_kind = if open { + OpenKind::Open + } else { + OpenKind::Closed + }; + self + } + + /// Store the open state via [`crate::Memory`]. + /// You can set the state via the first [`SetOpenCommand`] param. + #[inline] + pub fn open_memory( + mut self, + set_state: impl Into>, + close_behavior: PopupCloseBehavior, + ) -> Self { + self.open_kind = OpenKind::Memory { + set: set_state.into(), + close_behavior, + }; + self + } + + /// Store the open state via a mutable bool. + #[inline] + pub fn open_bool(mut self, open: &'a mut bool, close_behavior: PopupCloseBehavior) -> Self { + self.open_kind = OpenKind::Bool(open, close_behavior); + self + } + + /// Set the close behavior of the popup. + /// + /// This will do nothing if [`Popup::open`] was called. + #[inline] + pub fn close_behavior(mut self, close_behavior: PopupCloseBehavior) -> Self { + match &mut self.open_kind { + OpenKind::Memory { + close_behavior: behavior, + .. + } + | OpenKind::Bool(_, behavior) => { + *behavior = close_behavior; + } + _ => {} + } + self + } + + /// Show the popup relative to the pointer. + #[inline] + pub fn at_pointer(mut self) -> Self { + self.anchor = PopupAnchor::Pointer; + self + } + + /// Remember the pointer position at the time of opening the popup, and show the popup + /// relative to that. + #[inline] + pub fn at_pointer_fixed(mut self) -> Self { + self.anchor = PopupAnchor::PointerFixed; + self + } + + /// Show the popup relative to a specific position. + #[inline] + pub fn at_position(mut self, position: Pos2) -> Self { + self.anchor = PopupAnchor::Position(position); + self + } + + /// Show the popup relative to the given [`PopupAnchor`]. + #[inline] + pub fn anchor(mut self, anchor: impl Into) -> Self { + self.anchor = anchor.into(); + self + } + + /// Set the gap between the anchor and the popup. + #[inline] + pub fn gap(mut self, gap: f32) -> Self { + self.gap = gap; + self + } + + /// Set the sense of the popup. + #[inline] + pub fn sense(mut self, sense: Sense) -> Self { + self.sense = sense; + self + } + + /// Set the layout of the popup. + #[inline] + pub fn layout(mut self, layout: Layout) -> Self { + self.layout = layout; + self + } + + /// The width that will be passed to [`Area::default_width`]. + #[inline] + pub fn width(mut self, width: f32) -> Self { + self.width = Some(width); + self + } + + /// Set the id of the Area. + #[inline] + pub fn id(mut self, id: Id) -> Self { + self.id = id; + self + } + + /// Get the [`Context`] + pub fn ctx(&self) -> &Context { + &self.ctx + } + + /// Return the [`PopupAnchor`] of the popup. + pub fn get_anchor(&self) -> PopupAnchor { + self.anchor + } + + /// Return the anchor rect of the popup. + /// + /// Returns `None` if the anchor is [`PopupAnchor::Pointer`] and there is no pointer. + pub fn get_anchor_rect(&self) -> Option { + self.anchor.rect(self.id, &self.ctx) + } + + /// Get the expected rect the popup will be shown in. + /// + /// Returns `None` if the popup wasn't shown before or anchor is `PopupAnchor::Pointer` and + /// there is no pointer. + pub fn get_popup_rect(&self) -> Option { + let size = self.get_expected_size(); + if let Some(size) = size { + self.get_anchor_rect() + .map(|anchor| self.get_best_align().align_rect(&anchor, size, self.gap)) + } else { + None + } + } + + /// Get the id of the popup. + pub fn get_id(&self) -> Id { + self.id + } + + /// Is the popup open? + pub fn is_open(&self) -> bool { + match &self.open_kind { + OpenKind::Open => true, + OpenKind::Closed => false, + OpenKind::Bool(open, _) => **open, + OpenKind::Memory { .. } => self.ctx.memory(|mem| mem.is_popup_open(self.id)), + } + } + + pub fn get_expected_size(&self) -> Option { + AreaState::load(&self.ctx, self.id).and_then(|area| area.size) + } + + /// Calculate the best alignment for the popup, based on the last size and screen rect. + pub fn get_best_align(&self) -> RectAlign { + let expected_popup_size = self + .get_expected_size() + .unwrap_or(vec2(self.width.unwrap_or(0.0), 0.0)); + + let Some(anchor_rect) = self.anchor.rect(self.id, &self.ctx) else { + return self.rect_align; + }; + + RectAlign::find_best_align( + #[allow(clippy::iter_on_empty_collections)] + once(self.rect_align).chain( + self.alternative_aligns + // Need the empty slice so the iters have the same type so we can unwrap_or + .map(|a| a.iter().copied().chain([].iter().copied())) + .unwrap_or( + self.rect_align + .symmetries() + .iter() + .copied() + .chain(RectAlign::MENU_ALIGNS.iter().copied()), + ), + ), + self.ctx.screen_rect(), + anchor_rect, + self.gap, + expected_popup_size, + ) + } + + /// Show the popup. + /// Returns `None` if the popup is not open or anchor is `PopupAnchor::Pointer` and there is + /// no pointer. + pub fn show(self, content: impl FnOnce(&mut Ui) -> R) -> Option> { + let best_align = self.get_best_align(); + + let Popup { + id, + ctx, + anchor, + open_kind, + kind, + layer_id, + rect_align: _, + alternative_aligns: _, + gap, + widget_clicked_elsewhere, + width, + sense, + layout, + frame, + } = self; + + let hover_pos = ctx.pointer_hover_pos(); + if let OpenKind::Memory { set, .. } = open_kind { + ctx.memory_mut(|mem| match set { + Some(SetOpenCommand::Bool(open)) => { + if open { + match self.anchor { + PopupAnchor::PointerFixed => { + mem.open_popup_at(id, hover_pos); + } + _ => mem.open_popup(id), + } + } else { + mem.close_popup(); + } + } + Some(SetOpenCommand::Toggle) => { + mem.toggle_popup(id); + } + None => {} + }); + } + + if !open_kind.is_open(id, &ctx) { + return None; + } + + let (ui_kind, order) = match kind { + PopupKind::Popup => (UiKind::Popup, Order::Foreground), + PopupKind::Tooltip => (UiKind::Tooltip, Order::Tooltip), + PopupKind::Menu => (UiKind::Menu, Order::Foreground), + }; + + if kind == PopupKind::Popup { + ctx.pass_state_mut(|fs| { + fs.layers + .entry(layer_id) + .or_default() + .open_popups + .insert(id) + }); + } + + let anchor_rect = anchor.rect(id, &ctx)?; + + let (pivot, anchor) = best_align.pivot_pos(&anchor_rect, gap); + + let mut area = Area::new(id) + .order(order) + .kind(ui_kind) + .pivot(pivot) + .fixed_pos(anchor) + .sense(sense) + .layout(layout); + + if let Some(width) = width { + area = area.default_width(width); + } + + let frame = frame.unwrap_or_else(|| Frame::popup(&ctx.style())); + + let response = area.show(&ctx, |ui| frame.show(ui, content).inner); + + let should_close = |close_behavior| { + let should_close = match close_behavior { + PopupCloseBehavior::CloseOnClick => widget_clicked_elsewhere, + PopupCloseBehavior::CloseOnClickOutside => { + widget_clicked_elsewhere && response.response.clicked_elsewhere() + } + PopupCloseBehavior::IgnoreClicks => false, + }; + + should_close || ctx.input(|i| i.key_pressed(Key::Escape)) + }; + + match open_kind { + OpenKind::Open | OpenKind::Closed => {} + OpenKind::Bool(open, close_behavior) => { + if should_close(close_behavior) { + *open = false; + } + } + OpenKind::Memory { close_behavior, .. } => { + if should_close(close_behavior) { + ctx.memory_mut(|mem| mem.close_popup()); + } + } + } + + Some(response) + } } diff --git a/crates/egui/src/containers/tooltip.rs b/crates/egui/src/containers/tooltip.rs new file mode 100644 index 000000000..1cfc2a9c9 --- /dev/null +++ b/crates/egui/src/containers/tooltip.rs @@ -0,0 +1,376 @@ +use crate::pass_state::PerWidgetTooltipState; +use crate::{ + AreaState, Context, Id, InnerResponse, LayerId, Layout, Order, Popup, PopupAnchor, PopupKind, + Response, Sense, +}; +use emath::Vec2; + +pub struct Tooltip<'a> { + pub popup: Popup<'a>, + layer_id: LayerId, + widget_id: Id, +} + +impl<'a> Tooltip<'a> { + /// Show a tooltip that is always open + pub fn new( + widget_id: Id, + ctx: Context, + anchor: impl Into, + layer_id: LayerId, + ) -> Self { + Self { + // TODO(lucasmerlin): Set width somehow (we're missing context here) + popup: Popup::new(widget_id, ctx, anchor.into(), layer_id) + .kind(PopupKind::Tooltip) + .gap(4.0) + .sense(Sense::hover()), + layer_id, + widget_id, + } + } + + /// Show a tooltip for a widget. Always open (as long as this function is called). + pub fn for_widget(response: &Response) -> Self { + let popup = Popup::from_response(response) + .kind(PopupKind::Tooltip) + .gap(4.0) + .width(response.ctx.style().spacing.tooltip_width) + .sense(Sense::hover()); + Self { + popup, + layer_id: response.layer_id, + widget_id: response.id, + } + } + + /// Show a tooltip when hovering an enabled widget. + pub fn for_enabled(response: &Response) -> Self { + let mut tooltip = Self::for_widget(response); + tooltip.popup = tooltip + .popup + .open(response.enabled() && Self::should_show_tooltip(response)); + tooltip + } + + /// Show a tooltip when hovering a disabled widget. + pub fn for_disabled(response: &Response) -> Self { + let mut tooltip = Self::for_widget(response); + tooltip.popup = tooltip + .popup + .open(!response.enabled() && Self::should_show_tooltip(response)); + tooltip + } + + /// Show the tooltip at the pointer position. + #[inline] + pub fn at_pointer(mut self) -> Self { + self.popup = self.popup.at_pointer(); + self + } + + /// Set the gap between the tooltip and the anchor + /// + /// Default: 5.0 + #[inline] + pub fn gap(mut self, gap: f32) -> Self { + self.popup = self.popup.gap(gap); + self + } + + /// Set the layout of the tooltip + #[inline] + pub fn layout(mut self, layout: Layout) -> Self { + self.popup = self.popup.layout(layout); + self + } + + /// Set the width of the tooltip + #[inline] + pub fn width(mut self, width: f32) -> Self { + self.popup = self.popup.width(width); + self + } + + /// Show the tooltip + pub fn show(self, content: impl FnOnce(&mut crate::Ui) -> R) -> Option> { + let Self { + mut popup, + layer_id: parent_layer, + widget_id, + } = self; + + if !popup.is_open() { + return None; + } + + let rect = popup.get_anchor_rect()?; + + let mut state = popup.ctx().pass_state_mut(|fs| { + // Remember that this is the widget showing the tooltip: + fs.layers + .entry(parent_layer) + .or_default() + .widget_with_tooltip = Some(widget_id); + + fs.tooltips + .widget_tooltips + .get(&widget_id) + .copied() + .unwrap_or(PerWidgetTooltipState { + bounding_rect: rect, + tooltip_count: 0, + }) + }); + + let tooltip_area_id = Self::tooltip_id(widget_id, state.tooltip_count); + popup = popup.anchor(state.bounding_rect).id(tooltip_area_id); + + let response = popup.show(|ui| { + // By default, the text in tooltips aren't selectable. + // This means that most tooltips aren't interactable, + // which also mean they won't stick around so you can click them. + // Only tooltips that have actual interactive stuff (buttons, links, …) + // will stick around when you try to click them. + ui.style_mut().interaction.selectable_labels = false; + + content(ui) + }); + + // The popup might not be shown on at_pointer if there is no pointer. + if let Some(response) = &response { + state.tooltip_count += 1; + state.bounding_rect = state.bounding_rect.union(response.response.rect); + response + .response + .ctx + .pass_state_mut(|fs| fs.tooltips.widget_tooltips.insert(widget_id, state)); + Self::remember_that_tooltip_was_shown(&response.response.ctx); + } + + response + } + + fn when_was_a_toolip_last_shown_id() -> Id { + Id::new("when_was_a_toolip_last_shown") + } + + pub fn seconds_since_last_tooltip(ctx: &Context) -> f32 { + let when_was_a_toolip_last_shown = + ctx.data(|d| d.get_temp::(Self::when_was_a_toolip_last_shown_id())); + + if let Some(when_was_a_toolip_last_shown) = when_was_a_toolip_last_shown { + let now = ctx.input(|i| i.time); + (now - when_was_a_toolip_last_shown) as f32 + } else { + f32::INFINITY + } + } + + fn remember_that_tooltip_was_shown(ctx: &Context) { + let now = ctx.input(|i| i.time); + ctx.data_mut(|data| data.insert_temp::(Self::when_was_a_toolip_last_shown_id(), now)); + } + + /// What is the id of the next tooltip for this widget? + pub fn next_tooltip_id(ctx: &Context, widget_id: Id) -> Id { + let tooltip_count = ctx.pass_state(|fs| { + fs.tooltips + .widget_tooltips + .get(&widget_id) + .map_or(0, |state| state.tooltip_count) + }); + Self::tooltip_id(widget_id, tooltip_count) + } + + pub fn tooltip_id(widget_id: Id, tooltip_count: usize) -> Id { + widget_id.with(tooltip_count) + } + + /// Should we show a tooltip for this response? + pub fn should_show_tooltip(response: &Response) -> bool { + if response.ctx.memory(|mem| mem.everything_is_visible()) { + return true; + } + + let any_open_popups = response.ctx.prev_pass_state(|fs| { + fs.layers + .get(&response.layer_id) + .is_some_and(|layer| !layer.open_popups.is_empty()) + }); + if any_open_popups { + // Hide tooltips if the user opens a popup (menu, combo-box, etc.) in the same layer. + return false; + } + + let style = response.ctx.style(); + + let tooltip_delay = style.interaction.tooltip_delay; + let tooltip_grace_time = style.interaction.tooltip_grace_time; + + let ( + time_since_last_scroll, + time_since_last_click, + time_since_last_pointer_movement, + pointer_pos, + pointer_dir, + ) = response.ctx.input(|i| { + ( + i.time_since_last_scroll(), + i.pointer.time_since_last_click(), + i.pointer.time_since_last_movement(), + i.pointer.hover_pos(), + i.pointer.direction(), + ) + }); + + if time_since_last_scroll < tooltip_delay { + // See https://github.com/emilk/egui/issues/4781 + // Note that this means we cannot have `ScrollArea`s in a tooltip. + response + .ctx + .request_repaint_after_secs(tooltip_delay - time_since_last_scroll); + return false; + } + + let is_our_tooltip_open = response.is_tooltip_open(); + + if is_our_tooltip_open { + // Check if we should automatically stay open: + + let tooltip_id = Self::next_tooltip_id(&response.ctx, response.id); + let tooltip_layer_id = LayerId::new(Order::Tooltip, tooltip_id); + + let tooltip_has_interactive_widget = response.ctx.viewport(|vp| { + vp.prev_pass + .widgets + .get_layer(tooltip_layer_id) + .any(|w| w.enabled && w.sense.interactive()) + }); + + if tooltip_has_interactive_widget { + // We keep the tooltip open if hovered, + // or if the pointer is on its way to it, + // so that the user can interact with the tooltip + // (i.e. click links that are in it). + if let Some(area) = AreaState::load(&response.ctx, tooltip_id) { + let rect = area.rect(); + + if let Some(pos) = pointer_pos { + if rect.contains(pos) { + return true; // hovering interactive tooltip + } + if pointer_dir != Vec2::ZERO + && rect.intersects_ray(pos, pointer_dir.normalized()) + { + return true; // on the way to interactive tooltip + } + } + } + } + } + + let clicked_more_recently_than_moved = + time_since_last_click < time_since_last_pointer_movement + 0.1; + if clicked_more_recently_than_moved { + // It is common to click a widget and then rest the mouse there. + // It would be annoying to then see a tooltip for it immediately. + // Similarly, clicking should hide the existing tooltip. + // Only hovering should lead to a tooltip, not clicking. + // The offset is only to allow small movement just right after the click. + return false; + } + + if is_our_tooltip_open { + // Check if we should automatically stay open: + + if pointer_pos.is_some_and(|pointer_pos| response.rect.contains(pointer_pos)) { + // Handle the case of a big tooltip that covers the widget: + return true; + } + } + + let is_other_tooltip_open = response.ctx.prev_pass_state(|fs| { + if let Some(already_open_tooltip) = fs + .layers + .get(&response.layer_id) + .and_then(|layer| layer.widget_with_tooltip) + { + already_open_tooltip != response.id + } else { + false + } + }); + if is_other_tooltip_open { + // We only allow one tooltip per layer. First one wins. It is up to that tooltip to close itself. + return false; + } + + // Fast early-outs: + if response.enabled() { + if !response.hovered() || !response.ctx.input(|i| i.pointer.has_pointer()) { + return false; + } + } else if !response + .ctx + .rect_contains_pointer(response.layer_id, response.rect) + { + return false; + } + + // There is a tooltip_delay before showing the first tooltip, + // but once one tooltip is show, moving the mouse cursor to + // another widget should show the tooltip for that widget right away. + + // Let the user quickly move over some dead space to hover the next thing + let tooltip_was_recently_shown = + Self::seconds_since_last_tooltip(&response.ctx) < tooltip_grace_time; + + if !tooltip_was_recently_shown && !is_our_tooltip_open { + if style.interaction.show_tooltips_only_when_still { + // We only show the tooltip when the mouse pointer is still. + if !response + .ctx + .input(|i| i.pointer.is_still() && i.smooth_scroll_delta == Vec2::ZERO) + { + // wait for mouse to stop + response.ctx.request_repaint(); + return false; + } + } + + let time_since_last_interaction = time_since_last_scroll + .min(time_since_last_pointer_movement) + .min(time_since_last_click); + let time_til_tooltip = tooltip_delay - time_since_last_interaction; + + if 0.0 < time_til_tooltip { + // Wait until the mouse has been still for a while + response.ctx.request_repaint_after_secs(time_til_tooltip); + return false; + } + } + + // We don't want tooltips of things while we are dragging them, + // but we do want tooltips while holding down on an item on a touch screen. + if response + .ctx + .input(|i| i.pointer.any_down() && i.pointer.has_moved_too_much_for_a_click) + { + return false; + } + + // All checks passed: show the tooltip! + + true + } + + /// Was this tooltip visible last frame? + pub fn was_tooltip_open_last_frame(ctx: &Context, widget_id: Id) -> bool { + let primary_tooltip_area_id = Self::tooltip_id(widget_id, 0); + ctx.memory(|mem| { + mem.areas() + .visible_last_frame(&LayerId::new(Order::Tooltip, primary_tooltip_area_id)) + }) + } +} diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 759f7e41a..82b37c50e 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -458,7 +458,8 @@ pub use epaint::emath; pub use ecolor::hex_color; pub use ecolor::{Color32, Rgba}; pub use emath::{ - lerp, pos2, remap, remap_clamp, vec2, Align, Align2, NumExt, Pos2, Rangef, Rect, Vec2, Vec2b, + lerp, pos2, remap, remap_clamp, vec2, Align, Align2, NumExt, Pos2, Rangef, Rect, RectAlign, + Vec2, Vec2b, }; pub use epaint::{ mutex, diff --git a/crates/egui/src/memory/mod.rs b/crates/egui/src/memory/mod.rs index 2c669f0ff..3a6053f73 100644 --- a/crates/egui/src/memory/mod.rs +++ b/crates/egui/src/memory/mod.rs @@ -89,8 +89,12 @@ pub struct Memory { /// Which popup-window is open (if any)? /// Could be a combo box, color picker, menu, etc. + /// Optionally stores the position of the popup (usually this would be the position where + /// the user clicked). + /// If position is [`None`], the popup position will be calculated based on some configuration + /// (e.g. relative to some other widget). #[cfg_attr(feature = "persistence", serde(skip))] - popup: Option, + popup: Option<(Id, Option)>, #[cfg_attr(feature = "persistence", serde(skip))] everything_is_visible: bool, @@ -1070,7 +1074,7 @@ impl Memory { impl Memory { /// Is the given popup open? pub fn is_popup_open(&self, popup_id: Id) -> bool { - self.popup == Some(popup_id) || self.everything_is_visible() + self.popup.is_some_and(|(id, _)| id == popup_id) || self.everything_is_visible() } /// Is any popup open? @@ -1080,7 +1084,18 @@ impl Memory { /// Open the given popup and close all others. pub fn open_popup(&mut self, popup_id: Id) { - self.popup = Some(popup_id); + self.popup = Some((popup_id, None)); + } + + /// Open the popup and remember its position. + pub fn open_popup_at(&mut self, popup_id: Id, pos: impl Into>) { + self.popup = Some((popup_id, pos.into())); + } + + /// Get the position for this popup. + pub fn popup_position(&self, id: Id) -> Option { + self.popup + .and_then(|(popup_id, pos)| if popup_id == id { pos } else { None }) } /// Close the open popup, if any. diff --git a/crates/egui/src/response.rs b/crates/egui/src/response.rs index 131a420da..a5a702cfc 100644 --- a/crates/egui/src/response.rs +++ b/crates/egui/src/response.rs @@ -2,8 +2,8 @@ use std::{any::Any, sync::Arc}; use crate::{ emath::{Align, Pos2, Rect, Vec2}, - menu, pass_state, AreaState, Context, CursorIcon, Id, LayerId, Order, PointerButton, Sense, Ui, - WidgetRect, WidgetText, + menu, pass_state, Context, CursorIcon, Id, LayerId, PointerButton, Popup, PopupKind, Sense, + Tooltip, Ui, WidgetRect, WidgetText, }; // ---------------------------------------------------------------------------- @@ -550,36 +550,22 @@ impl Response { /// ``` #[doc(alias = "tooltip")] pub fn on_hover_ui(self, add_contents: impl FnOnce(&mut Ui)) -> Self { - if self.flags.contains(Flags::ENABLED) && self.should_show_hover_ui() { - self.show_tooltip_ui(add_contents); - } + Tooltip::for_enabled(&self).show(add_contents); self } /// Show this UI when hovering if the widget is disabled. pub fn on_disabled_hover_ui(self, add_contents: impl FnOnce(&mut Ui)) -> Self { - if !self.enabled() && self.should_show_hover_ui() { - crate::containers::show_tooltip_for( - &self.ctx, - self.layer_id, - self.id, - &self.rect, - add_contents, - ); - } + Tooltip::for_disabled(&self).show(add_contents); self } /// Like `on_hover_ui`, but show the ui next to cursor. pub fn on_hover_ui_at_pointer(self, add_contents: impl FnOnce(&mut Ui)) -> Self { - if self.enabled() && self.should_show_hover_ui() { - crate::containers::show_tooltip_at_pointer( - &self.ctx, - self.layer_id, - self.id, - add_contents, - ); - } + Tooltip::for_enabled(&self) + .at_pointer() + .gap(12.0) + .show(add_contents); self } @@ -587,13 +573,9 @@ impl Response { /// /// This can be used to give attention to a widget during a tutorial. pub fn show_tooltip_ui(&self, add_contents: impl FnOnce(&mut Ui)) { - crate::containers::show_tooltip_for( - &self.ctx, - self.layer_id, - self.id, - &self.rect, - add_contents, - ); + Popup::from_response(self) + .kind(PopupKind::Tooltip) + .show(add_contents); } /// Always show this tooltip, even if disabled and the user isn't hovering it. @@ -607,180 +589,7 @@ impl Response { /// Was the tooltip open last frame? pub fn is_tooltip_open(&self) -> bool { - crate::popup::was_tooltip_open_last_frame(&self.ctx, self.id) - } - - fn should_show_hover_ui(&self) -> bool { - if self.ctx.memory(|mem| mem.everything_is_visible()) { - return true; - } - - let any_open_popups = self.ctx.prev_pass_state(|fs| { - fs.layers - .get(&self.layer_id) - .is_some_and(|layer| !layer.open_popups.is_empty()) - }); - if any_open_popups { - // Hide tooltips if the user opens a popup (menu, combo-box, etc) in the same layer. - return false; - } - - let style = self.ctx.style(); - - let tooltip_delay = style.interaction.tooltip_delay; - let tooltip_grace_time = style.interaction.tooltip_grace_time; - - let ( - time_since_last_scroll, - time_since_last_click, - time_since_last_pointer_movement, - pointer_pos, - pointer_dir, - ) = self.ctx.input(|i| { - ( - i.time_since_last_scroll(), - i.pointer.time_since_last_click(), - i.pointer.time_since_last_movement(), - i.pointer.hover_pos(), - i.pointer.direction(), - ) - }); - - if time_since_last_scroll < tooltip_delay { - // See https://github.com/emilk/egui/issues/4781 - // Note that this means we cannot have `ScrollArea`s in a tooltip. - self.ctx - .request_repaint_after_secs(tooltip_delay - time_since_last_scroll); - return false; - } - - let is_our_tooltip_open = self.is_tooltip_open(); - - if is_our_tooltip_open { - // Check if we should automatically stay open: - - let tooltip_id = crate::next_tooltip_id(&self.ctx, self.id); - let tooltip_layer_id = LayerId::new(Order::Tooltip, tooltip_id); - - let tooltip_has_interactive_widget = self.ctx.viewport(|vp| { - vp.prev_pass - .widgets - .get_layer(tooltip_layer_id) - .any(|w| w.enabled && w.sense.interactive()) - }); - - if tooltip_has_interactive_widget { - // We keep the tooltip open if hovered, - // or if the pointer is on its way to it, - // so that the user can interact with the tooltip - // (i.e. click links that are in it). - if let Some(area) = AreaState::load(&self.ctx, tooltip_id) { - let rect = area.rect(); - - if let Some(pos) = pointer_pos { - if rect.contains(pos) { - return true; // hovering interactive tooltip - } - if pointer_dir != Vec2::ZERO - && rect.intersects_ray(pos, pointer_dir.normalized()) - { - return true; // on the way to interactive tooltip - } - } - } - } - } - - let clicked_more_recently_than_moved = - time_since_last_click < time_since_last_pointer_movement + 0.1; - if clicked_more_recently_than_moved { - // It is common to click a widget and then rest the mouse there. - // It would be annoying to then see a tooltip for it immediately. - // Similarly, clicking should hide the existing tooltip. - // Only hovering should lead to a tooltip, not clicking. - // The offset is only to allow small movement just right after the click. - return false; - } - - if is_our_tooltip_open { - // Check if we should automatically stay open: - - if pointer_pos.is_some_and(|pointer_pos| self.rect.contains(pointer_pos)) { - // Handle the case of a big tooltip that covers the widget: - return true; - } - } - - let is_other_tooltip_open = self.ctx.prev_pass_state(|fs| { - if let Some(already_open_tooltip) = fs - .layers - .get(&self.layer_id) - .and_then(|layer| layer.widget_with_tooltip) - { - already_open_tooltip != self.id - } else { - false - } - }); - if is_other_tooltip_open { - // We only allow one tooltip per layer. First one wins. It is up to that tooltip to close itself. - return false; - } - - // Fast early-outs: - if self.enabled() { - if !self.hovered() || !self.ctx.input(|i| i.pointer.has_pointer()) { - return false; - } - } else if !self.ctx.rect_contains_pointer(self.layer_id, self.rect) { - return false; - } - - // There is a tooltip_delay before showing the first tooltip, - // but once one tooltip is show, moving the mouse cursor to - // another widget should show the tooltip for that widget right away. - - // Let the user quickly move over some dead space to hover the next thing - let tooltip_was_recently_shown = - crate::popup::seconds_since_last_tooltip(&self.ctx) < tooltip_grace_time; - - if !tooltip_was_recently_shown && !is_our_tooltip_open { - if style.interaction.show_tooltips_only_when_still { - // We only show the tooltip when the mouse pointer is still. - if !self - .ctx - .input(|i| i.pointer.is_still() && i.smooth_scroll_delta == Vec2::ZERO) - { - // wait for mouse to stop - self.ctx.request_repaint(); - return false; - } - } - - let time_since_last_interaction = time_since_last_scroll - .min(time_since_last_pointer_movement) - .min(time_since_last_click); - let time_til_tooltip = tooltip_delay - time_since_last_interaction; - - if 0.0 < time_til_tooltip { - // Wait until the mouse has been still for a while - self.ctx.request_repaint_after_secs(time_til_tooltip); - return false; - } - } - - // We don't want tooltips of things while we are dragging them, - // but we do want tooltips while holding down on an item on a touch screen. - if self - .ctx - .input(|i| i.pointer.any_down() && i.pointer.has_moved_too_much_for_a_click) - { - return false; - } - - // All checks passed: show the tooltip! - - true + Tooltip::was_tooltip_open_last_frame(&self.ctx, self.id) } /// Like `on_hover_text`, but show the text next to cursor. diff --git a/crates/egui_demo_lib/src/demo/context_menu.rs b/crates/egui_demo_lib/src/demo/context_menu.rs index 2dce3e763..35abc0800 100644 --- a/crates/egui_demo_lib/src/demo/context_menu.rs +++ b/crates/egui_demo_lib/src/demo/context_menu.rs @@ -1,3 +1,5 @@ +use egui::{ComboBox, Popup}; + #[derive(Clone, Default, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct ContextMenus {} @@ -32,6 +34,20 @@ impl crate::View for ContextMenus { } }); + ui.horizontal(|ui| { + let response = ui.button("New menu"); + Popup::menu(&response).show(Self::nested_menus); + + let response = ui.button("New context menu"); + Popup::context_menu(&response).show(Self::nested_menus); + + ComboBox::new("Hi", "Hi").show_ui(ui, |ui| { + _ = ui.selectable_label(false, "I have some long text that should be wrapped"); + _ = ui.selectable_label(false, "Short"); + _ = ui.selectable_label(false, "Medium length"); + }); + }); + ui.vertical_centered(|ui| { ui.add(crate::egui_github_link_file!()); }); @@ -51,6 +67,7 @@ impl ContextMenus { ui.close_menu(); } let _ = ui.button("Item"); + ui.menu_button("Recursive", Self::nested_menus) }); ui.menu_button("SubMenu", |ui| { if ui.button("Open…").clicked() { 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 6f753c3a4..80d32e697 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -78,6 +78,7 @@ impl Default for DemoGroups { Box::::default(), Box::::default(), Box::::default(), + Box::::default(), Box::::default(), Box::::default(), Box::::default(), diff --git a/crates/egui_demo_lib/src/demo/mod.rs b/crates/egui_demo_lib/src/demo/mod.rs index cb68a46fb..8042f1fe6 100644 --- a/crates/egui_demo_lib/src/demo/mod.rs +++ b/crates/egui_demo_lib/src/demo/mod.rs @@ -23,6 +23,7 @@ pub mod paint_bezier; pub mod painting; pub mod panels; pub mod password; +mod popups; pub mod scene; pub mod screenshot; pub mod scrolling; diff --git a/crates/egui_demo_lib/src/demo/popups.rs b/crates/egui_demo_lib/src/demo/popups.rs new file mode 100644 index 000000000..0eeb76272 --- /dev/null +++ b/crates/egui_demo_lib/src/demo/popups.rs @@ -0,0 +1,181 @@ +use egui::{vec2, Align2, ComboBox, Frame, Id, Popup, PopupCloseBehavior, RectAlign, Tooltip, Ui}; + +/// Showcase [`Popup`]. +#[derive(Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(default))] +pub struct PopupsDemo { + align4: RectAlign, + gap: f32, + #[cfg_attr(feature = "serde", serde(skip))] + close_behavior: PopupCloseBehavior, + popup_open: bool, +} + +impl PopupsDemo { + fn apply_options<'a>(&self, popup: Popup<'a>) -> Popup<'a> { + popup + .align(self.align4) + .gap(self.gap) + .close_behavior(self.close_behavior) + } +} + +impl Default for PopupsDemo { + fn default() -> Self { + Self { + align4: RectAlign::default(), + gap: 4.0, + close_behavior: PopupCloseBehavior::CloseOnClick, + popup_open: false, + } + } +} + +impl crate::Demo for PopupsDemo { + fn name(&self) -> &'static str { + "\u{2755} Popups" + } + + fn show(&mut self, ctx: &egui::Context, open: &mut bool) { + egui::Window::new(self.name()) + .open(open) + .resizable(false) + .default_width(250.0) + .constrain(false) + .show(ctx, |ui| { + use crate::View as _; + self.ui(ui); + }); + } +} + +impl crate::View for PopupsDemo { + fn ui(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + ui.style_mut().spacing.item_spacing.x = 0.0; + let align_combobox = |ui: &mut Ui, label: &str, align: &mut Align2| { + let aligns = [ + (Align2::LEFT_TOP, "Left top"), + (Align2::LEFT_CENTER, "Left center"), + (Align2::LEFT_BOTTOM, "Left bottom"), + (Align2::CENTER_TOP, "Center top"), + (Align2::CENTER_CENTER, "Center center"), + (Align2::CENTER_BOTTOM, "Center bottom"), + (Align2::RIGHT_TOP, "Right top"), + (Align2::RIGHT_CENTER, "Right center"), + (Align2::RIGHT_BOTTOM, "Right bottom"), + ]; + + ui.label(label); + ComboBox::new(label, "") + .selected_text(aligns.iter().find(|(a, _)| a == align).unwrap().1) + .show_ui(ui, |ui| { + for (align2, name) in &aligns { + ui.selectable_value(align, *align2, *name); + } + }); + }; + + ui.label("Align4("); + align_combobox(ui, "parent: ", &mut self.align4.parent); + ui.label(", "); + align_combobox(ui, "child: ", &mut self.align4.child); + ui.label(") "); + + let presets = [ + (RectAlign::TOP_START, "Top start"), + (RectAlign::TOP, "Top"), + (RectAlign::TOP_END, "Top end"), + (RectAlign::RIGHT_START, "Right start"), + (RectAlign::RIGHT, "Right Center"), + (RectAlign::RIGHT_END, "Right end"), + (RectAlign::BOTTOM_START, "Bottom start"), + (RectAlign::BOTTOM, "Bottom"), + (RectAlign::BOTTOM_END, "Bottom end"), + (RectAlign::LEFT_START, "Left start"), + (RectAlign::LEFT, "Left"), + (RectAlign::LEFT_END, "Left end"), + ]; + + ui.label(" Presets: "); + + ComboBox::new("Preset", "") + .selected_text( + presets + .iter() + .find(|(a, _)| a == &self.align4) + .map_or("Select", |(_, name)| *name), + ) + .show_ui(ui, |ui| { + for (align4, name) in &presets { + ui.selectable_value(&mut self.align4, *align4, *name); + } + }); + }); + ui.horizontal(|ui| { + ui.label("Gap:"); + ui.add(egui::DragValue::new(&mut self.gap)); + }); + ui.horizontal(|ui| { + ui.label("Close behavior:"); + ui.selectable_value( + &mut self.close_behavior, + PopupCloseBehavior::CloseOnClick, + "Close on click", + ) + .on_hover_text("Closes when the user clicks anywhere (inside or outside)"); + ui.selectable_value( + &mut self.close_behavior, + PopupCloseBehavior::CloseOnClickOutside, + "Close on click outside", + ) + .on_hover_text("Closes when the user clicks outside the popup"); + ui.selectable_value( + &mut self.close_behavior, + PopupCloseBehavior::IgnoreClicks, + "Ignore clicks", + ) + .on_hover_text("Close only when the button is clicked again"); + }); + + ui.checkbox(&mut self.popup_open, "Show popup"); + + let response = Frame::group(ui.style()) + .inner_margin(vec2(0.0, 25.0)) + .show(ui, |ui| { + ui.vertical_centered(|ui| ui.button("Click, right-click and hover me!")) + .inner + }) + .inner; + + self.apply_options(Popup::menu(&response).id(Id::new("menu"))) + .show(|ui| { + _ = ui.button("Menu item 1"); + _ = ui.button("Menu item 2"); + }); + + self.apply_options(Popup::context_menu(&response).id(Id::new("context_menu"))) + .show(|ui| { + _ = ui.button("Context menu item 1"); + _ = ui.button("Context menu item 2"); + }); + + if self.popup_open { + self.apply_options(Popup::from_response(&response).id(Id::new("popup"))) + .show(|ui| { + ui.label("Popup contents"); + }); + } + + let mut tooltip = Tooltip::for_enabled(&response); + tooltip.popup = self.apply_options(tooltip.popup); + tooltip.show(|ui| { + ui.label("Tooltips are popups, too!"); + }); + + ui.vertical_centered(|ui| { + ui.add(crate::egui_github_link_file!()); + }); + } +} diff --git a/crates/egui_demo_lib/src/demo/tooltips.rs b/crates/egui_demo_lib/src/demo/tooltips.rs index ede4bb9fb..0e391c553 100644 --- a/crates/egui_demo_lib/src/demo/tooltips.rs +++ b/crates/egui_demo_lib/src/demo/tooltips.rs @@ -83,6 +83,9 @@ impl Tooltips { ui.label("You can select this text."); }); + ui.label("This tooltip shows at the mouse cursor.") + .on_hover_text_at_pointer("Move me around!!"); + ui.separator(); // --------------------------------------------------------- let tooltip_ui = |ui: &mut egui::Ui| { diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Context Menus.png b/crates/egui_demo_lib/tests/snapshots/demos/Context Menus.png index ea25033bf..ac3c736a0 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Context Menus.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Context Menus.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d4cc8e0919fed5bd1ef981658626dba728435ab95da8ee96ced1fb4838d535ff -size 11741 +oid sha256:eb2bc4a38f20ed0f5fced36e8e56936bee328b24a0a45127d5d3739d40331cb7 +size 15514 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Popups.png b/crates/egui_demo_lib/tests/snapshots/demos/Popups.png new file mode 100644 index 000000000..1575cbe08 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Popups.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4806984f9c801a054cea80b89664293680abaa57cf0a95cf9682f111e3794fc1 +size 25080 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Tooltips.png b/crates/egui_demo_lib/tests/snapshots/demos/Tooltips.png index 6d9aced18..f8bb020e2 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Tooltips.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Tooltips.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c80158ac9c823f94d2830d1423236ad441dc7da31e748b6815c69663fa2a03d0 -size 59662 +oid sha256:92b70683a685869274749d057de174896e18dae5cb67e70221c3efdb7106cdda +size 63684 diff --git a/crates/egui_kittest/tests/popup.rs b/crates/egui_kittest/tests/popup.rs new file mode 100644 index 000000000..f55bf6388 --- /dev/null +++ b/crates/egui_kittest/tests/popup.rs @@ -0,0 +1,31 @@ +use kittest::Queryable; + +#[test] +fn test_interactive_tooltip() { + struct State { + link_clicked: bool, + } + + let mut harness = egui_kittest::Harness::new_ui_state( + |ui, state| { + ui.label("I have a tooltip").on_hover_ui(|ui| { + if ui.link("link").clicked() { + state.link_clicked = true; + } + }); + }, + State { + link_clicked: false, + }, + ); + + harness.get_by_label_contains("tooltip").hover(); + harness.run(); + harness.get_by_label("link").hover(); + harness.run(); + harness.get_by_label("link").simulate_click(); + + harness.run(); + + assert!(harness.state().link_clicked); +} diff --git a/crates/emath/src/align.rs b/crates/emath/src/align.rs index 71f172441..b1b56755e 100644 --- a/crates/emath/src/align.rs +++ b/crates/emath/src/align.rs @@ -50,6 +50,16 @@ impl Align { } } + /// Returns the inverse alignment. + /// `Min` becomes `Max`, `Center` stays the same, `Max` becomes `Min`. + pub fn flip(self) -> Self { + match self { + Self::Min => Self::Max, + Self::Center => Self::Center, + Self::Max => Self::Min, + } + } + /// Returns a range of given size within a specified range. /// /// If the requested `size` is bigger than the size of `range`, then the returned @@ -170,6 +180,24 @@ impl Align2 { vec2(self.x().to_sign(), self.y().to_sign()) } + /// Flip on the x-axis + /// e.g. `TOP_LEFT` -> `TOP_RIGHT` + pub fn flip_x(self) -> Self { + Self([self.x().flip(), self.y()]) + } + + /// Flip on the y-axis + /// e.g. `TOP_LEFT` -> `BOTTOM_LEFT` + pub fn flip_y(self) -> Self { + Self([self.x(), self.y().flip()]) + } + + /// Flip on both axes + /// e.g. `TOP_LEFT` -> `BOTTOM_RIGHT` + pub fn flip(self) -> Self { + Self([self.x().flip(), self.y().flip()]) + } + /// Used e.g. to anchor a piece of text to a part of the rectangle. /// Give a position within the rect, specified by the aligns pub fn anchor_rect(self, rect: Rect) -> Rect { diff --git a/crates/emath/src/lib.rs b/crates/emath/src/lib.rs index 05210fbb2..ae04f9ecf 100644 --- a/crates/emath/src/lib.rs +++ b/crates/emath/src/lib.rs @@ -34,6 +34,7 @@ mod ordered_float; mod pos2; mod range; mod rect; +mod rect_align; mod rect_transform; mod rot2; pub mod smart_aim; @@ -50,6 +51,7 @@ pub use self::{ pos2::*, range::Rangef, rect::*, + rect_align::RectAlign, rect_transform::*, rot2::*, ts_transform::*, diff --git a/crates/emath/src/rect_align.rs b/crates/emath/src/rect_align.rs new file mode 100644 index 000000000..5a8102ad1 --- /dev/null +++ b/crates/emath/src/rect_align.rs @@ -0,0 +1,279 @@ +use crate::{Align2, Pos2, Rect, Vec2}; + +/// Position a child [`Rect`] relative to a parent [`Rect`]. +/// +/// The corner from [`RectAlign::child`] on the new rect will be aligned to +/// the corner from [`RectAlign::parent`] on the original rect. +/// +/// There are helper constants for the 12 common menu positions: +/// ```text +/// ┌───────────┐ ┌────────┐ ┌─────────┐ +/// │ TOP_START │ │ TOP │ │ TOP_END │ +/// └───────────┘ └────────┘ └─────────┘ +/// ┌──────────┐ ┌────────────────────────────────────┐ ┌───────────┐ +/// │LEFT_START│ │ │ │RIGHT_START│ +/// └──────────┘ │ │ └───────────┘ +/// ┌──────────┐ │ │ ┌───────────┐ +/// │ LEFT │ │ some_rect │ │ RIGHT │ +/// └──────────┘ │ │ └───────────┘ +/// ┌──────────┐ │ │ ┌───────────┐ +/// │ LEFT_END │ │ │ │ RIGHT_END │ +/// └──────────┘ └────────────────────────────────────┘ └───────────┘ +/// ┌────────────┐ ┌──────┐ ┌──────────┐ +/// │BOTTOM_START│ │BOTTOM│ │BOTTOM_END│ +/// └────────────┘ └──────┘ └──────────┘ +/// ``` +// There is no `new` function on purpose, since writing out `parent` and `child` is more +// reasonable. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct RectAlign { + /// The alignment in the parent (original) rect. + pub parent: Align2, + + /// The alignment in the child (new) rect. + pub child: Align2, +} + +impl Default for RectAlign { + fn default() -> Self { + Self::BOTTOM_START + } +} + +impl RectAlign { + /// Along the top edge, leftmost. + pub const TOP_START: Self = Self { + parent: Align2::LEFT_TOP, + child: Align2::LEFT_BOTTOM, + }; + + /// Along the top edge, centered. + pub const TOP: Self = Self { + parent: Align2::CENTER_TOP, + child: Align2::CENTER_BOTTOM, + }; + + /// Along the top edge, rightmost. + pub const TOP_END: Self = Self { + parent: Align2::RIGHT_TOP, + child: Align2::RIGHT_BOTTOM, + }; + + /// Along the right edge, topmost. + pub const RIGHT_START: Self = Self { + parent: Align2::RIGHT_TOP, + child: Align2::LEFT_TOP, + }; + + /// Along the right edge, centered. + pub const RIGHT: Self = Self { + parent: Align2::RIGHT_CENTER, + child: Align2::LEFT_CENTER, + }; + + /// Along the right edge, bottommost. + pub const RIGHT_END: Self = Self { + parent: Align2::RIGHT_BOTTOM, + child: Align2::LEFT_BOTTOM, + }; + + /// Along the bottom edge, rightmost. + pub const BOTTOM_END: Self = Self { + parent: Align2::RIGHT_BOTTOM, + child: Align2::RIGHT_TOP, + }; + + /// Along the bottom edge, centered. + pub const BOTTOM: Self = Self { + parent: Align2::CENTER_BOTTOM, + child: Align2::CENTER_TOP, + }; + + /// Along the bottom edge, leftmost. + pub const BOTTOM_START: Self = Self { + parent: Align2::LEFT_BOTTOM, + child: Align2::LEFT_TOP, + }; + + /// Along the left edge, bottommost. + pub const LEFT_END: Self = Self { + parent: Align2::LEFT_BOTTOM, + child: Align2::RIGHT_BOTTOM, + }; + + /// Along the left edge, centered. + pub const LEFT: Self = Self { + parent: Align2::LEFT_CENTER, + child: Align2::RIGHT_CENTER, + }; + + /// Along the left edge, topmost. + pub const LEFT_START: Self = Self { + parent: Align2::LEFT_TOP, + child: Align2::RIGHT_TOP, + }; + + /// The 12 most common menu positions as an array, for use with [`RectAlign::find_best_align`]. + pub const MENU_ALIGNS: [Self; 12] = [ + Self::BOTTOM_START, + Self::BOTTOM_END, + Self::TOP_START, + Self::TOP_END, + Self::RIGHT_END, + Self::RIGHT_START, + Self::LEFT_END, + Self::LEFT_START, + // These come last on purpose, we prefer the corner ones + Self::TOP, + Self::RIGHT, + Self::BOTTOM, + Self::LEFT, + ]; + + /// Align in the parent rect. + pub fn parent(&self) -> Align2 { + self.parent + } + + /// Align in the child rect. + pub fn child(&self) -> Align2 { + self.child + } + + /// Convert an [`Align2`] to an [`RectAlign`], positioning the child rect inside the parent. + pub fn from_align2(align: Align2) -> Self { + Self { + parent: align, + child: align, + } + } + + /// The center of the child rect will be aligned to a corner of the parent rect. + pub fn over_corner(align: Align2) -> Self { + Self { + parent: align, + child: Align2::CENTER_CENTER, + } + } + + /// Position the child rect outside the parent rect. + pub fn outside(align: Align2) -> Self { + Self { + parent: align, + child: align.flip(), + } + } + + /// Calculate the child rect based on a size and some optional gap. + pub fn align_rect(&self, parent_rect: &Rect, size: Vec2, gap: f32) -> Rect { + let (pivot, anchor) = self.pivot_pos(parent_rect, gap); + pivot.anchor_size(anchor, size) + } + + /// Returns a [`Align2`] and a [`Pos2`] that you can e.g. use with `Area::fixed_pos` + /// and `Area::pivot` to align an `Area` to some rect. + pub fn pivot_pos(&self, parent_rect: &Rect, gap: f32) -> (Align2, Pos2) { + (self.child(), self.anchor(parent_rect, gap)) + } + + /// Returns a sign vector (-1, 0 or 1 in each direction) that can be used as an offset to the + /// child rect, creating a gap between the rects while keeping the edges aligned. + pub fn gap_vector(&self) -> Vec2 { + let mut gap = -self.child.to_sign(); + + // Align the edges in these cases + match *self { + Self::TOP_START | Self::TOP_END | Self::BOTTOM_START | Self::BOTTOM_END => { + gap.x = 0.0; + } + Self::LEFT_START | Self::LEFT_END | Self::RIGHT_START | Self::RIGHT_END => { + gap.y = 0.0; + } + _ => {} + } + + gap + } + + /// Calculator the anchor point for the child rect, based on the parent rect and an optional gap. + pub fn anchor(&self, parent_rect: &Rect, gap: f32) -> Pos2 { + let pos = self.parent.pos_in_rect(parent_rect); + + let offset = self.gap_vector() * gap; + + pos + offset + } + + /// Flip the alignment on the x-axis. + pub fn flip_x(self) -> Self { + Self { + parent: self.parent.flip_x(), + child: self.child.flip_x(), + } + } + + /// Flip the alignment on the y-axis. + pub fn flip_y(self) -> Self { + Self { + parent: self.parent.flip_y(), + child: self.child.flip_y(), + } + } + + /// Flip the alignment on both axes. + pub fn flip(self) -> Self { + Self { + parent: self.parent.flip(), + child: self.child.flip(), + } + } + + /// Returns the 3 alternative [`RectAlign`]s that are flipped in various ways, for use + /// with [`RectAlign::find_best_align`]. + pub fn symmetries(self) -> [Self; 3] { + [self.flip_x(), self.flip_y(), self.flip()] + } + + /// Look for the [`RectAlign`] that fits best in the available space. + /// + /// See also: + /// - [`RectAlign::symmetries`] to calculate alternatives + /// - [`RectAlign::MENU_ALIGNS`] for the 12 common menu positions + pub fn find_best_align( + mut values_to_try: impl Iterator, + available_space: Rect, + parent_rect: Rect, + gap: f32, + size: Vec2, + ) -> Self { + let area = size.x * size.y; + + let blocked_area = |pos: Self| { + let rect = pos.align_rect(&parent_rect, size, gap); + area - available_space.intersect(rect).area() + }; + + let first = values_to_try.next().unwrap_or_default(); + + if blocked_area(first) == 0.0 { + return first; + } + + let mut best_area = blocked_area(first); + let mut best = first; + + for align in values_to_try { + let blocked = blocked_area(align); + if blocked == 0.0 { + return align; + } + if blocked < best_area { + best = align; + best_area = blocked; + } + } + + best + } +} diff --git a/examples/popups/src/main.rs b/examples/popups/src/main.rs index baed12c2a..3da475832 100644 --- a/examples/popups/src/main.rs +++ b/examples/popups/src/main.rs @@ -1,7 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release #![allow(rustdoc::missing_crate_level_docs)] // it's an example -use eframe::egui::{popup_below_widget, CentralPanel, ComboBox, Id, PopupCloseBehavior}; +use eframe::egui::{CentralPanel, ComboBox, Popup, PopupCloseBehavior}; fn main() -> Result<(), eframe::Error> { env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). @@ -42,23 +42,14 @@ impl eframe::App for MyApp { ui.label("PopupCloseBehavior::IgnoreClicks popup"); let response = ui.button("Open"); - let popup_id = Id::new("popup_id"); - if response.clicked() { - ui.memory_mut(|mem| mem.toggle_popup(popup_id)); - } - - popup_below_widget( - ui, - popup_id, - &response, - PopupCloseBehavior::IgnoreClicks, - |ui| { + Popup::menu(&response) + .close_behavior(PopupCloseBehavior::IgnoreClicks) + .show(|ui| { ui.set_min_width(310.0); ui.label("This popup will be open until you press the button again"); ui.checkbox(&mut self.checkbox, "Checkbox"); - }, - ); + }); }); } } From 770c976ed719eaf99a40e45cb3d194a2c99430af Mon Sep 17 00:00:00 2001 From: Braden Steffaniak Date: Tue, 18 Feb 2025 11:30:50 -0500 Subject: [PATCH 010/379] Fix image_loader for animated image types (#5688) Hi, after upgrading to 0.31.0 all of my beautiful static webp images started failing to load. I use the image_loader to load those via the `image` crate. I noticed that with 0.31.0 there are additions to how animated image types are handled with frames and such. And with those changes the frame index is attached to the uri at the end. This was problematic for the image_loader, because it wasn't updated to handle that frame tag at the end of the uri, so when looking up the bytes, it would fail to match the uri in the bytes cache (the bytes were being saved without the frame index, but attempting to be fetched _with_ the frame index). This fixes the image_loader for me with webp & gif. They don't load the animations, but I think that is because I don't have the custom image_loader set up so I'm not worried about that for myself. I'm not sure if that part is problematic in general, or if its just the way I have my features set up. You can recreate the issue on master by swapping out the dependency features in the `images` example like this: ``` # egui_extras = { workspace = true, features = ["default", "all_loaders"] } # env_logger = { version = "0.10", default-features = false, features = [ # "auto-color", # "humantime", # ] } # image = { workspace = true, features = ["jpeg", "png"] } egui_extras = { workspace = true, features = ["image", "svg"] } env_logger = { version = "0.10", default-features = false, features = [ "auto-color", "humantime", ] } image = { workspace = true, features = ["jpeg", "png", "webp", "gif"] } ``` * [x] I have followed the instructions in the PR template --------- Co-authored-by: lucasmerlin --- crates/egui_extras/src/loaders/image_loader.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/egui_extras/src/loaders/image_loader.rs b/crates/egui_extras/src/loaders/image_loader.rs index 171e56170..a2a6fb1df 100644 --- a/crates/egui_extras/src/loaders/image_loader.rs +++ b/crates/egui_extras/src/loaders/image_loader.rs @@ -1,5 +1,6 @@ use ahash::HashMap; use egui::{ + decode_animated_image_uri, load::{BytesPoll, ImageLoadResult, ImageLoader, ImagePoll, LoadError, SizeHint}, mutex::Mutex, ColorImage, @@ -58,6 +59,11 @@ impl ImageLoader for ImageCrateLoader { // 2. Mime from `BytesPoll::Ready` // 3. image::guess_format (used internally by image::load_from_memory) + // TODO(lucasmerlin): Egui currently changes all URIs for webp and gif files to include + // the frame index (#0), which breaks if the animated image loader is disabled. + // We work around this by removing the frame index from the URI here + let uri = decode_animated_image_uri(uri).map_or(uri, |(uri, _frame_index)| uri); + // (1) if uri.starts_with("file://") && !is_supported_uri(uri) { return Err(LoadError::NotSupported); From 071e090e2b2601e5ed4726a63a753188503dfaf2 Mon Sep 17 00:00:00 2001 From: Bryce Berger Date: Tue, 18 Feb 2025 11:33:27 -0500 Subject: [PATCH 011/379] add Label::show_tooltip_when_elided (#5710) fixes #5708 Allows the user to disable the automatic tooltip when a Label is elided * Closes * [x] I have followed the instructions in the PR template --- crates/egui/src/widgets/label.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/egui/src/widgets/label.rs b/crates/egui/src/widgets/label.rs index 67dc196bc..34b684df6 100644 --- a/crates/egui/src/widgets/label.rs +++ b/crates/egui/src/widgets/label.rs @@ -30,6 +30,7 @@ pub struct Label { sense: Option, selectable: Option, halign: Option, + show_tooltip_when_elided: bool, } impl Label { @@ -40,6 +41,7 @@ impl Label { sense: None, selectable: None, halign: None, + show_tooltip_when_elided: true, } } @@ -116,6 +118,23 @@ impl Label { self.sense = Some(sense); self } + + /// Show the full text when hovered, if the text was elided. + /// + /// By default, this is true. + /// + /// ``` + /// # use egui::{Label, Sense}; + /// # egui::__run_test_ui(|ui| { + /// ui.add(Label::new("some text").show_tooltip_when_elided(false)) + /// .on_hover_text("completely different text"); + /// # }); + /// ``` + #[inline] + pub fn show_tooltip_when_elided(mut self, show: bool) -> Self { + self.show_tooltip_when_elided = show; + self + } } impl Label { @@ -247,13 +266,14 @@ impl Widget for Label { let interactive = self.sense.is_some_and(|sense| sense != Sense::hover()); let selectable = self.selectable; + let show_tooltip_when_elided = self.show_tooltip_when_elided; let (galley_pos, galley, mut response) = self.layout_in_ui(ui); response .widget_info(|| WidgetInfo::labeled(WidgetType::Label, ui.is_enabled(), galley.text())); if ui.is_rect_visible(response.rect) { - if galley.elided { + if show_tooltip_when_elided && galley.elided { // Show the full (non-elided) text on hover: response = response.on_hover_text(galley.text()); } From 43261a53965aa57dd12e6e9a2a91137b582b7450 Mon Sep 17 00:00:00 2001 From: Aiden Date: Wed, 19 Feb 2025 01:01:07 +0800 Subject: [PATCH 012/379] Add pointer events and focus handling for apps run in a Shadow DOM (#5627) * [x] I have followed the instructions in the PR template This PR handles pointer events and focus which did following changes: - `element_from_point` and focus is now acquired from root node object by using `get_root_node` from document or a shadow root. - `TextAgent` is appended individually in each shadow root. These changes handles pointer events and focus well in a web app that are running in a shadow dom, or else the hover pointer actions and keyboard input events are not triggered in a shadow dom. Helpful for building embeddable/multi-view web-apps. --- crates/eframe/Cargo.toml | 1 + crates/eframe/src/web/events.rs | 19 +++++++++++++------ crates/eframe/src/web/mod.rs | 20 ++++++++++++-------- crates/eframe/src/web/text_agent.rs | 15 +++++++++++++-- crates/eframe/src/web/web_runner.rs | 2 +- 5 files changed, 40 insertions(+), 17 deletions(-) diff --git a/crates/eframe/Cargo.toml b/crates/eframe/Cargo.toml index 883ec7d59..774bcbbe9 100644 --- a/crates/eframe/Cargo.toml +++ b/crates/eframe/Cargo.toml @@ -253,6 +253,7 @@ web-sys = { workspace = true, features = [ "ResizeObserverEntry", "ResizeObserverOptions", "ResizeObserverSize", + "ShadowRoot", "Storage", "Touch", "TouchEvent", diff --git a/crates/eframe/src/web/events.rs b/crates/eframe/src/web/events.rs index 762f202fa..6a1b7b6db 100644 --- a/crates/eframe/src/web/events.rs +++ b/crates/eframe/src/web/events.rs @@ -1,5 +1,3 @@ -use web_sys::EventTarget; - use crate::web::string_from_js_value; use super::{ @@ -10,6 +8,8 @@ use super::{ DEBUG_RESIZE, }; +use web_sys::{Document, EventTarget, ShadowRoot}; + // TODO(emilk): there are more calls to `prevent_default` and `stop_propagation` // than what is probably needed. @@ -570,10 +570,17 @@ fn install_pointerup(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), /// Returns true if the cursor is above the canvas, or if we're dragging something. /// Pass in the position in browser viewport coordinates (usually event.clientX/Y). fn is_interested_in_pointer_event(runner: &AppRunner, pos: egui::Pos2) -> bool { - let document = web_sys::window().unwrap().document().unwrap(); - let is_hovering_canvas = document - .element_from_point(pos.x, pos.y) - .is_some_and(|element| element.eq(runner.canvas())); + let root_node = runner.canvas().get_root_node(); + + let element_at_point = if let Some(document) = root_node.dyn_ref::() { + document.element_from_point(pos.x, pos.y) + } else if let Some(shadow) = root_node.dyn_ref::() { + shadow.element_from_point(pos.x, pos.y) + } else { + None + }; + + let is_hovering_canvas = element_at_point.is_some_and(|element| element.eq(runner.canvas())); let is_pointer_down = runner .egui_ctx() .input(|i| i.pointer.any_down() || i.any_touches()); diff --git a/crates/eframe/src/web/mod.rs b/crates/eframe/src/web/mod.rs index 3dc7d7f8b..c67fa69e6 100644 --- a/crates/eframe/src/web/mod.rs +++ b/crates/eframe/src/web/mod.rs @@ -41,7 +41,7 @@ pub(crate) type ActiveWebPainter = web_painter_wgpu::WebPainterWgpu; pub use backend::*; use wasm_bindgen::prelude::*; -use web_sys::MediaQueryList; +use web_sys::{Document, MediaQueryList, Node}; use input::{ button_from_mouse_event, modifiers_from_kb_event, modifiers_from_mouse_event, @@ -64,18 +64,22 @@ pub(crate) fn string_from_js_value(value: &JsValue) -> String { /// - ``/`` with an `href` attribute /// - ``/`