From 6d312cc4c79d2a21941af40c4496ed5b3adb78c4 Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Thu, 3 Jul 2025 12:02:05 +0200 Subject: [PATCH 01/19] Add support for scrolling via accesskit / kittest (#7286) I need to scroll in a snapshot test in my app, and kittest had no utilities for this. Event::MouseWheel is error prone. This adds support for some accesskit scroll actions, and uses this in kittest to add helpers to scroll to a node / scroll the scroll area surrounding a node. The accesskit code says down/up/left/right `Scrolls by approximately one screen in a specific direction.`. Unfortunately it's difficult to get the size of a "screen" (I guess that would be the size of the containing scroll area)where I implemented the scrolling, so for now I've hardcoded it to 100px. I think scrolling a fixed amount is still better than not scrolling at all. --------- Co-authored-by: Emil Ernerfeldt --- crates/egui/src/context.rs | 47 +++++++++++++- crates/egui/src/input_state/mod.rs | 17 ++++++ crates/egui_kittest/src/lib.rs | 16 ++++- crates/egui_kittest/src/node.rs | 45 ++++++++++++++ .../tests/snapshots/test_scroll_initial.png | 3 + .../tests/snapshots/test_scroll_scrolled.png | 3 + crates/egui_kittest/tests/tests.rs | 61 ++++++++++++++++++- 7 files changed, 186 insertions(+), 6 deletions(-) create mode 100644 crates/egui_kittest/tests/snapshots/test_scroll_initial.png create mode 100644 crates/egui_kittest/tests/snapshots/test_scroll_scrolled.png diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 80bcf570f..abb03b1c8 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -1149,7 +1149,7 @@ impl Context { ID clashes happens when things like Windows or CollapsingHeaders share names,\n\ or when things like Plot and Grid:s aren't given unique id_salt:s.\n\n\ Sometimes the solution is to use ui.push_id.", - if below { "above" } else { "below" }) + if below { "above" } else { "below" }), ); } } @@ -1216,6 +1216,51 @@ impl Context { self.accesskit_node_builder(w.id, |builder| res.fill_accesskit_node_common(builder)); } + #[cfg(feature = "accesskit")] + self.write(|ctx| { + use crate::{Align, pass_state::ScrollTarget, style::ScrollAnimation}; + let viewport = ctx.viewport_for(ctx.viewport_id()); + + viewport + .input + .consume_accesskit_action_requests(res.id, |request| { + // TODO(lucasmerlin): Correctly handle the scroll unit: + // https://github.com/AccessKit/accesskit/blob/e639c0e0d8ccbfd9dff302d972fa06f9766d608e/common/src/lib.rs#L2621 + const DISTANCE: f32 = 100.0; + + match &request.action { + accesskit::Action::ScrollIntoView => { + viewport.this_pass.scroll_target = [ + Some(ScrollTarget::new( + res.rect.x_range(), + Some(Align::Center), + ScrollAnimation::none(), + )), + Some(ScrollTarget::new( + res.rect.y_range(), + Some(Align::Center), + ScrollAnimation::none(), + )), + ]; + } + accesskit::Action::ScrollDown => { + viewport.this_pass.scroll_delta.0 += DISTANCE * Vec2::UP; + } + accesskit::Action::ScrollUp => { + viewport.this_pass.scroll_delta.0 += DISTANCE * Vec2::DOWN; + } + accesskit::Action::ScrollLeft => { + viewport.this_pass.scroll_delta.0 += DISTANCE * Vec2::LEFT; + } + accesskit::Action::ScrollRight => { + viewport.this_pass.scroll_delta.0 += DISTANCE * Vec2::RIGHT; + } + _ => return false, + }; + true + }); + }); + res } diff --git a/crates/egui/src/input_state/mod.rs b/crates/egui/src/input_state/mod.rs index d87bd5669..fd3e78a21 100644 --- a/crates/egui/src/input_state/mod.rs +++ b/crates/egui/src/input_state/mod.rs @@ -824,6 +824,23 @@ impl InputState { }) } + #[cfg(feature = "accesskit")] + pub fn consume_accesskit_action_requests( + &mut self, + id: crate::Id, + mut consume: impl FnMut(&accesskit::ActionRequest) -> bool, + ) { + let accesskit_id = id.accesskit_id(); + self.events.retain(|event| { + if let Event::AccessKitActionRequest(request) = event { + if request.target == accesskit_id { + return !consume(request); + } + } + true + }); + } + #[cfg(feature = "accesskit")] pub fn has_accesskit_action_request(&self, id: crate::Id, action: accesskit::Action) -> bool { self.accesskit_action_requests(id, action).next().is_some() diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index 01aef3266..6adefe53b 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -28,6 +28,7 @@ pub use builder::*; pub use node::*; pub use renderer::*; +use egui::style::ScrollAnimation; use egui::{Key, Modifiers, Pos2, Rect, RepaintCause, Vec2, ViewportId}; use kittest::Queryable; @@ -55,6 +56,10 @@ impl Display for ExceededMaxStepsError { /// The [Harness] has a optional generic state that can be used to pass data to the app / ui closure. /// In _most cases_ it should be fine to just store the state in the closure itself. /// The state functions are useful if you need to access the state after the harness has been created. +/// +/// Some egui style options are changed from the defaults: +/// - The cursor blinking is disabled +/// - The scroll animation is disabled pub struct Harness<'a, State = ()> { pub ctx: egui::Context, input: egui::RawInput, @@ -96,8 +101,12 @@ impl<'a, State> Harness<'a, State> { let ctx = ctx.unwrap_or_default(); ctx.set_theme(theme); ctx.enable_accesskit(); - // Disable cursor blinking so it doesn't interfere with snapshots - ctx.all_styles_mut(|style| style.visuals.text_cursor.blink = false); + ctx.all_styles_mut(|style| { + // Disable cursor blinking so it doesn't interfere with snapshots + style.visuals.text_cursor.blink = false; + style.scroll_animation = ScrollAnimation::none(); + style.animation_time = 0.0; + }); let mut input = egui::RawInput { screen_rect: Some(screen_rect), ..Default::default() @@ -564,7 +573,8 @@ impl<'a, State> Harness<'a, State> { .expect("Missing root viewport") } - fn root(&self) -> Node<'_> { + /// The root node of the test harness. + pub fn root(&self) -> Node<'_> { Node { accesskit_node: self.kittest.root(), queue: &self.queued_events, diff --git a/crates/egui_kittest/src/node.rs b/crates/egui_kittest/src/node.rs index 51a0cc3a0..94940ffff 100644 --- a/crates/egui_kittest/src/node.rs +++ b/crates/egui_kittest/src/node.rs @@ -159,4 +159,49 @@ impl Node<'_> { pub fn is_focused(&self) -> bool { self.accesskit_node.is_focused() } + + /// Scroll the node into view. + pub fn scroll_to_me(&self) { + self.event(egui::Event::AccessKitActionRequest(ActionRequest { + action: accesskit::Action::ScrollIntoView, + target: self.accesskit_node.id(), + data: None, + })); + } + + /// Scroll the [`egui::ScrollArea`] containing this node down (100px). + pub fn scroll_down(&self) { + self.event(egui::Event::AccessKitActionRequest(ActionRequest { + action: accesskit::Action::ScrollDown, + target: self.accesskit_node.id(), + data: None, + })); + } + + /// Scroll the [`egui::ScrollArea`] containing this node up (100px). + pub fn scroll_up(&self) { + self.event(egui::Event::AccessKitActionRequest(ActionRequest { + action: accesskit::Action::ScrollUp, + target: self.accesskit_node.id(), + data: None, + })); + } + + /// Scroll the [`egui::ScrollArea`] containing this node left (100px). + pub fn scroll_left(&self) { + self.event(egui::Event::AccessKitActionRequest(ActionRequest { + action: accesskit::Action::ScrollLeft, + target: self.accesskit_node.id(), + data: None, + })); + } + + /// Scroll the [`egui::ScrollArea`] containing this node right (100px). + pub fn scroll_right(&self) { + self.event(egui::Event::AccessKitActionRequest(ActionRequest { + action: accesskit::Action::ScrollRight, + target: self.accesskit_node.id(), + data: None, + })); + } } diff --git a/crates/egui_kittest/tests/snapshots/test_scroll_initial.png b/crates/egui_kittest/tests/snapshots/test_scroll_initial.png new file mode 100644 index 000000000..32969d743 --- /dev/null +++ b/crates/egui_kittest/tests/snapshots/test_scroll_initial.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70d76e55327de17163bc9c7e128c28153f95db3229dec919352a024eb80544f1 +size 7399 diff --git a/crates/egui_kittest/tests/snapshots/test_scroll_scrolled.png b/crates/egui_kittest/tests/snapshots/test_scroll_scrolled.png new file mode 100644 index 000000000..361925d04 --- /dev/null +++ b/crates/egui_kittest/tests/snapshots/test_scroll_scrolled.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4b7b3145401b7cf9815a652a0914b230892ffda3b5e23fea530dafee9c0c3d3 +size 8110 diff --git a/crates/egui_kittest/tests/tests.rs b/crates/egui_kittest/tests/tests.rs index b4e49642f..6d66c5f5a 100644 --- a/crates/egui_kittest/tests/tests.rs +++ b/crates/egui_kittest/tests/tests.rs @@ -1,5 +1,5 @@ -use egui::{Modifiers, Vec2, include_image}; -use egui_kittest::Harness; +use egui::{Modifiers, ScrollArea, Vec2, include_image}; +use egui_kittest::{Harness, SnapshotResults}; use kittest::Queryable as _; #[test] @@ -81,3 +81,60 @@ fn should_wait_for_images() { harness.snapshot("should_wait_for_images"); } + +fn test_scroll_harness() -> Harness<'static, bool> { + Harness::builder() + .with_size(Vec2::new(100.0, 200.0)) + .build_ui_state( + |ui, state| { + ScrollArea::vertical().show(ui, |ui| { + for i in 0..20 { + ui.label(format!("Item {i}")); + } + if ui.button("Hidden Button").clicked() { + *state = true; + }; + }); + }, + false, + ) +} + +#[test] +fn test_scroll_to_me() { + let mut harness = test_scroll_harness(); + let mut results = SnapshotResults::new(); + + results.add(harness.try_snapshot("test_scroll_initial")); + + harness.get_by_label("Hidden Button").scroll_to_me(); + + harness.run(); + results.add(harness.try_snapshot("test_scroll_scrolled")); + + harness.get_by_label("Hidden Button").click(); + harness.run(); + + assert!( + harness.state(), + "The button was not clicked after scrolling." + ); +} + +#[test] +fn test_scroll_down() { + let mut harness = test_scroll_harness(); + + let button = harness.get_by_label("Hidden Button"); + button.scroll_down(); + button.scroll_down(); + harness.run(); + + harness.get_by_label("Hidden Button").click(); + harness.run(); + + assert!( + harness.state(), + "The button was not clicked after scrolling down. (Probably not scrolled enough / at all)" + ); +} From db3543d034b3de310b20036959b5a100e4977b0e Mon Sep 17 00:00:00 2001 From: Blackberry Float Date: Thu, 3 Jul 2025 07:14:07 -0400 Subject: [PATCH 02/19] Update area struct to allow force resizing (#7114) This is a really small PR so I am skipping the issue (based on contributing.md). This change adds an optional field and thus non breaking for the API. I ran into an issue during my development of an alerts manager widget ([see PR](https://github.com/blackberryfloat/egui_widget_ext/pull/2)) where I needed a scrollable overlay that did not block clicking areas of a parent widget when my alerts did not take up the entire parent. To achieve this I detect the sizing pass via the invisible flag and only render the alerts content and then on the next pass I add the scroll bar in around the alert content. Whenever the alert content changed though I would need to create a new Area with a new id to get proper sizing. That is a memory leak so I wanted to reset the size state to trigger a sizing pass. Memory is rightfully protected enough that the path to remove memory was dropped and I just added a hook to set a resize flag. I am sure there are better ways but this is what made sense to me. Looking forward to thoughts. ~~Logistics wise, I have proposed it as a patch because I was based off 0.31.1 for testing. I was also thinking it could be released quickly. I am happy to cherry pick onto main after. If that is not allowed I can rebase to main and pull against that.~~ (rebased on main) --------- Co-authored-by: Wesley Murray --- crates/egui/src/containers/area.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/egui/src/containers/area.rs b/crates/egui/src/containers/area.rs index d40df8358..5ae4a4b30 100644 --- a/crates/egui/src/containers/area.rs +++ b/crates/egui/src/containers/area.rs @@ -121,6 +121,7 @@ pub struct Area { new_pos: Option, fade_in: bool, layout: Layout, + sizing_pass: bool, } impl WidgetWithState for Area { @@ -147,6 +148,7 @@ impl Area { anchor: None, fade_in: true, layout: Layout::default(), + sizing_pass: false, } } @@ -357,6 +359,27 @@ impl Area { self.layout = layout; self } + + /// While true, a sizing pass will be done. This means the area will be invisible + /// and the contents will be laid out to estimate the proper containing size of the area. + /// If false, there will be no change to the default area behavior. This is useful if the + /// area contents area dynamic and you need to need to make sure the area adjusts its size + /// accordingly. + /// + /// This should only be set to true during the specific frames you want force a sizing pass. + /// Do NOT hard-code this as `.sizing_pass(true)`, as it will cause the area to never be + /// visible. + /// + /// # Arguments + /// - resize: If true, the area will be resized to fit its contents. False will keep the + /// default area resizing behavior. + /// + /// Default: `false`. + #[inline] + pub fn sizing_pass(mut self, resize: bool) -> Self { + self.sizing_pass = resize; + self + } } pub(crate) struct Prepared { @@ -410,6 +433,7 @@ impl Area { constrain_rect, fade_in, layout, + sizing_pass: force_sizing_pass, } = self; let constrain_rect = constrain_rect.unwrap_or_else(|| ctx.screen_rect()); @@ -425,6 +449,10 @@ impl Area { interactable, last_became_visible_at: None, }); + if force_sizing_pass { + sizing_pass = true; + state.size = None; + } state.pivot = pivot; state.interactable = interactable; if let Some(new_pos) = new_pos { From ba577602a434c78260e1c0582cdc18618c59f2fa Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Thu, 3 Jul 2025 13:40:02 +0200 Subject: [PATCH 03/19] Fix crash when using infinite widgets (#7296) * Closes https://github.com/emilk/egui/issues/7100 --- crates/egui/src/hit_test.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/egui/src/hit_test.rs b/crates/egui/src/hit_test.rs index f253a1dfe..1361c1c49 100644 --- a/crates/egui/src/hit_test.rs +++ b/crates/egui/src/hit_test.rs @@ -91,6 +91,8 @@ pub fn hit_test( } } + close.retain(|rect| !rect.interact_rect.any_nan()); // Protect against bad input and transforms + // When using layer transforms it is common to stack layers close to each other. // For instance, you may have a resize-separator on a panel, with two // transform-layers on either side. From 77df407f50230da3a920315eb1061cd0e6811b45 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 3 Jul 2025 14:23:15 +0200 Subject: [PATCH 04/19] `egui_kittest`: add `failed_pixel_count_threshold` (#7092) I thought about this - so we have two options here: 1. adding it to `SnapshotOptions` 2. adding it to every function which I do not like as this would be a huge breaking change ## Summary This pull request introduces a new feature to the `SnapshotOptions` struct in the `egui_kittest` crate, allowing users to specify a permissible percentage of pixel differences (`diff_percentage`) before a snapshot comparison is considered a failure. This feature provides more flexibility in handling minor visual discrepancies during snapshot testing. ### Additions to `SnapshotOptions`: * Added a new field `diff_percentage` of type `Option` to the `SnapshotOptions` struct. This field allows users to define a tolerance for pixel differences, with a default value of `None` (interpreted as 0% tolerance). * Updated the `Default` implementation of `SnapshotOptions` to initialize `diff_percentage` to `None`. ### Integration into snapshot comparison logic: * Updated the `try_image_snapshot_options` function to handle the new `diff_percentage` field. If a `diff_percentage` is specified, the function calculates the percentage of differing pixels and allows the snapshot to pass if the difference is within the specified tolerance. [[1]](diffhunk://#diff-6f481b5866b82a4fe126b7df2e6c9669040c79d1d200d76b87f376de5dec5065R204) [[2]](diffhunk://#diff-6f481b5866b82a4fe126b7df2e6c9669040c79d1d200d76b87f376de5dec5065R294-R301) * Closes * [x] I have followed the instructions in the PR template --------- Co-authored-by: lucasmerlin Co-authored-by: Emil Ernerfeldt --- .../src/demo/demo_app_windows.rs | 7 +- crates/egui_kittest/src/snapshot.rs | 131 +++++++++++++++++- 2 files changed, 130 insertions(+), 8 deletions(-) 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 0e3a0d2c2..4414a9572 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -373,7 +373,7 @@ mod tests { use crate::{Demo as _, demo::demo_app_windows::DemoGroups}; use egui_kittest::kittest::{NodeT as _, Queryable as _}; - use egui_kittest::{Harness, SnapshotOptions, SnapshotResults}; + use egui_kittest::{Harness, OsThreshold, SnapshotOptions, SnapshotResults}; #[test] fn demos_should_match_snapshot() { @@ -410,9 +410,10 @@ mod tests { harness.run_ok(); let mut options = SnapshotOptions::default(); - // The Bézier Curve demo needs a threshold of 2.1 to pass on linux + if name == "Bézier Curve" { - options.threshold = 2.1; + // The Bézier Curve demo needs a threshold of 2.1 to pass on linux: + options = options.threshold(OsThreshold::new(0.0).linux(2.1)); } results.add(harness.try_snapshot_options(&format!("demos/{name}"), &options)); diff --git a/crates/egui_kittest/src/snapshot.rs b/crates/egui_kittest/src/snapshot.rs index 3c7dc265a..e53615d90 100644 --- a/crates/egui_kittest/src/snapshot.rs +++ b/crates/egui_kittest/src/snapshot.rs @@ -13,16 +13,117 @@ pub struct SnapshotOptions { /// wgpu backends). pub threshold: f32, + /// The number of pixels that can differ before the snapshot is considered a failure. + /// Preferably, you should use `threshold` to control the sensitivity of the image comparison. + /// As a last resort, you can use this to allow a certain number of pixels to differ. + /// If `None`, the default is `0` (meaning no pixels can differ). + /// If `Some`, the value can be set per OS + pub failed_pixel_count_threshold: usize, + /// The path where the snapshots will be saved. /// The default is `tests/snapshots`. pub output_path: PathBuf, } +/// Helper struct to define the number of pixels that can differ before the snapshot is considered a failure. +/// This is useful if you want to set different thresholds for different operating systems. +/// +/// The default values are 0 / 0.0 +/// +/// Example usage: +/// ```no_run +/// use egui_kittest::{OsThreshold, SnapshotOptions}; +/// let mut harness = egui_kittest::Harness::new_ui(|ui| { +/// ui.label("Hi!"); +/// }); +/// harness.snapshot_options( +/// "os_threshold_example", +/// &SnapshotOptions::new() +/// .threshold(OsThreshold::new(0.0).windows(10.0)) +/// .failed_pixel_count_threshold(OsThreshold::new(0).windows(10).macos(53) +/// )) +/// ``` +#[derive(Debug, Clone, Copy)] +pub struct OsThreshold { + pub windows: T, + pub macos: T, + pub linux: T, + pub fallback: T, +} + +impl From for OsThreshold { + fn from(value: usize) -> Self { + Self::new(value) + } +} + +impl OsThreshold +where + T: Copy, +{ + /// Use the same value for all + pub fn new(same: T) -> Self { + Self { + windows: same, + macos: same, + linux: same, + fallback: same, + } + } + + /// Set the threshold for Windows. + #[inline] + pub fn windows(mut self, threshold: T) -> Self { + self.windows = threshold; + self + } + + /// Set the threshold for macOS. + #[inline] + pub fn macos(mut self, threshold: T) -> Self { + self.macos = threshold; + self + } + + /// Set the threshold for Linux. + #[inline] + pub fn linux(mut self, threshold: T) -> Self { + self.linux = threshold; + self + } + + /// Get the threshold for the current operating system. + pub fn threshold(&self) -> T { + if cfg!(target_os = "windows") { + self.windows + } else if cfg!(target_os = "macos") { + self.macos + } else if cfg!(target_os = "linux") { + self.linux + } else { + self.fallback + } + } +} + +impl From> for usize { + fn from(threshold: OsThreshold) -> Self { + threshold.threshold() + } +} + +impl From> for f32 { + fn from(threshold: OsThreshold) -> Self { + threshold.threshold() + } +} + impl Default for SnapshotOptions { fn default() -> Self { Self { threshold: 0.6, output_path: PathBuf::from("tests/snapshots"), + failed_pixel_count_threshold: 0, // Default is 0, meaning no pixels can differ } } } @@ -37,8 +138,8 @@ impl SnapshotOptions { /// The default is `0.6` (which is enough for most egui tests to pass across different /// wgpu backends). #[inline] - pub fn threshold(mut self, threshold: f32) -> Self { - self.threshold = threshold; + pub fn threshold(mut self, threshold: impl Into) -> Self { + self.threshold = threshold.into(); self } @@ -49,6 +150,20 @@ impl SnapshotOptions { self.output_path = output_path.into(); self } + + /// Change the number of pixels that can differ before the snapshot is considered a failure. + /// + /// Preferably, you should use [`Self::threshold`] to control the sensitivity of the image comparison. + /// As a last resort, you can use this to allow a certain number of pixels to differ. + #[inline] + pub fn failed_pixel_count_threshold( + mut self, + failed_pixel_count_threshold: impl Into>, + ) -> Self { + let failed_pixel_count_threshold = failed_pixel_count_threshold.into().threshold(); + self.failed_pixel_count_threshold = failed_pixel_count_threshold; + self + } } #[derive(Debug)] @@ -58,7 +173,7 @@ pub enum SnapshotError { /// Name of the test name: String, - /// Count of pixels that were different + /// Count of pixels that were different (above the per-pixel threshold). diff: i32, /// Path where the diff image was saved @@ -201,6 +316,7 @@ pub fn try_image_snapshot_options( let SnapshotOptions { threshold, output_path, + failed_pixel_count_threshold, } = options; let parent_path = if let Some(parent) = PathBuf::from(name).parent() { @@ -280,19 +396,24 @@ pub fn try_image_snapshot_options( let result = dify::diff::get_results(previous, new.clone(), *threshold, true, None, &None, &None); - if let Some((diff, result_image)) = result { + if let Some((num_wrong_pixels, result_image)) = result { result_image .save(diff_path.clone()) .map_err(|err| SnapshotError::WriteSnapshot { path: diff_path.clone(), err, })?; + if should_update_snapshots() { update_snapshot() } else { + if num_wrong_pixels as i64 <= *failed_pixel_count_threshold as i64 { + return Ok(()); + } + Err(SnapshotError::Diff { name: name.to_owned(), - diff, + diff: num_wrong_pixels, diff_path, }) } From 2b62c68598f3ecfb5c464ff6758db5eda4d474df Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Thu, 3 Jul 2025 14:31:35 +0200 Subject: [PATCH 05/19] Add `egui::Sides` `shrink_left` / `shrink_right` (#7295) This allows contents (on one of the sides) in egui::Sides to shrink. * related https://github.com/rerun-io/rerun/issues/10494 --- crates/egui/src/containers/sides.rs | 212 ++++++++++++++---- .../tests/snapshots/sides/default_long.png | 3 + .../sides/default_long_fit_contents.png | 3 + .../tests/snapshots/sides/default_short.png | 3 + .../sides/default_short_fit_contents.png | 3 + .../snapshots/sides/shrink_left_long.png | 3 + .../sides/shrink_left_long_fit_contents.png | 3 + .../snapshots/sides/shrink_left_short.png | 3 + .../sides/shrink_left_short_fit_contents.png | 3 + .../snapshots/sides/shrink_right_long.png | 3 + .../sides/shrink_right_long_fit_contents.png | 3 + .../snapshots/sides/shrink_right_short.png | 3 + .../sides/shrink_right_short_fit_contents.png | 3 + .../tests/snapshots/sides/wrap_left_long.png | 3 + .../sides/wrap_left_long_fit_contents.png | 3 + .../tests/snapshots/sides/wrap_left_short.png | 3 + .../sides/wrap_left_short_fit_contents.png | 3 + .../tests/snapshots/sides/wrap_right_long.png | 3 + .../sides/wrap_right_long_fit_contents.png | 3 + .../snapshots/sides/wrap_right_short.png | 3 + .../sides/wrap_right_short_fit_contents.png | 3 + tests/egui_tests/tests/test_sides.rs | 76 +++++++ 22 files changed, 308 insertions(+), 40 deletions(-) create mode 100644 tests/egui_tests/tests/snapshots/sides/default_long.png create mode 100644 tests/egui_tests/tests/snapshots/sides/default_long_fit_contents.png create mode 100644 tests/egui_tests/tests/snapshots/sides/default_short.png create mode 100644 tests/egui_tests/tests/snapshots/sides/default_short_fit_contents.png create mode 100644 tests/egui_tests/tests/snapshots/sides/shrink_left_long.png create mode 100644 tests/egui_tests/tests/snapshots/sides/shrink_left_long_fit_contents.png create mode 100644 tests/egui_tests/tests/snapshots/sides/shrink_left_short.png create mode 100644 tests/egui_tests/tests/snapshots/sides/shrink_left_short_fit_contents.png create mode 100644 tests/egui_tests/tests/snapshots/sides/shrink_right_long.png create mode 100644 tests/egui_tests/tests/snapshots/sides/shrink_right_long_fit_contents.png create mode 100644 tests/egui_tests/tests/snapshots/sides/shrink_right_short.png create mode 100644 tests/egui_tests/tests/snapshots/sides/shrink_right_short_fit_contents.png create mode 100644 tests/egui_tests/tests/snapshots/sides/wrap_left_long.png create mode 100644 tests/egui_tests/tests/snapshots/sides/wrap_left_long_fit_contents.png create mode 100644 tests/egui_tests/tests/snapshots/sides/wrap_left_short.png create mode 100644 tests/egui_tests/tests/snapshots/sides/wrap_left_short_fit_contents.png create mode 100644 tests/egui_tests/tests/snapshots/sides/wrap_right_long.png create mode 100644 tests/egui_tests/tests/snapshots/sides/wrap_right_long_fit_contents.png create mode 100644 tests/egui_tests/tests/snapshots/sides/wrap_right_short.png create mode 100644 tests/egui_tests/tests/snapshots/sides/wrap_right_short_fit_contents.png create mode 100644 tests/egui_tests/tests/test_sides.rs diff --git a/crates/egui/src/containers/sides.rs b/crates/egui/src/containers/sides.rs index e34ae70eb..8a67c6c5e 100644 --- a/crates/egui/src/containers/sides.rs +++ b/crates/egui/src/containers/sides.rs @@ -1,4 +1,4 @@ -use emath::Align; +use emath::{Align, NumExt as _}; use crate::{Layout, Ui, UiBuilder}; @@ -20,8 +20,13 @@ use crate::{Layout, Ui, UiBuilder}; /// /// If the parent is not wide enough to fit all widgets, the parent will be expanded to the right. /// -/// The left widgets are first added to the ui, left-to-right. -/// Then the right widgets are added, right-to-left. +/// The left widgets are added left-to-right. +/// The right widgets are added right-to-left. +/// +/// Which side is first depends on the configuration: +/// - [`Sides::extend`] - left widgets are added first +/// - [`Sides::shrink_left`] - right widgets are added first +/// - [`Sides::shrink_right`] - left widgets are added first /// /// ``` /// # egui::__run_test_ui(|ui| { @@ -40,6 +45,16 @@ use crate::{Layout, Ui, UiBuilder}; pub struct Sides { height: Option, spacing: Option, + kind: SidesKind, + wrap_mode: Option, +} + +#[derive(Clone, Copy, Debug, Default)] +enum SidesKind { + #[default] + Extend, + ShrinkLeft, + ShrinkRight, } impl Sides { @@ -68,58 +83,175 @@ impl Sides { self } + /// Try to shrink widgets on the left side. + /// + /// Right widgets will be added first. The left [`Ui`]s max rect will be limited to the + /// remaining space. + #[inline] + pub fn shrink_left(mut self) -> Self { + self.kind = SidesKind::ShrinkLeft; + self + } + + /// Try to shrink widgets on the right side. + /// + /// Left widgets will be added first. The right [`Ui`]s max rect will be limited to the + /// remaining space. + #[inline] + pub fn shrink_right(mut self) -> Self { + self.kind = SidesKind::ShrinkRight; + self + } + + /// Extend the left and right sides to fill the available space. + /// + /// This is the default behavior. + /// The left widgets will be added first, followed by the right widgets. + #[inline] + pub fn extend(mut self) -> Self { + self.kind = SidesKind::Extend; + self + } + + /// The text wrap mode for the shrinking side. + /// + /// Does nothing if [`Self::extend`] is used (the default). + #[inline] + pub fn wrap_mode(mut self, wrap_mode: crate::TextWrapMode) -> Self { + self.wrap_mode = Some(wrap_mode); + self + } + + /// Truncate the text on the shrinking side. + /// + /// This is a shortcut for [`Self::wrap_mode`]. + /// Does nothing if [`Self::extend`] is used (the default). + #[inline] + pub fn truncate(mut self) -> Self { + self.wrap_mode = Some(crate::TextWrapMode::Truncate); + self + } + + /// Wrap the text on the shrinking side. + /// + /// This is a shortcut for [`Self::wrap_mode`]. + /// Does nothing if [`Self::extend`] is used (the default). + #[inline] + pub fn wrap(mut self) -> Self { + self.wrap_mode = Some(crate::TextWrapMode::Wrap); + self + } + pub fn show( self, ui: &mut Ui, add_left: impl FnOnce(&mut Ui) -> RetL, add_right: impl FnOnce(&mut Ui) -> RetR, ) -> (RetL, RetR) { - let Self { height, spacing } = self; + let Self { + height, + spacing, + mut kind, + mut wrap_mode, + } = self; let height = height.unwrap_or_else(|| ui.spacing().interact_size.y); let spacing = spacing.unwrap_or_else(|| ui.spacing().item_spacing.x); let mut top_rect = ui.available_rect_before_wrap(); top_rect.max.y = top_rect.min.y + height; - let result_left; - let result_right; - - let left_rect = { - let left_max_rect = top_rect; - let mut left_ui = ui.new_child( - UiBuilder::new() - .max_rect(left_max_rect) - .layout(Layout::left_to_right(Align::Center)), - ); - result_left = add_left(&mut left_ui); - left_ui.min_rect() - }; - - let right_rect = { - let right_max_rect = top_rect.with_min_x(left_rect.max.x); - let mut right_ui = ui.new_child( - UiBuilder::new() - .max_rect(right_max_rect) - .layout(Layout::right_to_left(Align::Center)), - ); - result_right = add_right(&mut right_ui); - right_ui.min_rect() - }; - - let mut final_rect = left_rect.union(right_rect); - let min_width = left_rect.width() + spacing + right_rect.width(); - if ui.is_sizing_pass() { - // Make as small as possible: - final_rect.max.x = left_rect.min.x + min_width; - } else { - // If the rects overlap, make sure we expand the allocated rect so that the parent - // ui knows we overflowed, and resizes: - final_rect.max.x = final_rect.max.x.max(left_rect.min.x + min_width); + kind = SidesKind::Extend; + wrap_mode = None; } - ui.advance_cursor_after_rect(final_rect); + match kind { + SidesKind::ShrinkLeft => { + let (right_rect, result_right) = Self::create_ui( + ui, + top_rect, + Layout::right_to_left(Align::Center), + add_right, + None, + ); + let available_width = top_rect.width() - right_rect.width() - spacing; + let left_rect_constraint = + top_rect.with_max_x(top_rect.min.x + available_width.at_least(0.0)); + let (left_rect, result_left) = Self::create_ui( + ui, + left_rect_constraint, + Layout::left_to_right(Align::Center), + add_left, + wrap_mode, + ); - (result_left, result_right) + ui.advance_cursor_after_rect(left_rect.union(right_rect)); + (result_left, result_right) + } + SidesKind::ShrinkRight => { + let (left_rect, result_left) = Self::create_ui( + ui, + top_rect, + Layout::left_to_right(Align::Center), + add_left, + None, + ); + let right_rect_constraint = top_rect.with_min_x(left_rect.max.x + spacing); + let (right_rect, result_right) = Self::create_ui( + ui, + right_rect_constraint, + Layout::right_to_left(Align::Center), + add_right, + wrap_mode, + ); + + ui.advance_cursor_after_rect(left_rect.union(right_rect)); + (result_left, result_right) + } + SidesKind::Extend => { + let (left_rect, result_left) = Self::create_ui( + ui, + top_rect, + Layout::left_to_right(Align::Center), + add_left, + None, + ); + let right_max_rect = top_rect.with_min_x(left_rect.max.x); + let (right_rect, result_right) = Self::create_ui( + ui, + right_max_rect, + Layout::right_to_left(Align::Center), + add_right, + None, + ); + + let mut final_rect = left_rect.union(right_rect); + let min_width = left_rect.width() + spacing + right_rect.width(); + + if ui.is_sizing_pass() { + final_rect.max.x = left_rect.min.x + min_width; + } else { + final_rect.max.x = final_rect.max.x.max(left_rect.min.x + min_width); + } + + ui.advance_cursor_after_rect(final_rect); + (result_left, result_right) + } + } + } + + fn create_ui( + ui: &mut Ui, + max_rect: emath::Rect, + layout: Layout, + add_content: impl FnOnce(&mut Ui) -> Ret, + wrap_mode: Option, + ) -> (emath::Rect, Ret) { + let mut child_ui = ui.new_child(UiBuilder::new().max_rect(max_rect).layout(layout)); + if let Some(wrap_mode) = wrap_mode { + child_ui.style_mut().wrap_mode = Some(wrap_mode); + } + let result = add_content(&mut child_ui); + (child_ui.min_rect(), result) } } diff --git a/tests/egui_tests/tests/snapshots/sides/default_long.png b/tests/egui_tests/tests/snapshots/sides/default_long.png new file mode 100644 index 000000000..2d66f3665 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/sides/default_long.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b7ceaa95512c67dcbf1c8ba5a8f33bf4833c2e863d09903fb71b5aa2822cc086 +size 7889 diff --git a/tests/egui_tests/tests/snapshots/sides/default_long_fit_contents.png b/tests/egui_tests/tests/snapshots/sides/default_long_fit_contents.png new file mode 100644 index 000000000..15e9dc464 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/sides/default_long_fit_contents.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:931af33f4548924b3bb75a2e513b9e689bce94436b10a9f811140eb11e9d6442 +size 8552 diff --git a/tests/egui_tests/tests/snapshots/sides/default_short.png b/tests/egui_tests/tests/snapshots/sides/default_short.png new file mode 100644 index 000000000..756a3068f --- /dev/null +++ b/tests/egui_tests/tests/snapshots/sides/default_short.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6448d44c1c9bed08cd6a4af39b141f3a4ca203ca5f7b967cbc0e0d0c2b4fb73f +size 1647 diff --git a/tests/egui_tests/tests/snapshots/sides/default_short_fit_contents.png b/tests/egui_tests/tests/snapshots/sides/default_short_fit_contents.png new file mode 100644 index 000000000..6f3189261 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/sides/default_short_fit_contents.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44622002ebe287208d26359a06804b1f8737f86eb482322bdf3881a1fe53941f +size 1276 diff --git a/tests/egui_tests/tests/snapshots/sides/shrink_left_long.png b/tests/egui_tests/tests/snapshots/sides/shrink_left_long.png new file mode 100644 index 000000000..39e1bab98 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/sides/shrink_left_long.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88e1557dffa7295e7e7e37ed175fcec40aab939f9b67137a1ce33811e8ae4722 +size 7148 diff --git a/tests/egui_tests/tests/snapshots/sides/shrink_left_long_fit_contents.png b/tests/egui_tests/tests/snapshots/sides/shrink_left_long_fit_contents.png new file mode 100644 index 000000000..15e9dc464 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/sides/shrink_left_long_fit_contents.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:931af33f4548924b3bb75a2e513b9e689bce94436b10a9f811140eb11e9d6442 +size 8552 diff --git a/tests/egui_tests/tests/snapshots/sides/shrink_left_short.png b/tests/egui_tests/tests/snapshots/sides/shrink_left_short.png new file mode 100644 index 000000000..756a3068f --- /dev/null +++ b/tests/egui_tests/tests/snapshots/sides/shrink_left_short.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6448d44c1c9bed08cd6a4af39b141f3a4ca203ca5f7b967cbc0e0d0c2b4fb73f +size 1647 diff --git a/tests/egui_tests/tests/snapshots/sides/shrink_left_short_fit_contents.png b/tests/egui_tests/tests/snapshots/sides/shrink_left_short_fit_contents.png new file mode 100644 index 000000000..6f3189261 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/sides/shrink_left_short_fit_contents.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44622002ebe287208d26359a06804b1f8737f86eb482322bdf3881a1fe53941f +size 1276 diff --git a/tests/egui_tests/tests/snapshots/sides/shrink_right_long.png b/tests/egui_tests/tests/snapshots/sides/shrink_right_long.png new file mode 100644 index 000000000..3326d9527 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/sides/shrink_right_long.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:508209ca303751ef323301b25bb3878410742ea79339b75363d2681b98d2712b +size 7068 diff --git a/tests/egui_tests/tests/snapshots/sides/shrink_right_long_fit_contents.png b/tests/egui_tests/tests/snapshots/sides/shrink_right_long_fit_contents.png new file mode 100644 index 000000000..15e9dc464 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/sides/shrink_right_long_fit_contents.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:931af33f4548924b3bb75a2e513b9e689bce94436b10a9f811140eb11e9d6442 +size 8552 diff --git a/tests/egui_tests/tests/snapshots/sides/shrink_right_short.png b/tests/egui_tests/tests/snapshots/sides/shrink_right_short.png new file mode 100644 index 000000000..756a3068f --- /dev/null +++ b/tests/egui_tests/tests/snapshots/sides/shrink_right_short.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6448d44c1c9bed08cd6a4af39b141f3a4ca203ca5f7b967cbc0e0d0c2b4fb73f +size 1647 diff --git a/tests/egui_tests/tests/snapshots/sides/shrink_right_short_fit_contents.png b/tests/egui_tests/tests/snapshots/sides/shrink_right_short_fit_contents.png new file mode 100644 index 000000000..6f3189261 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/sides/shrink_right_short_fit_contents.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44622002ebe287208d26359a06804b1f8737f86eb482322bdf3881a1fe53941f +size 1276 diff --git a/tests/egui_tests/tests/snapshots/sides/wrap_left_long.png b/tests/egui_tests/tests/snapshots/sides/wrap_left_long.png new file mode 100644 index 000000000..36929a413 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/sides/wrap_left_long.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a0c9e39c18fc5bb1fc02a86dbf02e3ffca5537dbe8986d5c5b50cb4984c97466 +size 9085 diff --git a/tests/egui_tests/tests/snapshots/sides/wrap_left_long_fit_contents.png b/tests/egui_tests/tests/snapshots/sides/wrap_left_long_fit_contents.png new file mode 100644 index 000000000..15e9dc464 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/sides/wrap_left_long_fit_contents.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:931af33f4548924b3bb75a2e513b9e689bce94436b10a9f811140eb11e9d6442 +size 8552 diff --git a/tests/egui_tests/tests/snapshots/sides/wrap_left_short.png b/tests/egui_tests/tests/snapshots/sides/wrap_left_short.png new file mode 100644 index 000000000..756a3068f --- /dev/null +++ b/tests/egui_tests/tests/snapshots/sides/wrap_left_short.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6448d44c1c9bed08cd6a4af39b141f3a4ca203ca5f7b967cbc0e0d0c2b4fb73f +size 1647 diff --git a/tests/egui_tests/tests/snapshots/sides/wrap_left_short_fit_contents.png b/tests/egui_tests/tests/snapshots/sides/wrap_left_short_fit_contents.png new file mode 100644 index 000000000..6f3189261 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/sides/wrap_left_short_fit_contents.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44622002ebe287208d26359a06804b1f8737f86eb482322bdf3881a1fe53941f +size 1276 diff --git a/tests/egui_tests/tests/snapshots/sides/wrap_right_long.png b/tests/egui_tests/tests/snapshots/sides/wrap_right_long.png new file mode 100644 index 000000000..47398293f --- /dev/null +++ b/tests/egui_tests/tests/snapshots/sides/wrap_right_long.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a6e9ba0acb573853ef5b3dedb1156d99cdf80338ccb160093960e8aaa41bd5df +size 9048 diff --git a/tests/egui_tests/tests/snapshots/sides/wrap_right_long_fit_contents.png b/tests/egui_tests/tests/snapshots/sides/wrap_right_long_fit_contents.png new file mode 100644 index 000000000..15e9dc464 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/sides/wrap_right_long_fit_contents.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:931af33f4548924b3bb75a2e513b9e689bce94436b10a9f811140eb11e9d6442 +size 8552 diff --git a/tests/egui_tests/tests/snapshots/sides/wrap_right_short.png b/tests/egui_tests/tests/snapshots/sides/wrap_right_short.png new file mode 100644 index 000000000..756a3068f --- /dev/null +++ b/tests/egui_tests/tests/snapshots/sides/wrap_right_short.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6448d44c1c9bed08cd6a4af39b141f3a4ca203ca5f7b967cbc0e0d0c2b4fb73f +size 1647 diff --git a/tests/egui_tests/tests/snapshots/sides/wrap_right_short_fit_contents.png b/tests/egui_tests/tests/snapshots/sides/wrap_right_short_fit_contents.png new file mode 100644 index 000000000..6f3189261 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/sides/wrap_right_short_fit_contents.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44622002ebe287208d26359a06804b1f8737f86eb482322bdf3881a1fe53941f +size 1276 diff --git a/tests/egui_tests/tests/test_sides.rs b/tests/egui_tests/tests/test_sides.rs new file mode 100644 index 000000000..293abd311 --- /dev/null +++ b/tests/egui_tests/tests/test_sides.rs @@ -0,0 +1,76 @@ +use egui::{TextWrapMode, Vec2, containers::Sides}; +use egui_kittest::{Harness, SnapshotResults}; + +#[test] +fn sides_container_tests() { + let mut results = SnapshotResults::new(); + + test_variants("default", |sides| sides, &mut results); + + test_variants( + "shrink_left", + |sides| sides.shrink_left().truncate(), + &mut results, + ); + + test_variants( + "shrink_right", + |sides| sides.shrink_right().truncate(), + &mut results, + ); + + test_variants( + "wrap_left", + |sides| sides.shrink_left().wrap_mode(TextWrapMode::Wrap), + &mut results, + ); + + test_variants( + "wrap_right", + |sides| sides.shrink_right().wrap_mode(TextWrapMode::Wrap), + &mut results, + ); +} + +fn test_variants( + name: &str, + mut create_sides: impl FnMut(Sides) -> Sides, + results: &mut SnapshotResults, +) { + for (variant_name, left_text, right_text, fit_contents) in [ + ("short", "Left", "Right", false), + ( + "long", + "Very long left content that should not fit.", + "Very long right text that should also not fit.", + false, + ), + ("short_fit_contents", "Left", "Right", true), + ( + "long_fit_contents", + "Very long left content that should not fit.", + "Very long right text that should also not fit.", + true, + ), + ] { + let mut harness = Harness::builder() + .with_size(Vec2::new(400.0, 50.0)) + .build_ui(|ui| { + create_sides(Sides::new()).show( + ui, + |left| { + left.label(left_text); + }, + |right| { + right.label(right_text); + }, + ); + }); + + if fit_contents { + harness.fit_contents(); + } + + results.add(harness.try_snapshot(&format!("sides/{name}_{variant_name}"))); + } +} From 47a2bb10b03e48cac8bd4183246f9984c342e2ad Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Thu, 3 Jul 2025 16:34:47 +0200 Subject: [PATCH 06/19] Remove `SelectableLabel` (#7277) * part of https://github.com/emilk/egui/issues/7264 * removes SelectableLabel (Use `Button::selectable` instead) * updates `Ui::selectable_value/label` with IntoAtoms support Had to make some changes to `Button` since the SelecatbleLabel had no frame unless selected. --- crates/egui/src/ui.rs | 20 ++--- crates/egui/src/widgets/button.rs | 51 ++++++++++++- crates/egui/src/widgets/mod.rs | 3 +- crates/egui/src/widgets/selected_label.rs | 90 +---------------------- crates/egui_demo_lib/src/demo/password.rs | 2 +- 5 files changed, 63 insertions(+), 103 deletions(-) diff --git a/crates/egui/src/ui.rs b/crates/egui/src/ui.rs index 3738e6e53..c24ca211b 100644 --- a/crates/egui/src/ui.rs +++ b/crates/egui/src/ui.rs @@ -27,7 +27,7 @@ use crate::{ vec2, widgets, widgets::{ Button, Checkbox, DragValue, Hyperlink, Image, ImageSource, Label, Link, RadioButton, - SelectableLabel, Separator, Spinner, TextEdit, Widget, color_picker, + Separator, Spinner, TextEdit, Widget, color_picker, }, }; // ---------------------------------------------------------------------------- @@ -2077,13 +2077,13 @@ impl Ui { Checkbox::new(checked, atoms).ui(self) } - /// Acts like a checkbox, but looks like a [`SelectableLabel`]. + /// Acts like a checkbox, but looks like a [`Button::selectable`]. /// /// Click to toggle to bool. /// /// See also [`Self::checkbox`]. - pub fn toggle_value(&mut self, selected: &mut bool, text: impl Into) -> Response { - let mut response = self.selectable_label(*selected, text); + pub fn toggle_value<'a>(&mut self, selected: &mut bool, atoms: impl IntoAtoms<'a>) -> Response { + let mut response = self.selectable_label(*selected, atoms); if response.clicked() { *selected = !*selected; response.mark_changed(); @@ -2134,10 +2134,10 @@ impl Ui { /// Show a label which can be selected or not. /// - /// See also [`SelectableLabel`] and [`Self::toggle_value`]. + /// See also [`Button::selectable`] and [`Self::toggle_value`]. #[must_use = "You should check if the user clicked this with `if ui.selectable_label(…).clicked() { … } "] - pub fn selectable_label(&mut self, checked: bool, text: impl Into) -> Response { - SelectableLabel::new(checked, text).ui(self) + pub fn selectable_label<'a>(&mut self, checked: bool, text: impl IntoAtoms<'a>) -> Response { + Button::selectable(checked, text).ui(self) } /// Show selectable text. It is selected if `*current_value == selected_value`. @@ -2145,12 +2145,12 @@ impl Ui { /// /// Example: `ui.selectable_value(&mut my_enum, Enum::Alternative, "Alternative")`. /// - /// See also [`SelectableLabel`] and [`Self::toggle_value`]. - pub fn selectable_value( + /// See also [`Button::selectable`] and [`Self::toggle_value`]. + pub fn selectable_value<'a, Value: PartialEq>( &mut self, current_value: &mut Value, selected_value: Value, - text: impl Into, + text: impl IntoAtoms<'a>, ) -> Response { let mut response = self.selectable_label(*current_value == selected_value, text); if response.clicked() && *current_value != selected_value { diff --git a/crates/egui/src/widgets/button.rs b/crates/egui/src/widgets/button.rs index aa75eabd8..d836c0701 100644 --- a/crates/egui/src/widgets/button.rs +++ b/crates/egui/src/widgets/button.rs @@ -29,6 +29,7 @@ pub struct Button<'a> { stroke: Option, small: bool, frame: Option, + frame_when_inactive: bool, min_size: Vec2, corner_radius: Option, selected: bool, @@ -44,6 +45,7 @@ impl<'a> Button<'a> { stroke: None, small: false, frame: None, + frame_when_inactive: true, min_size: Vec2::ZERO, corner_radius: None, selected: false, @@ -52,6 +54,27 @@ impl<'a> Button<'a> { } } + /// Show a selectable button. + /// + /// Equivalent to: + /// ```rust + /// # use egui::{Button, IntoAtoms, __run_test_ui}; + /// # __run_test_ui(|ui| { + /// let selected = true; + /// ui.add(Button::new("toggle me").selected(selected).frame_when_inactive(!selected).frame(true)); + /// # }); + /// ``` + /// + /// See also: + /// - [`Ui::selectable_value`] + /// - [`Ui::selectable_label`] + pub fn selectable(selected: bool, atoms: impl IntoAtoms<'a>) -> Self { + Self::new(atoms) + .selected(selected) + .frame_when_inactive(selected) + .frame(true) + } + /// Creates a button with an image. The size of the image as displayed is defined by the provided size. /// /// Note: In contrast to [`Button::new`], this limits the image size to the default font height @@ -138,6 +161,18 @@ impl<'a> Button<'a> { self } + /// If `false`, the button will not have a frame when inactive. + /// + /// Default: `true`. + /// + /// Note: When [`Self::frame`] (or `ui.visuals().button_frame`) is `false`, this setting + /// has no effect. + #[inline] + pub fn frame_when_inactive(mut self, frame_when_inactive: bool) -> Self { + self.frame_when_inactive = frame_when_inactive; + self + } + /// By default, buttons senses clicks. /// Change this to a drag-button with `Sense::drag()`. #[inline] @@ -220,6 +255,7 @@ impl<'a> Button<'a> { stroke, small, frame, + frame_when_inactive, mut min_size, corner_radius, selected, @@ -243,9 +279,9 @@ impl<'a> Button<'a> { let text = layout.text().map(String::from); - let has_frame = frame.unwrap_or_else(|| ui.visuals().button_frame); + let has_frame_margin = frame.unwrap_or_else(|| ui.visuals().button_frame); - let mut button_padding = if has_frame { + let mut button_padding = if has_frame_margin { ui.spacing().button_padding } else { Vec2::ZERO @@ -262,13 +298,22 @@ impl<'a> Button<'a> { let response = if ui.is_rect_visible(prepared.response.rect) { let visuals = ui.style().interact_selectable(&prepared.response, selected); + let visible_frame = if frame_when_inactive { + has_frame_margin + } else { + has_frame_margin + && (prepared.response.hovered() + || prepared.response.is_pointer_button_down_on() + || prepared.response.has_focus()) + }; + if image_tint_follows_text_color { prepared.map_images(|image| image.tint(visuals.text_color())); } prepared.fallback_text_color = visuals.text_color(); - if has_frame { + if visible_frame { let stroke = stroke.unwrap_or(visuals.bg_stroke); let fill = fill.unwrap_or(visuals.weak_bg_fill); prepared.frame = prepared diff --git a/crates/egui/src/widgets/mod.rs b/crates/egui/src/widgets/mod.rs index d303b181b..9cf003c94 100644 --- a/crates/egui/src/widgets/mod.rs +++ b/crates/egui/src/widgets/mod.rs @@ -22,6 +22,8 @@ mod slider; mod spinner; pub mod text_edit; +#[expect(deprecated)] +pub use self::selected_label::SelectableLabel; pub use self::{ button::Button, checkbox::Checkbox, @@ -35,7 +37,6 @@ pub use self::{ label::Label, progress_bar::ProgressBar, radio_button::RadioButton, - selected_label::SelectableLabel, separator::Separator, slider::{Slider, SliderClamping, SliderOrientation}, spinner::Spinner, diff --git a/crates/egui/src/widgets/selected_label.rs b/crates/egui/src/widgets/selected_label.rs index 4b2ee9ae2..da18b5fe0 100644 --- a/crates/egui/src/widgets/selected_label.rs +++ b/crates/egui/src/widgets/selected_label.rs @@ -1,88 +1,2 @@ -use crate::{ - NumExt as _, Response, Sense, TextStyle, Ui, Widget, WidgetInfo, WidgetText, WidgetType, -}; - -/// One out of several alternatives, either selected or not. -/// Will mark selected items with a different background color. -/// An alternative to [`crate::RadioButton`] and [`crate::Checkbox`]. -/// -/// Usually you'd use [`Ui::selectable_value`] or [`Ui::selectable_label`] instead. -/// -/// ``` -/// # egui::__run_test_ui(|ui| { -/// #[derive(PartialEq)] -/// enum Enum { First, Second, Third } -/// let mut my_enum = Enum::First; -/// -/// ui.selectable_value(&mut my_enum, Enum::First, "First"); -/// -/// // is equivalent to: -/// -/// if ui.add(egui::SelectableLabel::new(my_enum == Enum::First, "First")).clicked() { -/// my_enum = Enum::First -/// } -/// # }); -/// ``` -#[must_use = "You should put this widget in a ui with `ui.add(widget);`"] -pub struct SelectableLabel { - selected: bool, - text: WidgetText, -} - -impl SelectableLabel { - pub fn new(selected: bool, text: impl Into) -> Self { - Self { - selected, - text: text.into(), - } - } -} - -impl Widget for SelectableLabel { - fn ui(self, ui: &mut Ui) -> Response { - let Self { selected, text } = self; - - let button_padding = ui.spacing().button_padding; - let total_extra = button_padding + button_padding; - - let wrap_width = ui.available_width() - total_extra.x; - let galley = text.into_galley(ui, None, wrap_width, TextStyle::Button); - - let mut desired_size = total_extra + galley.size(); - desired_size.y = desired_size.y.at_least(ui.spacing().interact_size.y); - let (rect, response) = ui.allocate_at_least(desired_size, Sense::click()); - response.widget_info(|| { - WidgetInfo::selected( - WidgetType::SelectableLabel, - ui.is_enabled(), - selected, - galley.text(), - ) - }); - - if ui.is_rect_visible(response.rect) { - let text_pos = ui - .layout() - .align_size_within_rect(galley.size(), rect.shrink2(button_padding)) - .min; - - let visuals = ui.style().interact_selectable(&response, selected); - - if selected || response.hovered() || response.highlighted() || response.has_focus() { - let rect = rect.expand(visuals.expansion); - - ui.painter().rect( - rect, - visuals.corner_radius, - visuals.weak_bg_fill, - visuals.bg_stroke, - epaint::StrokeKind::Inside, - ); - } - - ui.painter().galley(text_pos, galley, visuals.text_color()); - } - - response - } -} +#[deprecated = "SelectableLabel has been removed. Use Button::selectable() instead"] +pub struct SelectableLabel {} diff --git a/crates/egui_demo_lib/src/demo/password.rs b/crates/egui_demo_lib/src/demo/password.rs index f22b5aa8a..04b3c6f37 100644 --- a/crates/egui_demo_lib/src/demo/password.rs +++ b/crates/egui_demo_lib/src/demo/password.rs @@ -27,7 +27,7 @@ pub fn password_ui(ui: &mut egui::Ui, password: &mut String) -> egui::Response { let result = ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // Toggle the `show_plaintext` bool with a button: let response = ui - .add(egui::SelectableLabel::new(show_plaintext, "👁")) + .selectable_label(show_plaintext, "👁") .on_hover_text("Show/hide password"); if response.clicked() { From d94386de3dc6009f241af15eb17f4329a5cf60cd Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Fri, 4 Jul 2025 09:55:03 +0200 Subject: [PATCH 07/19] Fix `debug_assert` triggered by `menu`/`intersect_ray` (#7299) --- crates/egui/src/input_state/mod.rs | 5 ++++- crates/emath/src/rect.rs | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/egui/src/input_state/mod.rs b/crates/egui/src/input_state/mod.rs index fd3e78a21..a3ebf532e 100644 --- a/crates/egui/src/input_state/mod.rs +++ b/crates/egui/src/input_state/mod.rs @@ -1465,7 +1465,10 @@ impl PointerState { } if let Some(pos) = self.hover_pos() { - return rect.intersects_ray(pos, self.direction()); + let dir = self.direction(); + if dir != Vec2::ZERO { + return rect.intersects_ray(pos, self.direction()); + } } false } diff --git a/crates/emath/src/rect.rs b/crates/emath/src/rect.rs index dc63315b6..777c12527 100644 --- a/crates/emath/src/rect.rs +++ b/crates/emath/src/rect.rs @@ -651,7 +651,7 @@ impl Rect { pub fn intersects_ray(&self, o: Pos2, d: Vec2) -> bool { debug_assert!( d.is_normalized(), - "expected normalized direction, but `d` has length {}", + "Debug assert: expected normalized direction, but `d` has length {}", d.length() ); From 7ac137bfc167d0f7ff78d5fd042d6042ff190455 Mon Sep 17 00:00:00 2001 From: valadaptive <79560998+valadaptive@users.noreply.github.com> Date: Fri, 4 Jul 2025 07:15:48 -0400 Subject: [PATCH 08/19] Make the font atlas use a color image (#7298) * [x] I have followed the instructions in the PR template Splitting this out from the Parley work as requested. This removes `FontImage` and makes the font atlas use a `ColorImage`. It converts alpha to coverage at glyph-drawing time, not at delta-upload time. This doesn't do much now, but will allow for color emoji rendering once we start using Parley. I've changed things around so that we pass in `text_alpha_to_coverage` to the `Fonts` the same way we do with `pixels_per_point` and `max_texture_side`, reusing the existing code to check if the setting differs and recreating the font atlas if so. I'm not quite sure why this wasn't done in the first place. I've left `ImageData` as an enum for now, in case we want to add support for more texture pixel formats in the future (which I personally think would be worthwhile). If you'd like, I can just remove that enum entirely. --- crates/egui-wgpu/src/renderer.rs | 13 -- crates/egui/src/context.rs | 63 ++------- crates/egui/src/lib.rs | 2 +- crates/egui_demo_lib/benches/benchmark.rs | 7 +- crates/egui_glow/src/painter.rs | 17 --- crates/epaint/benches/benchmark.rs | 6 +- crates/epaint/src/image.rs | 160 +++++----------------- crates/epaint/src/lib.rs | 2 +- crates/epaint/src/shapes/text_shape.rs | 7 +- crates/epaint/src/text/font.rs | 3 +- crates/epaint/src/text/fonts.rs | 42 ++++-- crates/epaint/src/text/text_layout.rs | 37 ++++- crates/epaint/src/texture_atlas.rs | 29 ++-- crates/epaint/src/textures.rs | 2 +- 14 files changed, 147 insertions(+), 243 deletions(-) diff --git a/crates/egui-wgpu/src/renderer.rs b/crates/egui-wgpu/src/renderer.rs index 473c73028..41a9b3b78 100644 --- a/crates/egui-wgpu/src/renderer.rs +++ b/crates/egui-wgpu/src/renderer.rs @@ -564,19 +564,6 @@ impl Renderer { ); Cow::Borrowed(&image.pixels) } - epaint::ImageData::Font(image) => { - assert_eq!( - width as usize * height as usize, - image.pixels.len(), - "Mismatch between texture size and texel count" - ); - profiling::scope!("font -> sRGBA"); - Cow::Owned( - image - .srgba_pixels(Default::default()) - .collect::>(), - ) - } }; let data_bytes: &[u8] = bytemuck::cast_slice(data_color32.as_slice()); diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index abb03b1c8..f3bc73cec 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -78,7 +78,7 @@ impl Default for WrappedTextureManager { // Will be filled in later let font_id = tex_mngr.alloc( "egui_font_texture".into(), - epaint::FontImage::new([0, 0]).into(), + epaint::ColorImage::filled([0, 0], Color32::TRANSPARENT).into(), Default::default(), ); assert_eq!( @@ -610,6 +610,8 @@ impl ContextImpl { log::trace!("Adding new fonts"); } + let text_alpha_from_coverage = self.memory.options.style().visuals.text_alpha_from_coverage; + let mut is_new = false; let fonts = self @@ -624,13 +626,14 @@ impl ContextImpl { Fonts::new( pixels_per_point, max_texture_side, + text_alpha_from_coverage, self.font_definitions.clone(), ) }); { profiling::scope!("Fonts::begin_pass"); - fonts.begin_pass(pixels_per_point, max_texture_side); + fonts.begin_pass(pixels_per_point, max_texture_side, text_alpha_from_coverage); } if is_new && self.memory.options.preload_font_glyphs { @@ -1921,16 +1924,6 @@ impl Context { } } - pub(crate) fn reset_font_atlas(&self) { - let pixels_per_point = self.pixels_per_point(); - let fonts = self.read(|ctx| { - ctx.fonts - .get(&pixels_per_point.into()) - .map(|current_fonts| current_fonts.lock().fonts.definitions().clone()) - }); - self.memory_mut(|mem| mem.new_font_definitions = fonts); - } - /// Tell `egui` which fonts to use. /// /// The default `egui` fonts only support latin and cyrillic alphabets, @@ -2066,19 +2059,10 @@ impl Context { /// You can use [`Ui::style_mut`] to change the style of a single [`Ui`]. pub fn set_style_of(&self, theme: Theme, style: impl Into>) { let style = style.into(); - let mut recreate_font_atlas = false; - self.options_mut(|opt| { - let dest = match theme { - Theme::Dark => &mut opt.dark_style, - Theme::Light => &mut opt.light_style, - }; - recreate_font_atlas = - dest.visuals.text_alpha_from_coverage != style.visuals.text_alpha_from_coverage; - *dest = style; + self.options_mut(|opt| match theme { + Theme::Dark => opt.dark_style = style, + Theme::Light => opt.light_style = style, }); - if recreate_font_atlas { - self.reset_font_atlas(); - } } /// The [`crate::Visuals`] used by all subsequent windows, panels etc. @@ -2475,28 +2459,7 @@ impl ContextImpl { } // Inform the backend of all textures that have been updated (including font atlas). - let textures_delta = { - // HACK to get much nicer looking text in light mode. - // This assumes all text is black-on-white in light mode, - // and white-on-black in dark mode, which is not necessarily true, - // but often close enough. - // Of course this fails for cases when there is black-on-white text in dark mode, - // and white-on-black text in light mode. - - let text_alpha_from_coverage = - self.memory.options.style().visuals.text_alpha_from_coverage; - - let mut textures_delta = self.tex_manager.0.write().take_delta(); - - for (_, delta) in &mut textures_delta.set { - if let ImageData::Font(font) = &mut delta.image { - delta.image = - ImageData::Color(font.to_color_image(text_alpha_from_coverage).into()); - } - } - - textures_delta - }; + let textures_delta = self.tex_manager.0.write().take_delta(); let mut platform_output: PlatformOutput = std::mem::take(&mut viewport.output); @@ -3094,17 +3057,9 @@ impl Context { options.ui(ui); - let text_alpha_from_coverage_changed = - prev_options.style().visuals.text_alpha_from_coverage - != options.style().visuals.text_alpha_from_coverage; - if options != prev_options { self.options_mut(move |o| *o = options); } - - if text_alpha_from_coverage_changed { - ui.ctx().reset_font_atlas(); - } } fn fonts_tweak_ui(&self, ui: &mut Ui) { diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index b4daa9326..7abc5ba41 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -467,7 +467,7 @@ pub use emath::{ remap_clamp, vec2, }; pub use epaint::{ - ClippedPrimitive, ColorImage, CornerRadius, FontImage, ImageData, Margin, Mesh, PaintCallback, + ClippedPrimitive, ColorImage, CornerRadius, ImageData, Margin, Mesh, PaintCallback, PaintCallbackInfo, Shadow, Shape, Stroke, StrokeKind, TextureHandle, TextureId, mutex, text::{FontData, FontDefinitions, FontFamily, FontId, FontTweak}, textures::{TextureFilter, TextureOptions, TextureWrapMode, TexturesDelta}, diff --git a/crates/egui_demo_lib/benches/benchmark.rs b/crates/egui_demo_lib/benches/benchmark.rs index e0c86f0db..02f098e67 100644 --- a/crates/egui_demo_lib/benches/benchmark.rs +++ b/crates/egui_demo_lib/benches/benchmark.rs @@ -168,6 +168,7 @@ pub fn criterion_benchmark(c: &mut Criterion) { let fonts = egui::epaint::text::Fonts::new( pixels_per_point, max_texture_side, + egui::epaint::AlphaFromCoverage::default(), egui::FontDefinitions::default(), ); { @@ -210,7 +211,11 @@ pub fn criterion_benchmark(c: &mut Criterion) { let mut rng = rand::rng(); b.iter(|| { - fonts.begin_pass(pixels_per_point, max_texture_side); + fonts.begin_pass( + pixels_per_point, + max_texture_side, + egui::epaint::AlphaFromCoverage::default(), + ); // Delete a random character, simulating a user making an edit in a long file: let mut new_string = string.clone(); diff --git a/crates/egui_glow/src/painter.rs b/crates/egui_glow/src/painter.rs index a574cbb71..5833d73ef 100644 --- a/crates/egui_glow/src/painter.rs +++ b/crates/egui_glow/src/painter.rs @@ -534,23 +534,6 @@ impl Painter { self.upload_texture_srgb(delta.pos, image.size, delta.options, data); } - egui::ImageData::Font(image) => { - assert_eq!( - image.width() * image.height(), - image.pixels.len(), - "Mismatch between texture size and texel count" - ); - - let data: Vec = { - profiling::scope!("font -> sRGBA"); - image - .srgba_pixels(Default::default()) - .flat_map(|a| a.to_array()) - .collect() - }; - - self.upload_texture_srgb(delta.pos, image.size, delta.options, &data); - } }; } diff --git a/crates/epaint/benches/benchmark.rs b/crates/epaint/benches/benchmark.rs index 676e1d0fd..d4b10a216 100644 --- a/crates/epaint/benches/benchmark.rs +++ b/crates/epaint/benches/benchmark.rs @@ -1,8 +1,8 @@ use criterion::{Criterion, black_box, criterion_group, criterion_main}; use epaint::{ - ClippedShape, Color32, Mesh, PathStroke, Pos2, Rect, Shape, Stroke, TessellationOptions, - Tessellator, TextureAtlas, Vec2, pos2, tessellator::Path, + AlphaFromCoverage, ClippedShape, Color32, Mesh, PathStroke, Pos2, Rect, Shape, Stroke, + TessellationOptions, Tessellator, TextureAtlas, Vec2, pos2, tessellator::Path, }; #[global_allocator] @@ -66,7 +66,7 @@ fn tessellate_circles(c: &mut Criterion) { let pixels_per_point = 2.0; let options = TessellationOptions::default(); - let atlas = TextureAtlas::new([4096, 256]); + let atlas = TextureAtlas::new([4096, 256], AlphaFromCoverage::default()); let font_tex_size = atlas.size(); let prepared_discs = atlas.prepared_discs(); diff --git a/crates/epaint/src/image.rs b/crates/epaint/src/image.rs index 6b40714cd..e14ea869e 100644 --- a/crates/epaint/src/image.rs +++ b/crates/epaint/src/image.rs @@ -7,24 +7,20 @@ use std::sync::Arc; /// /// To load an image file, see [`ColorImage::from_rgba_unmultiplied`]. /// -/// In order to paint the image on screen, you first need to convert it to +/// This is currently an enum with only one variant, but more image types may be added in the future. /// -/// See also: [`ColorImage`], [`FontImage`]. -#[derive(Clone, PartialEq)] +/// See also: [`ColorImage`]. +#[derive(Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub enum ImageData { /// RGBA image. Color(Arc), - - /// Used for the font texture. - Font(FontImage), } impl ImageData { pub fn size(&self) -> [usize; 2] { match self { Self::Color(image) => image.size, - Self::Font(image) => image.size, } } @@ -38,7 +34,7 @@ impl ImageData { pub fn bytes_per_pixel(&self) -> usize { match self { - Self::Color(_) | Self::Font(_) => 4, + Self::Color(_) => 4, } } } @@ -271,6 +267,37 @@ impl ColorImage { } Self::new([width, height], output) } + + /// Clone a sub-region as a new image. + pub fn region_by_pixels(&self, [x, y]: [usize; 2], [w, h]: [usize; 2]) -> Self { + assert!( + x + w <= self.width(), + "x + w should be <= self.width(), but x: {}, w: {}, width: {}", + x, + w, + self.width() + ); + assert!( + y + h <= self.height(), + "y + h should be <= self.height(), but y: {}, h: {}, height: {}", + y, + h, + self.height() + ); + + let mut pixels = Vec::with_capacity(w * h); + for y in y..y + h { + let offset = y * self.width() + x; + pixels.extend(&self.pixels[offset..(offset + w)]); + } + assert_eq!( + pixels.len(), + w * h, + "pixels.len should be w * h, but got {}", + pixels.len() + ); + Self::new([w, h], pixels) + } } impl std::ops::Index<(usize, usize)> for ColorImage { @@ -371,127 +398,12 @@ impl AlphaFromCoverage { } } -/// A single-channel image designed for the font texture. -/// -/// Each value represents "coverage", i.e. how much a texel is covered by a character. -/// -/// This is roughly interpreted as the opacity of a white image. -#[derive(Clone, Default, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub struct FontImage { - /// width, height - pub size: [usize; 2], - - /// The coverage value. - /// - /// Often you want to use [`Self::srgba_pixels`] instead. - pub pixels: Vec, -} - -impl FontImage { - pub fn new(size: [usize; 2]) -> Self { - Self { - size, - pixels: vec![0.0; size[0] * size[1]], - } - } - - #[inline] - pub fn width(&self) -> usize { - self.size[0] - } - - #[inline] - pub fn height(&self) -> usize { - self.size[1] - } - - /// Returns the textures as `sRGBA` premultiplied pixels, row by row, top to bottom. - #[inline] - pub fn srgba_pixels( - &self, - alpha_from_coverage: AlphaFromCoverage, - ) -> impl ExactSizeIterator + '_ { - self.pixels - .iter() - .map(move |&coverage| alpha_from_coverage.color_from_coverage(coverage)) - } - - /// Convert this coverage image to a [`ColorImage`]. - pub fn to_color_image(&self, alpha_from_coverage: AlphaFromCoverage) -> ColorImage { - profiling::function_scope!(); - let pixels = self.srgba_pixels(alpha_from_coverage).collect(); - ColorImage::new(self.size, pixels) - } - - /// Clone a sub-region as a new image. - pub fn region(&self, [x, y]: [usize; 2], [w, h]: [usize; 2]) -> Self { - assert!( - x + w <= self.width(), - "x + w should be <= self.width(), but x: {}, w: {}, width: {}", - x, - w, - self.width() - ); - assert!( - y + h <= self.height(), - "y + h should be <= self.height(), but y: {}, h: {}, height: {}", - y, - h, - self.height() - ); - - let mut pixels = Vec::with_capacity(w * h); - for y in y..y + h { - let offset = y * self.width() + x; - pixels.extend(&self.pixels[offset..(offset + w)]); - } - assert_eq!( - pixels.len(), - w * h, - "pixels.len should be w * h, but got {}", - pixels.len() - ); - Self { - size: [w, h], - pixels, - } - } -} - -impl std::ops::Index<(usize, usize)> for FontImage { - type Output = f32; - - #[inline] - fn index(&self, (x, y): (usize, usize)) -> &f32 { - let [w, h] = self.size; - assert!(x < w && y < h, "x: {x}, y: {y}, w: {w}, h: {h}"); - &self.pixels[y * w + x] - } -} - -impl std::ops::IndexMut<(usize, usize)> for FontImage { - #[inline] - fn index_mut(&mut self, (x, y): (usize, usize)) -> &mut f32 { - let [w, h] = self.size; - assert!(x < w && y < h, "x: {x}, y: {y}, w: {w}, h: {h}"); - &mut self.pixels[y * w + x] - } -} - -impl From for ImageData { - #[inline(always)] - fn from(image: FontImage) -> Self { - Self::Font(image) - } -} - // ---------------------------------------------------------------------------- /// A change to an image. /// /// Either a whole new image, or an update to a rectangular region of it. -#[derive(Clone, PartialEq)] +#[derive(Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[must_use = "The painter must take care of this"] pub struct ImageDelta { diff --git a/crates/epaint/src/lib.rs b/crates/epaint/src/lib.rs index 7afc7b146..f02889d97 100644 --- a/crates/epaint/src/lib.rs +++ b/crates/epaint/src/lib.rs @@ -50,7 +50,7 @@ pub use self::{ color::ColorMode, corner_radius::CornerRadius, corner_radius_f32::CornerRadiusF32, - image::{AlphaFromCoverage, ColorImage, FontImage, ImageData, ImageDelta}, + image::{AlphaFromCoverage, ColorImage, ImageData, ImageDelta}, margin::Margin, margin_f32::*, mesh::{Mesh, Mesh16, Vertex}, diff --git a/crates/epaint/src/shapes/text_shape.rs b/crates/epaint/src/shapes/text_shape.rs index bf9db964b..b366c86cf 100644 --- a/crates/epaint/src/shapes/text_shape.rs +++ b/crates/epaint/src/shapes/text_shape.rs @@ -179,7 +179,12 @@ mod tests { #[test] fn text_bounding_box_under_rotation() { - let fonts = Fonts::new(1.0, 1024, FontDefinitions::default()); + let fonts = Fonts::new( + 1.0, + 1024, + AlphaFromCoverage::default(), + FontDefinitions::default(), + ); let font = FontId::monospace(12.0); let mut t = crate::Shape::text( diff --git a/crates/epaint/src/text/font.rs b/crates/epaint/src/text/font.rs index 72fffaad0..dd095c443 100644 --- a/crates/epaint/src/text/font.rs +++ b/crates/epaint/src/text/font.rs @@ -279,12 +279,13 @@ impl FontImpl { } else { let glyph_pos = { let atlas = &mut self.atlas.lock(); + let text_alpha_from_coverage = atlas.text_alpha_from_coverage; let (glyph_pos, image) = atlas.allocate((glyph_width, glyph_height)); glyph.draw(|x, y, v| { if 0.0 < v { let px = glyph_pos.0 + x as usize; let py = glyph_pos.1 + y as usize; - image[(px, py)] = v; + image[(px, py)] = text_alpha_from_coverage.color_from_coverage(v); } }); glyph_pos diff --git a/crates/epaint/src/text/fonts.rs b/crates/epaint/src/text/fonts.rs index 6e90b5666..5f9006915 100644 --- a/crates/epaint/src/text/fonts.rs +++ b/crates/epaint/src/text/fonts.rs @@ -1,7 +1,7 @@ use std::{collections::BTreeMap, sync::Arc}; use crate::{ - TextureAtlas, + AlphaFromCoverage, TextureAtlas, mutex::{Mutex, MutexGuard}, text::{ Galley, LayoutJob, LayoutSection, @@ -430,36 +430,56 @@ impl Fonts { pub fn new( pixels_per_point: f32, max_texture_side: usize, + text_alpha_from_coverage: AlphaFromCoverage, definitions: FontDefinitions, ) -> Self { let fonts_and_cache = FontsAndCache { - fonts: FontsImpl::new(pixels_per_point, max_texture_side, definitions), + fonts: FontsImpl::new( + pixels_per_point, + max_texture_side, + text_alpha_from_coverage, + definitions, + ), galley_cache: Default::default(), }; Self(Arc::new(Mutex::new(fonts_and_cache))) } /// Call at the start of each frame with the latest known - /// `pixels_per_point` and `max_texture_side`. + /// `pixels_per_point`, `max_texture_side`, and `text_alpha_from_coverage`. /// /// Call after painting the previous frame, but before using [`Fonts`] for the new frame. /// - /// This function will react to changes in `pixels_per_point` and `max_texture_side`, + /// This function will react to changes in `pixels_per_point`, `max_texture_side`, and `text_alpha_from_coverage`, /// as well as notice when the font atlas is getting full, and handle that. - pub fn begin_pass(&self, pixels_per_point: f32, max_texture_side: usize) { + pub fn begin_pass( + &self, + pixels_per_point: f32, + max_texture_side: usize, + text_alpha_from_coverage: AlphaFromCoverage, + ) { let mut fonts_and_cache = self.0.lock(); let pixels_per_point_changed = fonts_and_cache.fonts.pixels_per_point != pixels_per_point; let max_texture_side_changed = fonts_and_cache.fonts.max_texture_side != max_texture_side; + let text_alpha_from_coverage_changed = + fonts_and_cache.fonts.atlas.lock().text_alpha_from_coverage != text_alpha_from_coverage; let font_atlas_almost_full = fonts_and_cache.fonts.atlas.lock().fill_ratio() > 0.8; - let needs_recreate = - pixels_per_point_changed || max_texture_side_changed || font_atlas_almost_full; + let needs_recreate = pixels_per_point_changed + || max_texture_side_changed + || text_alpha_from_coverage_changed + || font_atlas_almost_full; if needs_recreate { let definitions = fonts_and_cache.fonts.definitions.clone(); *fonts_and_cache = FontsAndCache { - fonts: FontsImpl::new(pixels_per_point, max_texture_side, definitions), + fonts: FontsImpl::new( + pixels_per_point, + max_texture_side, + text_alpha_from_coverage, + definitions, + ), galley_cache: Default::default(), }; } @@ -497,7 +517,7 @@ impl Fonts { /// The full font atlas image. #[inline] - pub fn image(&self) -> crate::FontImage { + pub fn image(&self) -> crate::ColorImage { self.lock().fonts.atlas.lock().image().clone() } @@ -642,6 +662,7 @@ impl FontsImpl { pub fn new( pixels_per_point: f32, max_texture_side: usize, + text_alpha_from_coverage: AlphaFromCoverage, definitions: FontDefinitions, ) -> Self { assert!( @@ -651,7 +672,7 @@ impl FontsImpl { let texture_width = max_texture_side.at_most(16 * 1024); let initial_height = 32; // Keep initial font atlas small, so it is fast to upload to GPU. This will expand as needed anyways. - let atlas = TextureAtlas::new([texture_width, initial_height]); + let atlas = TextureAtlas::new([texture_width, initial_height], text_alpha_from_coverage); let atlas = Arc::new(Mutex::new(atlas)); @@ -1120,6 +1141,7 @@ mod tests { let mut fonts = FontsImpl::new( pixels_per_point, max_texture_side, + AlphaFromCoverage::default(), FontDefinitions::default(), ); diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index 3777d860c..7915bbf61 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -1034,11 +1034,18 @@ fn is_cjk_break_allowed(c: char) -> bool { #[cfg(test)] mod tests { + use crate::AlphaFromCoverage; + use super::{super::*, *}; #[test] fn test_zero_max_width() { - let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default()); + let mut fonts = FontsImpl::new( + 1.0, + 1024, + AlphaFromCoverage::default(), + FontDefinitions::default(), + ); let mut layout_job = LayoutJob::single_section("W".into(), TextFormat::default()); layout_job.wrap.max_width = 0.0; let galley = layout(&mut fonts, layout_job.into()); @@ -1049,7 +1056,12 @@ mod tests { fn test_truncate_with_newline() { // No matter where we wrap, we should be appending the newline character. - let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default()); + let mut fonts = FontsImpl::new( + 1.0, + 1024, + AlphaFromCoverage::default(), + FontDefinitions::default(), + ); let text_format = TextFormat { font_id: FontId::monospace(12.0), ..Default::default() @@ -1094,7 +1106,12 @@ mod tests { #[test] fn test_cjk() { - let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default()); + let mut fonts = FontsImpl::new( + 1.0, + 1024, + AlphaFromCoverage::default(), + FontDefinitions::default(), + ); let mut layout_job = LayoutJob::single_section( "日本語とEnglishの混在した文章".into(), TextFormat::default(), @@ -1109,7 +1126,12 @@ mod tests { #[test] fn test_pre_cjk() { - let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default()); + let mut fonts = FontsImpl::new( + 1.0, + 1024, + AlphaFromCoverage::default(), + FontDefinitions::default(), + ); let mut layout_job = LayoutJob::single_section( "日本語とEnglishの混在した文章".into(), TextFormat::default(), @@ -1124,7 +1146,12 @@ mod tests { #[test] fn test_truncate_width() { - let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default()); + let mut fonts = FontsImpl::new( + 1.0, + 1024, + AlphaFromCoverage::default(), + FontDefinitions::default(), + ); let mut layout_job = LayoutJob::single_section("# DNA\nMore text".into(), TextFormat::default()); layout_job.wrap.max_width = f32::INFINITY; diff --git a/crates/epaint/src/texture_atlas.rs b/crates/epaint/src/texture_atlas.rs index 8e174e544..36dd1b48e 100644 --- a/crates/epaint/src/texture_atlas.rs +++ b/crates/epaint/src/texture_atlas.rs @@ -1,6 +1,7 @@ +use ecolor::Color32; use emath::{Rect, remap_clamp}; -use crate::{FontImage, ImageDelta}; +use crate::{AlphaFromCoverage, ColorImage, ImageDelta}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct Rectu { @@ -57,7 +58,7 @@ pub struct PreparedDisc { /// More characters can be added, possibly expanding the texture. #[derive(Clone)] pub struct TextureAtlas { - image: FontImage, + image: ColorImage, /// What part of the image that is dirty dirty: Rectu, @@ -72,18 +73,22 @@ pub struct TextureAtlas { /// pre-rasterized discs of radii `2^i`, where `i` is the index. discs: Vec, + + /// Controls how to convert glyph coverage to alpha. + pub(crate) text_alpha_from_coverage: AlphaFromCoverage, } impl TextureAtlas { - pub fn new(size: [usize; 2]) -> Self { + pub fn new(size: [usize; 2], text_alpha_from_coverage: AlphaFromCoverage) -> Self { assert!(size[0] >= 1024, "Tiny texture atlas"); let mut atlas = Self { - image: FontImage::new(size), + image: ColorImage::filled(size, Color32::TRANSPARENT), dirty: Rectu::EVERYTHING, cursor: (0, 0), row_height: 0, overflowed: false, discs: vec![], // will be filled in below + text_alpha_from_coverage, }; // Make the top left pixel fully white for `WHITE_UV`, i.e. painting something with solid color: @@ -93,7 +98,7 @@ impl TextureAtlas { (0, 0), "Expected the first allocation to be at (0, 0), but was at {pos:?}" ); - image[pos] = 1.0; + image[pos] = Color32::WHITE; // Allocate a series of anti-aliased discs used to render small filled circles: // TODO(emilk): these circles can be packed A LOT better. @@ -116,7 +121,7 @@ impl TextureAtlas { let coverage = remap_clamp(distance_to_center, (r - 0.5)..=(r + 0.5), 1.0..=0.0); image[((x as i32 + hw + dx) as usize, (y as i32 + hw + dy) as usize)] = - coverage; + text_alpha_from_coverage.color_from_coverage(coverage); } } atlas.discs.push(PrerasterizedDisc { @@ -184,7 +189,7 @@ impl TextureAtlas { /// The full font atlas image. #[inline] - pub fn image(&self) -> &FontImage { + pub fn image(&self) -> &ColorImage { &self.image } @@ -200,14 +205,14 @@ impl TextureAtlas { } else { let pos = [dirty.min_x, dirty.min_y]; let size = [dirty.max_x - dirty.min_x, dirty.max_y - dirty.min_y]; - let region = self.image.region(pos, size); + let region = self.image.region_by_pixels(pos, size); Some(ImageDelta::partial(pos, region, texture_options)) } } /// Returns the coordinates of where the rect ended up, /// and invalidates the region. - pub fn allocate(&mut self, (w, h): (usize, usize)) -> ((usize, usize), &mut FontImage) { + pub fn allocate(&mut self, (w, h): (usize, usize)) -> ((usize, usize), &mut ColorImage) { /// On some low-precision GPUs (my old iPad) characters get muddled up /// if we don't add some empty pixels between the characters. /// On modern high-precision GPUs this is not needed. @@ -254,13 +259,15 @@ impl TextureAtlas { } } -fn resize_to_min_height(image: &mut FontImage, required_height: usize) -> bool { +fn resize_to_min_height(image: &mut ColorImage, required_height: usize) -> bool { while required_height >= image.height() { image.size[1] *= 2; // double the height } if image.width() * image.height() > image.pixels.len() { - image.pixels.resize(image.width() * image.height(), 0.0); + image + .pixels + .resize(image.width() * image.height(), Color32::TRANSPARENT); true } else { false diff --git a/crates/epaint/src/textures.rs b/crates/epaint/src/textures.rs index cc191a75f..0944a9052 100644 --- a/crates/epaint/src/textures.rs +++ b/crates/epaint/src/textures.rs @@ -271,7 +271,7 @@ pub enum TextureWrapMode { /// What has been allocated and freed during the last period. /// /// These are commands given to the integration painter. -#[derive(Clone, Default, PartialEq)] +#[derive(Clone, Default, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[must_use = "The painter must take care of this"] pub struct TexturesDelta { From a811b975c24ecdcb0dec50a7195a5312d98385da Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 7 Jul 2025 09:33:08 +0200 Subject: [PATCH 09/19] Better deprecation of SelectableLabel --- crates/egui/src/widgets/selected_label.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/egui/src/widgets/selected_label.rs b/crates/egui/src/widgets/selected_label.rs index da18b5fe0..536ef43da 100644 --- a/crates/egui/src/widgets/selected_label.rs +++ b/crates/egui/src/widgets/selected_label.rs @@ -1,2 +1,13 @@ -#[deprecated = "SelectableLabel has been removed. Use Button::selectable() instead"] +#![expect(deprecated, clippy::new_ret_no_self)] + +use crate::WidgetText; + +#[deprecated = "Use `Button::selectable()` instead"] pub struct SelectableLabel {} + +impl SelectableLabel { + #[deprecated = "Use `Button::selectable()` instead"] + pub fn new(selected: bool, text: impl Into) -> super::Button<'static> { + crate::Button::selectable(selected, text) + } +} From 6d807074222c1f049ffb17082ff6afca8ed0a67a Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 7 Jul 2025 12:02:01 +0200 Subject: [PATCH 10/19] Fix tooltips sometimes changing position each frame (#7304) There was a bug in how we decide where to place a `Tooltip` (or other `Popup`), which could lead to tooltips jumping around every frame, especially if it changed size slightly. The new code is simpler and bug-free. --- crates/egui/src/containers/popup.rs | 1 + crates/emath/src/align.rs | 8 +++- crates/emath/src/rect_align.rs | 57 ++++++++++++----------------- 3 files changed, 31 insertions(+), 35 deletions(-) diff --git a/crates/egui/src/containers/popup.rs b/crates/egui/src/containers/popup.rs index 434747cda..b63825e46 100644 --- a/crates/egui/src/containers/popup.rs +++ b/crates/egui/src/containers/popup.rs @@ -484,6 +484,7 @@ impl<'a> Popup<'a> { self.gap, expected_popup_size, ) + .unwrap_or_default() } /// Show the popup. diff --git a/crates/emath/src/align.rs b/crates/emath/src/align.rs index 89b28d4af..a672c456e 100644 --- a/crates/emath/src/align.rs +++ b/crates/emath/src/align.rs @@ -146,7 +146,7 @@ impl Align { // ---------------------------------------------------------------------------- /// Two-dimension alignment, e.g. [`Align2::LEFT_TOP`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Copy, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct Align2(pub [Align; 2]); @@ -298,3 +298,9 @@ impl std::ops::IndexMut for Align2 { pub fn center_size_in_rect(size: Vec2, frame: Rect) -> Rect { Align2::CENTER_CENTER.align_size_within_rect(size, frame) } + +impl std::fmt::Debug for Align2 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Align2({:?}, {:?})", self.x(), self.y()) + } +} diff --git a/crates/emath/src/rect_align.rs b/crates/emath/src/rect_align.rs index 5a8102ad1..31580405f 100644 --- a/crates/emath/src/rect_align.rs +++ b/crates/emath/src/rect_align.rs @@ -7,9 +7,9 @@ use crate::{Align2, Pos2, Rect, Vec2}; /// /// There are helper constants for the 12 common menu positions: /// ```text -/// ┌───────────┐ ┌────────┐ ┌─────────┐ -/// │ TOP_START │ │ TOP │ │ TOP_END │ -/// └───────────┘ └────────┘ └─────────┘ +/// ┌───────────┐ ┌────────┐ ┌─────────┐ +/// │ TOP_START │ │ TOP │ │ TOP_END │ +/// └───────────┘ └────────┘ └─────────┘ /// ┌──────────┐ ┌────────────────────────────────────┐ ┌───────────┐ /// │LEFT_START│ │ │ │RIGHT_START│ /// └──────────┘ │ │ └───────────┘ @@ -19,9 +19,9 @@ use crate::{Align2, Pos2, Rect, Vec2}; /// ┌──────────┐ │ │ ┌───────────┐ /// │ LEFT_END │ │ │ │ RIGHT_END │ /// └──────────┘ └────────────────────────────────────┘ └───────────┘ -/// ┌────────────┐ ┌──────┐ ┌──────────┐ -/// │BOTTOM_START│ │BOTTOM│ │BOTTOM_END│ -/// └────────────┘ └──────┘ └──────────┘ +/// ┌────────────┐ ┌──────┐ ┌──────────┐ +/// │BOTTOM_START│ │BOTTOM│ │BOTTOM_END│ +/// └────────────┘ └──────┘ └──────────┘ /// ``` // There is no `new` function on purpose, since writing out `parent` and `child` is more // reasonable. @@ -235,45 +235,34 @@ impl RectAlign { [self.flip_x(), self.flip_y(), self.flip()] } - /// Look for the [`RectAlign`] that fits best in the available space. + /// Look for the first alternative [`RectAlign`] that allows the child rect to fit + /// inside the `screen_rect`. + /// + /// If no alternative fits, the first is returned. + /// If no alternatives are given, `None` is returned. /// /// 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, + values_to_try: impl Iterator, + screen_rect: 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; + expected_size: Vec2, + ) -> Option { + let mut first_choice = None; 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; + first_choice = first_choice.or(Some(align)); // Remember the first alternative + + let suggested_popup_rect = align.align_rect(&parent_rect, expected_size, gap); + + if screen_rect.contains_rect(suggested_popup_rect) { + return Some(align); } } - best + first_choice } } From 3622a03a460c1eb65d1c31dcf947eeddc8515e5b Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 7 Jul 2025 12:02:51 +0200 Subject: [PATCH 11/19] Mark `Popup` with `#[must_use]` --- crates/egui/src/containers/popup.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/egui/src/containers/popup.rs b/crates/egui/src/containers/popup.rs index b63825e46..44dafc62f 100644 --- a/crates/egui/src/containers/popup.rs +++ b/crates/egui/src/containers/popup.rs @@ -160,6 +160,7 @@ impl From for UiKind { } } +#[must_use = "Call `.show()` to actually display the popup"] pub struct Popup<'a> { id: Id, ctx: Context, From 93d562221b27e7345e0bbc59fad9dd91c2f2a585 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 7 Jul 2025 12:03:03 +0200 Subject: [PATCH 12/19] Change `Rect::area` to return zero for negative rectangles (#7305) Previously a single-negative rectangle (where `min.x > max.x` XOR `min.y > max.y`) would return a negative area, while a doubly-negative rectangle (`min.x > max.x` AND `min.y > max.y`) would return a positive area. Now both return zero instead. --- crates/emath/src/rect.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/emath/src/rect.rs b/crates/emath/src/rect.rs index 777c12527..8810fe361 100644 --- a/crates/emath/src/rect.rs +++ b/crates/emath/src/rect.rs @@ -1,6 +1,6 @@ use std::fmt; -use crate::{Div, Mul, Pos2, Rangef, Rot2, Vec2, lerp, pos2, vec2}; +use crate::{Div, Mul, NumExt as _, Pos2, Rangef, Rot2, Vec2, lerp, pos2, vec2}; /// A rectangular region of space. /// @@ -341,11 +341,13 @@ impl Rect { self.max - self.min } + /// Note: this can be negative. #[inline(always)] pub fn width(&self) -> f32 { self.max.x - self.min.x } + /// Note: this can be negative. #[inline(always)] pub fn height(&self) -> f32 { self.max.y - self.min.y @@ -373,9 +375,10 @@ impl Rect { } } + /// This is never negative, and instead returns zero for negative rectangles. #[inline(always)] pub fn area(&self) -> f32 { - self.width() * self.height() + self.width().at_least(0.0) * self.height().at_least(0.0) } /// The distance from the rect to the position. From 933d305159a9e3855cdf5edf34eb98d589ca361b Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 7 Jul 2025 12:06:59 +0200 Subject: [PATCH 13/19] Improve doc-string for `Image::alt_text` --- crates/egui/src/widgets/image.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/egui/src/widgets/image.rs b/crates/egui/src/widgets/image.rs index 0ec4e9c90..08b377843 100644 --- a/crates/egui/src/widgets/image.rs +++ b/crates/egui/src/widgets/image.rs @@ -278,7 +278,8 @@ impl<'a> Image<'a> { } /// Set alt text for the image. This will be shown when the image fails to load. - /// It will also be read to screen readers. + /// + /// It will also be used for accessibility (e.g. read by screen readers). #[inline] pub fn alt_text(mut self, label: impl Into) -> Self { self.alt_text = Some(label.into()); @@ -672,7 +673,7 @@ pub fn paint_texture_load_result( rect: Rect, show_loading_spinner: Option, options: &ImageOptions, - alt: Option<&str>, + alt_text: Option<&str>, ) { match tlr { Ok(TexturePoll::Ready { texture }) => { @@ -697,9 +698,9 @@ pub fn paint_texture_load_result( 0.0, TextFormat::simple(font_id.clone(), ui.visuals().error_fg_color), ); - if let Some(alt) = alt { + if let Some(alt_text) = alt_text { job.append( - alt, + alt_text, ui.spacing().item_spacing.x, TextFormat::simple(font_id, ui.visuals().text_color()), ); From b11b77e85f5b11bc0180e49773b0e875b57b6784 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 7 Jul 2025 12:07:13 +0200 Subject: [PATCH 14/19] Save a few CPU cycles with earlier early-out from `Popup::show` (#7306) --- crates/egui/src/containers/popup.rs | 52 +++++++++++++++-------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/crates/egui/src/containers/popup.rs b/crates/egui/src/containers/popup.rs index 44dafc62f..b16b61648 100644 --- a/crates/egui/src/containers/popup.rs +++ b/crates/egui/src/containers/popup.rs @@ -492,31 +492,11 @@ impl<'a> Popup<'a> { /// 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 hover_pos = self.ctx.pointer_hover_pos(); - let Popup { - id, - ctx, - anchor, - open_kind, - close_behavior, - kind, - info, - layer_id, - rect_align: _, - alternative_aligns: _, - gap, - widget_clicked_elsewhere, - width, - sense, - layout, - frame, - style, - } = self; - - let hover_pos = ctx.pointer_hover_pos(); - if let OpenKind::Memory { set, .. } = open_kind { - ctx.memory_mut(|mem| match set { + let id = self.id; + if let OpenKind::Memory { set } = self.open_kind { + self.ctx.memory_mut(|mem| match set { Some(SetOpenCommand::Bool(open)) => { if open { match self.anchor { @@ -538,10 +518,32 @@ impl<'a> Popup<'a> { }); } - if !open_kind.is_open(id, &ctx) { + if !self.open_kind.is_open(self.id, &self.ctx) { return None; } + let best_align = self.get_best_align(); + + let Popup { + id, + ctx, + anchor, + open_kind, + close_behavior, + kind, + info, + layer_id, + rect_align: _, + alternative_aligns: _, + gap, + widget_clicked_elsewhere, + width, + sense, + layout, + frame, + style, + } = self; + if kind != PopupKind::Tooltip { ctx.pass_state_mut(|fs| { fs.layers From 09596a5e7b4a18eb0cbcaf07084141187c36d87d Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 7 Jul 2025 13:50:53 +0200 Subject: [PATCH 15/19] egui_kittest: more ergonomic functions taking `Impl Into` (#7307) --- crates/egui_demo_app/tests/test_demo_app.rs | 2 +- .../src/demo/demo_app_windows.rs | 2 +- .../src/demo/tests/tessellation_test.rs | 2 +- crates/egui_demo_lib/src/rendering_test.rs | 2 +- crates/egui_kittest/src/snapshot.rs | 42 ++++++++++++------- tests/egui_tests/tests/test_sides.rs | 2 +- tests/egui_tests/tests/test_widgets.rs | 4 +- 7 files changed, 34 insertions(+), 22 deletions(-) diff --git a/crates/egui_demo_app/tests/test_demo_app.rs b/crates/egui_demo_app/tests/test_demo_app.rs index 961990b90..63b3b2a89 100644 --- a/crates/egui_demo_app/tests/test_demo_app.rs +++ b/crates/egui_demo_app/tests/test_demo_app.rs @@ -69,6 +69,6 @@ fn test_demo_app() { // Can't use Harness::run because fractal clock keeps requesting repaints harness.run_steps(4); - results.add(harness.try_snapshot(&anchor.to_string())); + results.add(harness.try_snapshot(anchor.to_string())); } } 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 4414a9572..7f24d7284 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -416,7 +416,7 @@ mod tests { options = options.threshold(OsThreshold::new(0.0).linux(2.1)); } - results.add(harness.try_snapshot_options(&format!("demos/{name}"), &options)); + results.add(harness.try_snapshot_options(format!("demos/{name}"), &options)); } } diff --git a/crates/egui_demo_lib/src/demo/tests/tessellation_test.rs b/crates/egui_demo_lib/src/demo/tests/tessellation_test.rs index c1a325a5f..cb08cf24e 100644 --- a/crates/egui_demo_lib/src/demo/tests/tessellation_test.rs +++ b/crates/egui_demo_lib/src/demo/tests/tessellation_test.rs @@ -374,7 +374,7 @@ mod tests { harness.fit_contents(); harness.run(); - harness.snapshot(&format!("tessellation_test/{name}")); + harness.snapshot(format!("tessellation_test/{name}")); } } } diff --git a/crates/egui_demo_lib/src/rendering_test.rs b/crates/egui_demo_lib/src/rendering_test.rs index e14bfca07..427d0f4ee 100644 --- a/crates/egui_demo_lib/src/rendering_test.rs +++ b/crates/egui_demo_lib/src/rendering_test.rs @@ -745,7 +745,7 @@ mod tests { harness.fit_contents(); - results.add(harness.try_snapshot(&format!("rendering_test/dpi_{dpi:.2}"))); + results.add(harness.try_snapshot(format!("rendering_test/dpi_{dpi:.2}"))); } } } diff --git a/crates/egui_kittest/src/snapshot.rs b/crates/egui_kittest/src/snapshot.rs index e53615d90..8a457ec52 100644 --- a/crates/egui_kittest/src/snapshot.rs +++ b/crates/egui_kittest/src/snapshot.rs @@ -310,7 +310,15 @@ fn should_update_snapshots() -> bool { /// reading or writing the snapshot. pub fn try_image_snapshot_options( new: &image::RgbaImage, - name: &str, + name: impl Into, + options: &SnapshotOptions, +) -> SnapshotResult { + try_image_snapshot_options_impl(new, name.into(), options) +} + +fn try_image_snapshot_options_impl( + new: &image::RgbaImage, + name: String, options: &SnapshotOptions, ) -> SnapshotResult { let SnapshotOptions { @@ -319,7 +327,7 @@ pub fn try_image_snapshot_options( failed_pixel_count_threshold, } = options; - let parent_path = if let Some(parent) = PathBuf::from(name).parent() { + let parent_path = if let Some(parent) = PathBuf::from(&name).parent() { output_path.join(parent) } else { output_path.clone() @@ -385,7 +393,7 @@ pub fn try_image_snapshot_options( return update_snapshot(); } else { return Err(SnapshotError::SizeMismatch { - name: name.to_owned(), + name, expected: previous.dimensions(), actual: new.dimensions(), }); @@ -412,7 +420,7 @@ pub fn try_image_snapshot_options( } Err(SnapshotError::Diff { - name: name.to_owned(), + name, diff: num_wrong_pixels, diff_path, }) @@ -435,7 +443,7 @@ pub fn try_image_snapshot_options( /// # Errors /// Returns a [`SnapshotError`] if the image does not match the snapshot or if there was an error /// reading or writing the snapshot. -pub fn try_image_snapshot(current: &image::RgbaImage, name: &str) -> SnapshotResult { +pub fn try_image_snapshot(current: &image::RgbaImage, name: impl Into) -> SnapshotResult { try_image_snapshot_options(current, name, &SnapshotOptions::default()) } @@ -456,7 +464,11 @@ pub fn try_image_snapshot(current: &image::RgbaImage, name: &str) -> SnapshotRes /// Panics if the image does not match the snapshot or if there was an error reading or writing the /// snapshot. #[track_caller] -pub fn image_snapshot_options(current: &image::RgbaImage, name: &str, options: &SnapshotOptions) { +pub fn image_snapshot_options( + current: &image::RgbaImage, + name: impl Into, + options: &SnapshotOptions, +) { match try_image_snapshot_options(current, name, options) { Ok(_) => {} Err(err) => { @@ -475,7 +487,7 @@ pub fn image_snapshot_options(current: &image::RgbaImage, name: &str, options: & /// Panics if the image does not match the snapshot or if there was an error reading or writing the /// snapshot. #[track_caller] -pub fn image_snapshot(current: &image::RgbaImage, name: &str) { +pub fn image_snapshot(current: &image::RgbaImage, name: impl Into) { match try_image_snapshot(current, name) { Ok(_) => {} Err(err) => { @@ -506,13 +518,13 @@ impl Harness<'_, State> { /// error reading or writing the snapshot, if the rendering fails or if no default renderer is available. pub fn try_snapshot_options( &mut self, - name: &str, + name: impl Into, options: &SnapshotOptions, ) -> SnapshotResult { let image = self .render() .map_err(|err| SnapshotError::RenderError { err })?; - try_image_snapshot_options(&image, name, options) + try_image_snapshot_options(&image, name.into(), options) } /// Render an image using the setup [`crate::TestRenderer`] and compare it to the snapshot. @@ -523,7 +535,7 @@ impl Harness<'_, State> { /// # Errors /// Returns a [`SnapshotError`] if the image does not match the snapshot, if there was an /// error reading or writing the snapshot, if the rendering fails or if no default renderer is available. - pub fn try_snapshot(&mut self, name: &str) -> SnapshotResult { + pub fn try_snapshot(&mut self, name: impl Into) -> SnapshotResult { let image = self .render() .map_err(|err| SnapshotError::RenderError { err })?; @@ -549,7 +561,7 @@ impl Harness<'_, State> { /// Panics if the image does not match the snapshot, if there was an error reading or writing the /// snapshot, if the rendering fails or if no default renderer is available. #[track_caller] - pub fn snapshot_options(&mut self, name: &str, options: &SnapshotOptions) { + pub fn snapshot_options(&mut self, name: impl Into, options: &SnapshotOptions) { match self.try_snapshot_options(name, options) { Ok(_) => {} Err(err) => { @@ -567,7 +579,7 @@ impl Harness<'_, State> { /// Panics if the image does not match the snapshot, if there was an error reading or writing the /// snapshot, if the rendering fails or if no default renderer is available. #[track_caller] - pub fn snapshot(&mut self, name: &str) { + pub fn snapshot(&mut self, name: impl Into) { match self.try_snapshot(name) { Ok(_) => {} Err(err) => { @@ -588,7 +600,7 @@ impl Harness<'_, State> { )] pub fn try_wgpu_snapshot_options( &mut self, - name: &str, + name: impl Into, options: &SnapshotOptions, ) -> SnapshotResult { self.try_snapshot_options(name, options) @@ -598,7 +610,7 @@ impl Harness<'_, State> { since = "0.31.0", note = "Use `try_snapshot` instead. This function will be removed in 0.32" )] - pub fn try_wgpu_snapshot(&mut self, name: &str) -> SnapshotResult { + pub fn try_wgpu_snapshot(&mut self, name: impl Into) -> SnapshotResult { self.try_snapshot(name) } @@ -606,7 +618,7 @@ impl Harness<'_, State> { since = "0.31.0", note = "Use `snapshot_options` instead. This function will be removed in 0.32" )] - pub fn wgpu_snapshot_options(&mut self, name: &str, options: &SnapshotOptions) { + pub fn wgpu_snapshot_options(&mut self, name: impl Into, options: &SnapshotOptions) { self.snapshot_options(name, options); } diff --git a/tests/egui_tests/tests/test_sides.rs b/tests/egui_tests/tests/test_sides.rs index 293abd311..52d35db4c 100644 --- a/tests/egui_tests/tests/test_sides.rs +++ b/tests/egui_tests/tests/test_sides.rs @@ -71,6 +71,6 @@ fn test_variants( harness.fit_contents(); } - results.add(harness.try_snapshot(&format!("sides/{name}_{variant_name}"))); + results.add(harness.try_snapshot(format!("sides/{name}_{variant_name}"))); } } diff --git a/tests/egui_tests/tests/test_widgets.rs b/tests/egui_tests/tests/test_widgets.rs index 4ec317521..a6050e95b 100644 --- a/tests/egui_tests/tests/test_widgets.rs +++ b/tests/egui_tests/tests/test_widgets.rs @@ -244,7 +244,7 @@ fn test_widget_layout(name: &str, mut w: impl FnMut(&mut Ui) -> Response) -> Sna }); harness.fit_contents(); - harness.try_snapshot(&format!("layout/{name}")) + harness.try_snapshot(format!("layout/{name}")) } /// Utility to create a snapshot test of the different states of a egui widget. @@ -370,7 +370,7 @@ impl<'a> VisualTests<'a> { harness.fit_contents(); - harness.try_snapshot(&format!("visuals/{}", self.name)) + harness.try_snapshot(format!("visuals/{}", self.name)) } } From dd1052108ee5775d5730e346e88f933869a62463 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 7 Jul 2025 13:58:22 +0200 Subject: [PATCH 16/19] Add snapshot test for image blending (#7309) This adds a test that can be used to see the improvements made by this PR (if any): * https://github.com/emilk/egui/pull/5839 --- crates/egui_demo_lib/data/ring.png | Bin 0 -> 507 bytes crates/egui_demo_lib/tests/image_blending.rs | 25 ++++++++++++++++++ .../snapshots/image_blending/image_x1.png | 3 +++ .../snapshots/image_blending/image_x2.png | 3 +++ 4 files changed, 31 insertions(+) create mode 100644 crates/egui_demo_lib/data/ring.png create mode 100644 crates/egui_demo_lib/tests/image_blending.rs create mode 100644 crates/egui_demo_lib/tests/snapshots/image_blending/image_x1.png create mode 100644 crates/egui_demo_lib/tests/snapshots/image_blending/image_x2.png diff --git a/crates/egui_demo_lib/data/ring.png b/crates/egui_demo_lib/data/ring.png new file mode 100644 index 0000000000000000000000000000000000000000..f82db91963b9888718c91603837ea40377b3f6d4 GIT binary patch literal 507 zcmVVK~#7F%~p$T zgfI*>rw+gd%?OOZ2pxe<+6|5kxD9Ru=my;YIsvzVYfFwWh7Haketx#&BnEgg z3d>|fbo8lJ*Mnp#isA>(0+WzjNoEXVBB~eD zNkekuF$lx!T;^=u4ne}IFX|o>Nu#!_+758m-5~?UW6|-E+Z$+w)b_N*$-=jn8oa_u zz|`%9%iUMQL8$ZB#wI=YdD@v=gPp848;$D{qsv){>l(-&AyLq(GuL6bh`Qra)n(N{ xF=AgFjTpu(L@uSzS_u1ROnWfU)i%Ma;t$Q!*^gw- Date: Mon, 7 Jul 2025 17:46:27 +0200 Subject: [PATCH 17/19] Improve texture filtering by doing it in gamma space (#7311) * Closes https://github.com/emilk/egui/pull/5839 This makes some transparent images look a lot nicer when blended: ![image](https://github.com/user-attachments/assets/7f370aaf-886a-423c-8391-c378849b63ca) Cursive text will also look nicer. This unfortunately changes the contract of what `register_native_texture` expects --------- Co-authored-by: Adrian Blumer --- crates/egui-wgpu/src/egui.wgsl | 10 ++++------ crates/egui-wgpu/src/renderer.rs | 8 ++++---- crates/egui/src/lib.rs | 10 ++++------ .../tests/snapshots/easymarkeditor.png | 4 ++-- .../tests/snapshots/imageviewer.png | 4 ++-- crates/egui_demo_lib/src/rendering_test.rs | 16 +++++++++------- .../tests/snapshots/demos/Bézier Curve.png | 4 ++-- .../tests/snapshots/demos/Clipboard Test.png | 4 ++-- .../tests/snapshots/demos/Scene.png | 4 ++-- .../tests/snapshots/demos/Tessellation Test.png | 4 ++-- .../tests/snapshots/image_blending/image_x1.png | 4 ++-- .../tests/snapshots/image_blending/image_x2.png | 4 ++-- .../tests/snapshots/rendering_test/dpi_1.00.png | 4 ++-- .../tests/snapshots/rendering_test/dpi_1.25.png | 4 ++-- .../tests/snapshots/rendering_test/dpi_1.50.png | 4 ++-- .../tests/snapshots/rendering_test/dpi_1.67.png | 4 ++-- .../tests/snapshots/rendering_test/dpi_1.75.png | 4 ++-- .../tests/snapshots/rendering_test/dpi_2.00.png | 4 ++-- .../tessellation_test/Additive rectangle.png | 4 ++-- .../tessellation_test/Blurred stroke.png | 4 ++-- .../snapshots/tessellation_test/Blurred.png | 4 ++-- .../tessellation_test/Minimal rounding.png | 4 ++-- .../snapshots/tessellation_test/Normal.png | 4 ++-- .../Thick stroke, minimal rounding.png | 4 ++-- .../snapshots/tessellation_test/Thin filled.png | 4 ++-- .../tessellation_test/Thin stroked.png | 4 ++-- .../tests/snapshots/widget_gallery_dark_x1.png | 4 ++-- .../tests/snapshots/widget_gallery_dark_x2.png | 4 ++-- .../tests/snapshots/widget_gallery_light_x1.png | 4 ++-- .../tests/snapshots/widget_gallery_light_x2.png | 4 ++-- crates/egui_glow/src/painter.rs | 10 ++-------- crates/egui_glow/src/shader/fragment.glsl | 17 ----------------- 32 files changed, 75 insertions(+), 100 deletions(-) diff --git a/crates/egui-wgpu/src/egui.wgsl b/crates/egui-wgpu/src/egui.wgsl index b60d9de9e..2921be74f 100644 --- a/crates/egui-wgpu/src/egui.wgsl +++ b/crates/egui-wgpu/src/egui.wgsl @@ -97,9 +97,8 @@ fn vs_main( @fragment fn fs_main_linear_framebuffer(in: VertexOutput) -> @location(0) vec4 { - // We always have an sRGB aware texture at the moment. - let tex_linear = textureSample(r_tex_color, r_tex_sampler, in.tex_coord); - let tex_gamma = gamma_from_linear_rgba(tex_linear); + // We expect "normal" textures that are NOT sRGB-aware. + let tex_gamma = textureSample(r_tex_color, r_tex_sampler, in.tex_coord); var out_color_gamma = in.color * tex_gamma; // Dither the float color down to eight bits to reduce banding. // This step is optional for egui backends. @@ -115,9 +114,8 @@ fn fs_main_linear_framebuffer(in: VertexOutput) -> @location(0) vec4 { @fragment fn fs_main_gamma_framebuffer(in: VertexOutput) -> @location(0) vec4 { - // We always have an sRGB aware texture at the moment. - let tex_linear = textureSample(r_tex_color, r_tex_sampler, in.tex_coord); - let tex_gamma = gamma_from_linear_rgba(tex_linear); + // We expect "normal" textures that are NOT sRGB-aware. + let tex_gamma = textureSample(r_tex_color, r_tex_sampler, in.tex_coord); var out_color_gamma = in.color * tex_gamma; // Dither the float color down to eight bits to reduce banding. // This step is optional for egui backends. diff --git a/crates/egui-wgpu/src/renderer.rs b/crates/egui-wgpu/src/renderer.rs index 41a9b3b78..012613aeb 100644 --- a/crates/egui-wgpu/src/renderer.rs +++ b/crates/egui-wgpu/src/renderer.rs @@ -629,9 +629,9 @@ impl Renderer { mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, // Minspec for wgpu WebGL emulation is WebGL2, so this should always be supported. + format: wgpu::TextureFormat::Rgba8Unorm, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[wgpu::TextureFormat::Rgba8UnormSrgb], + view_formats: &[wgpu::TextureFormat::Rgba8Unorm], }) }; let origin = wgpu::Origin3d::ZERO; @@ -690,7 +690,7 @@ impl Renderer { /// /// This enables the application to reference the texture inside an image ui element. /// This effectively enables off-screen rendering inside the egui UI. Texture must have - /// the texture format [`wgpu::TextureFormat::Rgba8UnormSrgb`]. + /// the texture format [`wgpu::TextureFormat::Rgba8Unorm`]. pub fn register_native_texture( &mut self, device: &wgpu::Device, @@ -738,7 +738,7 @@ impl Renderer { /// This allows applications to specify individual minification/magnification filters as well as /// custom mipmap and tiling options. /// - /// The texture must have the format [`wgpu::TextureFormat::Rgba8UnormSrgb`]. + /// The texture must have the format [`wgpu::TextureFormat::Rgba8Unorm`]. /// Any compare function supplied in the [`wgpu::SamplerDescriptor`] will be ignored. #[expect(clippy::needless_pass_by_value)] // false positive pub fn register_native_texture_with_sampler_options( diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 7abc5ba41..9c4686bde 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -161,12 +161,10 @@ //! //! * egui uses premultiplied alpha, so make sure your blending function is `(ONE, ONE_MINUS_SRC_ALPHA)`. //! * Make sure your texture sampler is clamped (`GL_CLAMP_TO_EDGE`). -//! * egui prefers linear color spaces for all blending so: -//! * Use an sRGBA-aware texture if available (e.g. `GL_SRGB8_ALPHA8`). -//! * Otherwise: remember to decode gamma in the fragment shader. -//! * Decode the gamma of the incoming vertex colors in your vertex shader. -//! * Turn on sRGBA/linear framebuffer if available (`GL_FRAMEBUFFER_SRGB`). -//! * Otherwise: gamma-encode the colors before you write them again. +//! * egui prefers gamma color spaces for all blending so: +//! * Do NOT use an sRGBA-aware texture (NOT `GL_SRGB8_ALPHA8`). +//! * Multiply texture and vertex colors in gamma space +//! * Turn OFF sRGBA/gamma framebuffer (NO `GL_FRAMEBUFFER_SRGB`). //! //! //! # Understanding immediate mode diff --git a/crates/egui_demo_app/tests/snapshots/easymarkeditor.png b/crates/egui_demo_app/tests/snapshots/easymarkeditor.png index 6a1d0290a..f06e16cba 100644 --- a/crates/egui_demo_app/tests/snapshots/easymarkeditor.png +++ b/crates/egui_demo_app/tests/snapshots/easymarkeditor.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f62d5375ff784e333e01a31b84d9caadf2dcbd2b19647a08977dab6550b48828 -size 179654 +oid sha256:fc3dbdcd483d4da7a9c1a00f0245a7882997fbcd2d26f8d6a6d2d855f3382063 +size 179724 diff --git a/crates/egui_demo_app/tests/snapshots/imageviewer.png b/crates/egui_demo_app/tests/snapshots/imageviewer.png index a13af2e71..e6f108e98 100644 --- a/crates/egui_demo_app/tests/snapshots/imageviewer.png +++ b/crates/egui_demo_app/tests/snapshots/imageviewer.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0e37b3ce49c9ccc1a64beb58b176e23ab6c1fa2d897f676b0de85e510e6bfa85 -size 100845 +oid sha256:c8ad2c2d494e2287b878049091688069e4d86b69ae72b89cb7ecbe47d8c35e33 +size 100766 diff --git a/crates/egui_demo_lib/src/rendering_test.rs b/crates/egui_demo_lib/src/rendering_test.rs index 427d0f4ee..e6f8b2c2d 100644 --- a/crates/egui_demo_lib/src/rendering_test.rs +++ b/crates/egui_demo_lib/src/rendering_test.rs @@ -159,7 +159,7 @@ impl ColorTest { ui.separator(); // TODO(emilk): test color multiplication (image tint), - // to make sure vertex and texture color multiplication is done in linear space. + // to make sure vertex and texture color multiplication is done in gamma space. ui.label("Gamma interpolation:"); self.show_gradients(ui, WHITE, (RED, GREEN), Interpolation::Gamma); @@ -191,8 +191,8 @@ impl ColorTest { ui.separator(); - ui.label("Linear interpolation (texture sampling):"); - self.show_gradients(ui, WHITE, (RED, GREEN), Interpolation::Linear); + ui.label("Texture interpolation (texture sampling) should be in gamma space:"); + self.show_gradients(ui, WHITE, (RED, GREEN), Interpolation::Gamma); } fn show_gradients( @@ -245,11 +245,10 @@ impl ColorTest { let g = Gradient::endpoints(left, right); match interpolation { - Interpolation::Linear => { - // texture sampler is sRGBA aware, and should therefore be linear - self.tex_gradient(ui, "Texture of width 2 (test texture sampler)", bg_fill, &g); - } + Interpolation::Linear => {} Interpolation::Gamma => { + self.tex_gradient(ui, "Texture of width 2 (test texture sampler)", bg_fill, &g); + // vertex shader uses gamma self.vertex_gradient( ui, @@ -330,7 +329,10 @@ fn vertex_gradient(ui: &mut Ui, bg_fill: Color32, gradient: &Gradient) -> Respon #[derive(Clone, Copy)] enum Interpolation { + /// egui used to want Linear interpolation for some things, but now we're always in gamma space. + #[expect(unused)] Linear, + Gamma, } diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Bézier Curve.png b/crates/egui_demo_lib/tests/snapshots/demos/Bézier Curve.png index 8bceea77e..0bf7d928c 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Bézier Curve.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Bézier Curve.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cbe9f58cce2466360b4b93b03afaaee36711b3017ddff1b2b56bfe49ea91a076 -size 31306 +oid sha256:13262df01a7f2cd5655b8b0bb9379ae02a851c877314375f047a7d749908125c +size 31368 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Clipboard Test.png b/crates/egui_demo_lib/tests/snapshots/demos/Clipboard Test.png index ec9510008..449c88683 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Clipboard Test.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Clipboard Test.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b4f807098e0bc56eaacabb76d646a76036cc66a7a6e54b1c934fa9fecb5b0170 -size 26470 +oid sha256:27d5aa7b7e6bd5f59c1765e98ca4588545284456e4cc255799ea797950e09850 +size 26461 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Scene.png b/crates/egui_demo_lib/tests/snapshots/demos/Scene.png index f5bb0ffd1..760c84e8f 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Scene.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Scene.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fdf3535530c1abb1262383ff9a3f2a740ad2c62ccec33ec5fb435be11625d139 -size 35125 +oid sha256:aabc0e3821a2d9b21708e9b8d9be02ad55055ccabe719a93af921dba2384b4b3 +size 34297 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Tessellation Test.png b/crates/egui_demo_lib/tests/snapshots/demos/Tessellation Test.png index 6f3ca31d5..462a40ad9 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Tessellation Test.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Tessellation Test.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:16dc96246f011c6e9304409af7b4084f28e20cd813e44abca73834386e98b9b1 -size 70373 +oid sha256:a3f8873c9cfb80ddeb1ccc0fa04c1c84ea936db1685238f5d29ee6e89f55e457 +size 68814 diff --git a/crates/egui_demo_lib/tests/snapshots/image_blending/image_x1.png b/crates/egui_demo_lib/tests/snapshots/image_blending/image_x1.png index e53ea7352..7ef5676bb 100644 --- a/crates/egui_demo_lib/tests/snapshots/image_blending/image_x1.png +++ b/crates/egui_demo_lib/tests/snapshots/image_blending/image_x1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ae04cea447427982f1d68bb2250563aaa3be137a77f6dd3f253da77c194c84cf -size 812 +oid sha256:e057c0bba4ec4c30e890c39153bd6dd17c511f410bfb894e66ef3ef9973d8fd4 +size 807 diff --git a/crates/egui_demo_lib/tests/snapshots/image_blending/image_x2.png b/crates/egui_demo_lib/tests/snapshots/image_blending/image_x2.png index 25ce3ca22..89fad98f9 100644 --- a/crates/egui_demo_lib/tests/snapshots/image_blending/image_x2.png +++ b/crates/egui_demo_lib/tests/snapshots/image_blending/image_x2.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ff053e309e6ae38a4b6fe1dd58f1255116fffab6182ce5f77b6360b00cf2af47 -size 2067 +oid sha256:c8b573f58a41efe26a0bf335e27cc123ffd4c13b24576e46d96ddedfed68b606 +size 2027 diff --git a/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.00.png b/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.00.png index 51b8d8540..200de9835 100644 --- a/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.00.png +++ b/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.00.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9f6cf5b14056522d06f0cb1e56bafd7e5ab7a9033eb358748d43d748bb0ceef1 -size 553177 +oid sha256:39bd11647241521c0ad5c7163a1af4f1aa86792018657091a2d47bb7f2c48b47 +size 598408 diff --git a/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.25.png b/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.25.png index 3e73d0abb..ea9298ad6 100644 --- a/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.25.png +++ b/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.25.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fd3bd1f64995db34a14dbc860ae8b8e269073ed7b8f10d10ce8f99b613cfc999 -size 769357 +oid sha256:080a59163ab60d60738cfab5afac7cfbddfb585d8a0ee7d03540924b458badea +size 833822 diff --git a/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.50.png b/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.50.png index 4b9a5194e..86ec338c7 100644 --- a/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.50.png +++ b/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.50.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f12e6145f3a1c3fda6dede3daeb0e52ed2bffb35531d823133224a477798a14a -size 907800 +oid sha256:216d3d028f48f4bfbd6aca0a25500655d4eb4d5581a387397ed0b048d57fc0c3 +size 984737 diff --git a/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.67.png b/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.67.png index c5f324368..3b239324a 100644 --- a/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.67.png +++ b/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.67.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:05bdcfd2c34b6d7badede14f5495dce34e5e9cfe421314f40dcea15e9f865736 -size 1024735 +oid sha256:399fc3d64e7ff637425effe6c80d684be1cf8bb9b555458352d0ae6c62bddb5a +size 1109505 diff --git a/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.75.png b/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.75.png index 8e4481d06..8d4a1b365 100644 --- a/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.75.png +++ b/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.75.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8365c89f6b823f01464a9310bab7717bf25305b335cdeecf21711c7dca9f053f -size 1140082 +oid sha256:30ce4874a1adb8acf8c8e404f3e19e683eca3133cdef15befbc50d6c09618094 +size 1241773 diff --git a/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_2.00.png b/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_2.00.png index de8c8b321..854ee6b29 100644 --- a/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_2.00.png +++ b/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_2.00.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b38021057ec6b5bb39c41bd4afaf5e9ff38687216d52d5bba8cbf7b6fdfe9a4f -size 1291518 +oid sha256:135fbe5f4ee485ee671931b64c16410b9073d40bedb23dc2545fc863448e8c63 +size 1398091 diff --git a/crates/egui_demo_lib/tests/snapshots/tessellation_test/Additive rectangle.png b/crates/egui_demo_lib/tests/snapshots/tessellation_test/Additive rectangle.png index 2fdbaff3d..852bc6bb2 100644 --- a/crates/egui_demo_lib/tests/snapshots/tessellation_test/Additive rectangle.png +++ b/crates/egui_demo_lib/tests/snapshots/tessellation_test/Additive rectangle.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4ac90da596084a880487035b276177e98d711854143373d59860f01733b1c0cd -size 45592 +oid sha256:1b0fe7aa33506c59142aff764c6b229e8c55b35c8038312b22ea2659987a081a +size 45578 diff --git a/crates/egui_demo_lib/tests/snapshots/tessellation_test/Blurred stroke.png b/crates/egui_demo_lib/tests/snapshots/tessellation_test/Blurred stroke.png index 5eb8bf536..49ba9ad07 100644 --- a/crates/egui_demo_lib/tests/snapshots/tessellation_test/Blurred stroke.png +++ b/crates/egui_demo_lib/tests/snapshots/tessellation_test/Blurred stroke.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e412d424aac7b9cbdfdb8e36bd598e6cbc77183da7733c94c5f20e70699b8b4a -size 87263 +oid sha256:3a3512ea7235640db79e86aa84039621784270201c9213c910f15e7451e5600b +size 87336 diff --git a/crates/egui_demo_lib/tests/snapshots/tessellation_test/Blurred.png b/crates/egui_demo_lib/tests/snapshots/tessellation_test/Blurred.png index e9e1a078d..6130a530e 100644 --- a/crates/egui_demo_lib/tests/snapshots/tessellation_test/Blurred.png +++ b/crates/egui_demo_lib/tests/snapshots/tessellation_test/Blurred.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:222a32da21c69ee46e847e29fb05fd5e1d2de6bb7a22358549bc426f8243fdcb -size 119671 +oid sha256:dc4918a534f26b72d42ef20221e26c0f91a0252870a1987c1fe4cc4aa3c74872 +size 119406 diff --git a/crates/egui_demo_lib/tests/snapshots/tessellation_test/Minimal rounding.png b/crates/egui_demo_lib/tests/snapshots/tessellation_test/Minimal rounding.png index a08a658eb..7969d6bee 100644 --- a/crates/egui_demo_lib/tests/snapshots/tessellation_test/Minimal rounding.png +++ b/crates/egui_demo_lib/tests/snapshots/tessellation_test/Minimal rounding.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d42e11f50a9522dd5ae73e8f8336bfb01493751705055a63abea3f5258f7c9c1 -size 51626 +oid sha256:71182570a65693839fd7cd7390025731ab3f3f88ab55bc67d8be6466fe5a2c11 +size 51843 diff --git a/crates/egui_demo_lib/tests/snapshots/tessellation_test/Normal.png b/crates/egui_demo_lib/tests/snapshots/tessellation_test/Normal.png index 9d19dbc9b..49141c40d 100644 --- a/crates/egui_demo_lib/tests/snapshots/tessellation_test/Normal.png +++ b/crates/egui_demo_lib/tests/snapshots/tessellation_test/Normal.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c33617dfde24071fa65aff2543f22883f5526152fb344997b1877aeb38df72fe -size 54848 +oid sha256:a0dc0294f990730da34fcbbc53f44280306ec6179826c20a6c9ee946e1148b61 +size 55042 diff --git a/crates/egui_demo_lib/tests/snapshots/tessellation_test/Thick stroke, minimal rounding.png b/crates/egui_demo_lib/tests/snapshots/tessellation_test/Thick stroke, minimal rounding.png index 7816cfdb0..e8f61ae77 100644 --- a/crates/egui_demo_lib/tests/snapshots/tessellation_test/Thick stroke, minimal rounding.png +++ b/crates/egui_demo_lib/tests/snapshots/tessellation_test/Thick stroke, minimal rounding.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fbf40a1f56a6e280002719c6556fe477c93fa7fe88d398372ed36efaa1b83a62 -size 55282 +oid sha256:3004adfe5a864bdc768ceb42d1f09b6debcaf5413f8fea4d36b0aff99e4584f9 +size 55511 diff --git a/crates/egui_demo_lib/tests/snapshots/tessellation_test/Thin filled.png b/crates/egui_demo_lib/tests/snapshots/tessellation_test/Thin filled.png index 6005e865a..139648c38 100644 --- a/crates/egui_demo_lib/tests/snapshots/tessellation_test/Thin filled.png +++ b/crates/egui_demo_lib/tests/snapshots/tessellation_test/Thin filled.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:33621731155ebb463fb01ea41ab20272885250efcd7d5c7683c10936b296e14d -size 36446 +oid sha256:b99360833f59a212a965a13d52485ab8ad0e6420b9288b2d6936507067c22a85 +size 36395 diff --git a/crates/egui_demo_lib/tests/snapshots/tessellation_test/Thin stroked.png b/crates/egui_demo_lib/tests/snapshots/tessellation_test/Thin stroked.png index 713e01fcc..10ad7603b 100644 --- a/crates/egui_demo_lib/tests/snapshots/tessellation_test/Thin stroked.png +++ b/crates/egui_demo_lib/tests/snapshots/tessellation_test/Thin stroked.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:186bd8a3146ad8f1977955e3f7fa593877ad1bf1e8376d32f446c67f36a2aafe -size 36493 +oid sha256:82aa004f668f0ac6b493717b4bff8436ccc1e991c7fb3fcde5b5f3a123c06b9f +size 36428 diff --git a/crates/egui_demo_lib/tests/snapshots/widget_gallery_dark_x1.png b/crates/egui_demo_lib/tests/snapshots/widget_gallery_dark_x1.png index d607894d4..9cd2d630e 100644 --- a/crates/egui_demo_lib/tests/snapshots/widget_gallery_dark_x1.png +++ b/crates/egui_demo_lib/tests/snapshots/widget_gallery_dark_x1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:62df72fd7e2404c4aa482f09eff5103ee28e8afc42ee8c8c74307a246f64cda6 -size 64651 +oid sha256:7e21bb01ae6e4226402a97b7086b49604cdde6b41a6770199df68dc940cd9a45 +size 64748 diff --git a/crates/egui_demo_lib/tests/snapshots/widget_gallery_dark_x2.png b/crates/egui_demo_lib/tests/snapshots/widget_gallery_dark_x2.png index ffb00ce22..f881f639c 100644 --- a/crates/egui_demo_lib/tests/snapshots/widget_gallery_dark_x2.png +++ b/crates/egui_demo_lib/tests/snapshots/widget_gallery_dark_x2.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3f5a7397601cb718d5529842a428d2d328d4fe3d1a9cf1a3ca6d583d8525f75e -size 153190 +oid sha256:0626bc45888ad250bf4b49c7f7f462a93ab91e3a2817fd7d0902411043c97132 +size 153289 diff --git a/crates/egui_demo_lib/tests/snapshots/widget_gallery_light_x1.png b/crates/egui_demo_lib/tests/snapshots/widget_gallery_light_x1.png index 948766c97..5b88cc531 100644 --- a/crates/egui_demo_lib/tests/snapshots/widget_gallery_light_x1.png +++ b/crates/egui_demo_lib/tests/snapshots/widget_gallery_light_x1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:34d85b6015112ea2733f7246f8daabfb9d983523e187339e4d26bfc1f3a3bba3 -size 59460 +oid sha256:919a82c95468300bcd09471eb31d53d25d50cdcb02c27ddbc759d24e65da92b6 +size 59398 diff --git a/crates/egui_demo_lib/tests/snapshots/widget_gallery_light_x2.png b/crates/egui_demo_lib/tests/snapshots/widget_gallery_light_x2.png index 150365d5f..a1971cad6 100644 --- a/crates/egui_demo_lib/tests/snapshots/widget_gallery_light_x2.png +++ b/crates/egui_demo_lib/tests/snapshots/widget_gallery_light_x2.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4f51d75010cd1213daa6a1282d352655e64b69da7bca478011ea055a2e5349bc -size 146500 +oid sha256:a55e39a640b0e2cc992286a86dcf38460f1abcc7b964df9022549ca1a94c4df5 +size 146408 diff --git a/crates/egui_glow/src/painter.rs b/crates/egui_glow/src/painter.rs index 5833d73ef..98fe25a45 100644 --- a/crates/egui_glow/src/painter.rs +++ b/crates/egui_glow/src/painter.rs @@ -172,12 +172,7 @@ impl Painter { let supported_extensions = gl.supported_extensions(); log::trace!("OpenGL extensions: {supported_extensions:?}"); - let srgb_textures = shader_version == ShaderVersion::Es300 // WebGL2 always support sRGB - || supported_extensions.iter().any(|extension| { - // EXT_sRGB, GL_ARB_framebuffer_sRGB, GL_EXT_sRGB, GL_EXT_texture_sRGB_decode, … - extension.contains("sRGB") - }); - log::debug!("SRGB texture Support: {:?}", srgb_textures); + let srgb_textures = false; // egui wants normal sRGB-unaware textures let supports_srgb_framebuffer = !cfg!(target_arch = "wasm32") && supported_extensions.iter().any(|extension| { @@ -202,11 +197,10 @@ impl Painter { &gl, glow::FRAGMENT_SHADER, &format!( - "{}\n#define NEW_SHADER_INTERFACE {}\n#define DITHERING {}\n#define SRGB_TEXTURES {}\n{}\n{}", + "{}\n#define NEW_SHADER_INTERFACE {}\n#define DITHERING {}\n{}\n{}", shader_version_declaration, shader_version.is_new_shader_interface() as i32, dithering as i32, - srgb_textures as i32, shader_prefix, FRAG_SRC ), diff --git a/crates/egui_glow/src/shader/fragment.glsl b/crates/egui_glow/src/shader/fragment.glsl index f2792ed04..07a931b53 100644 --- a/crates/egui_glow/src/shader/fragment.glsl +++ b/crates/egui_glow/src/shader/fragment.glsl @@ -43,25 +43,8 @@ vec3 dither_interleaved(vec3 rgb, float levels) { return rgb + noise / (levels - 1.0); } -// 0-1 sRGB gamma from 0-1 linear -vec3 srgb_gamma_from_linear(vec3 rgb) { - bvec3 cutoff = lessThan(rgb, vec3(0.0031308)); - vec3 lower = rgb * vec3(12.92); - vec3 higher = vec3(1.055) * pow(rgb, vec3(1.0 / 2.4)) - vec3(0.055); - return mix(higher, lower, vec3(cutoff)); -} - -// 0-1 sRGBA gamma from 0-1 linear -vec4 srgba_gamma_from_linear(vec4 rgba) { - return vec4(srgb_gamma_from_linear(rgba.rgb), rgba.a); -} - void main() { -#if SRGB_TEXTURES - vec4 texture_in_gamma = srgba_gamma_from_linear(texture2D(u_sampler, v_tc)); -#else vec4 texture_in_gamma = texture2D(u_sampler, v_tc); -#endif // We multiply the colors in gamma space, because that's the only way to get text to look right. vec4 frag_color_gamma = v_rgba_in_gamma * texture_in_gamma; From 508c60b2e2f2a0e63e911529dd71f2d995a9c50f Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Wed, 9 Jul 2025 08:19:04 +0200 Subject: [PATCH 18/19] Add `Galley::intrinsic_size` and use it in `AtomLayout` (#7146) - part of https://github.com/emilk/egui/issues/5762 - also allows me to simplify sizing logic in egui_flex --- crates/egui/src/atomics/atom.rs | 4 +- crates/egui/src/atomics/atom_kind.rs | 9 ++-- crates/egui/src/atomics/atom_layout.rs | 18 +++---- crates/egui/src/atomics/sized_atom.rs | 4 +- crates/epaint/src/shapes/text_shape.rs | 2 + crates/epaint/src/text/fonts.rs | 57 +++++++++++++++++++++ crates/epaint/src/text/text_layout.rs | 47 ++++++++++++++++- crates/epaint/src/text/text_layout_types.rs | 12 +++++ tests/egui_tests/tests/test_atoms.rs | 32 ++++++++++++ 9 files changed, 166 insertions(+), 19 deletions(-) diff --git a/crates/egui/src/atomics/atom.rs b/crates/egui/src/atomics/atom.rs index 4f4b5b750..ee5ff30d4 100644 --- a/crates/egui/src/atomics/atom.rs +++ b/crates/egui/src/atomics/atom.rs @@ -81,7 +81,7 @@ impl<'a> Atom<'a> { wrap_mode = Some(TextWrapMode::Truncate); } - let (preferred, kind) = self.kind.into_sized(ui, available_size, wrap_mode); + let (intrinsic, kind) = self.kind.into_sized(ui, available_size, wrap_mode); let size = self .size @@ -89,7 +89,7 @@ impl<'a> Atom<'a> { SizedAtom { size, - preferred_size: preferred, + intrinsic_size: intrinsic.at_least(self.size.unwrap_or_default()), grow: self.grow, kind, } diff --git a/crates/egui/src/atomics/atom_kind.rs b/crates/egui/src/atomics/atom_kind.rs index 2672e646b..b85b504a2 100644 --- a/crates/egui/src/atomics/atom_kind.rs +++ b/crates/egui/src/atomics/atom_kind.rs @@ -81,11 +81,10 @@ impl<'a> AtomKind<'a> { ) -> (Vec2, SizedAtomKind<'a>) { match self { AtomKind::Text(text) => { - let galley = text.into_galley(ui, wrap_mode, available_size.x, TextStyle::Button); - ( - galley.size(), // TODO(#5762): calculate the preferred size - SizedAtomKind::Text(galley), - ) + let wrap_mode = wrap_mode.unwrap_or(ui.wrap_mode()); + let galley = + text.into_galley(ui, Some(wrap_mode), available_size.x, TextStyle::Button); + (galley.intrinsic_size, SizedAtomKind::Text(galley)) } AtomKind::Image(image) => { let size = image.load_and_calc_size(ui, available_size); diff --git a/crates/egui/src/atomics/atom_layout.rs b/crates/egui/src/atomics/atom_layout.rs index a25a4b7c6..53819fbb0 100644 --- a/crates/egui/src/atomics/atom_layout.rs +++ b/crates/egui/src/atomics/atom_layout.rs @@ -183,10 +183,10 @@ impl<'a> AtomLayout<'a> { let mut desired_width = 0.0; - // Preferred width / height is the ideal size of the widget, e.g. the size where the + // intrinsic width / height is the ideal size of the widget, e.g. the size where the // text is not wrapped. Used to set Response::intrinsic_size. - let mut preferred_width = 0.0; - let mut preferred_height = 0.0; + let mut intrinsic_width = 0.0; + let mut intrinsic_height = 0.0; let mut height: f32 = 0.0; @@ -203,7 +203,7 @@ impl<'a> AtomLayout<'a> { if atoms.len() > 1 { let gap_space = gap * (atoms.len() as f32 - 1.0); desired_width += gap_space; - preferred_width += gap_space; + intrinsic_width += gap_space; } for (idx, item) in atoms.into_iter().enumerate() { @@ -224,10 +224,10 @@ impl<'a> AtomLayout<'a> { let size = sized.size; desired_width += size.x; - preferred_width += sized.preferred_size.x; + intrinsic_width += sized.intrinsic_size.x; height = height.at_least(size.y); - preferred_height = preferred_height.at_least(sized.preferred_size.y); + intrinsic_height = intrinsic_height.at_least(sized.intrinsic_size.y); sized_items.push(sized); } @@ -243,10 +243,10 @@ impl<'a> AtomLayout<'a> { let size = sized.size; desired_width += size.x; - preferred_width += sized.preferred_size.x; + intrinsic_width += sized.intrinsic_size.x; height = height.at_least(size.y); - preferred_height = preferred_height.at_least(sized.preferred_size.y); + intrinsic_height = intrinsic_height.at_least(sized.intrinsic_size.y); sized_items.insert(index, sized); } @@ -259,7 +259,7 @@ impl<'a> AtomLayout<'a> { let mut response = ui.interact(rect, id, sense); response.intrinsic_size = - Some((Vec2::new(preferred_width, preferred_height) + margin.sum()).at_least(min_size)); + Some((Vec2::new(intrinsic_width, intrinsic_height) + margin.sum()).at_least(min_size)); AllocatedAtomLayout { sized_atoms: sized_items, diff --git a/crates/egui/src/atomics/sized_atom.rs b/crates/egui/src/atomics/sized_atom.rs index 50fa443a9..f1ae0f81b 100644 --- a/crates/egui/src/atomics/sized_atom.rs +++ b/crates/egui/src/atomics/sized_atom.rs @@ -12,8 +12,8 @@ pub struct SizedAtom<'a> { /// size.x + gap. pub size: Vec2, - /// Preferred size of the atom. This is used to calculate `Response::intrinsic_size`. - pub preferred_size: Vec2, + /// Intrinsic size of the atom. This is used to calculate `Response::intrinsic_size`. + pub intrinsic_size: Vec2, pub kind: SizedAtomKind<'a>, } diff --git a/crates/epaint/src/shapes/text_shape.rs b/crates/epaint/src/shapes/text_shape.rs index b366c86cf..9505dc49b 100644 --- a/crates/epaint/src/shapes/text_shape.rs +++ b/crates/epaint/src/shapes/text_shape.rs @@ -130,10 +130,12 @@ impl TextShape { num_vertices: _, num_indices: _, pixels_per_point: _, + intrinsic_size, } = Arc::make_mut(galley); *rect = transform.scaling * *rect; *mesh_bounds = transform.scaling * *mesh_bounds; + *intrinsic_size = transform.scaling * *intrinsic_size; for text::PlacedRow { pos, row } in rows { *pos *= transform.scaling; diff --git a/crates/epaint/src/text/fonts.rs b/crates/epaint/src/text/fonts.rs index 5f9006915..a08f3206e 100644 --- a/crates/epaint/src/text/fonts.rs +++ b/crates/epaint/src/text/fonts.rs @@ -1072,6 +1072,7 @@ mod tests { use core::f32; use super::*; + use crate::text::{TextWrapping, layout}; use crate::{Stroke, text::TextFormat}; use ecolor::Color32; use emath::Align; @@ -1183,4 +1184,60 @@ mod tests { } } } + + #[test] + fn test_intrinsic_size() { + let pixels_per_point = [1.0, 1.3, 2.0, 0.867]; + let max_widths = [40.0, 80.0, 133.0, 200.0]; + let rounded_output_to_gui = [false, true]; + + for pixels_per_point in pixels_per_point { + let mut fonts = FontsImpl::new( + pixels_per_point, + 1024, + AlphaFromCoverage::default(), + FontDefinitions::default(), + ); + + for &max_width in &max_widths { + for round_output_to_gui in rounded_output_to_gui { + for mut job in jobs() { + job.wrap = TextWrapping::wrap_at_width(max_width); + + job.round_output_to_gui = round_output_to_gui; + + let galley_wrapped = layout(&mut fonts, job.clone().into()); + + job.wrap = TextWrapping::no_max_width(); + + let text = job.text.clone(); + let galley_unwrapped = layout(&mut fonts, job.into()); + + let intrinsic_size = galley_wrapped.intrinsic_size; + let unwrapped_size = galley_unwrapped.size(); + + let difference = (intrinsic_size - unwrapped_size).length().abs(); + similar_asserts::assert_eq!( + format!("{intrinsic_size:.4?}"), + format!("{unwrapped_size:.4?}"), + "Wrapped intrinsic size should almost match unwrapped size. Intrinsic: {intrinsic_size:.8?} vs unwrapped: {unwrapped_size:.8?} + Difference: {difference:.8?} + wrapped rows: {}, unwrapped rows: {} + pixels_per_point: {pixels_per_point}, text: {text:?}, max_width: {max_width}, round_output_to_gui: {round_output_to_gui}", + galley_wrapped.rows.len(), + galley_unwrapped.rows.len() + ); + similar_asserts::assert_eq!( + format!("{intrinsic_size:.4?}"), + format!("{unwrapped_size:.4?}"), + "Unwrapped galley intrinsic size should exactly match its size. \ + {:.8?} vs {:8?}", + galley_unwrapped.intrinsic_size, + galley_unwrapped.size(), + ); + } + } + } + } + } } diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index 7915bbf61..6dc0aa03f 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -82,6 +82,7 @@ pub fn layout(fonts: &mut FontsImpl, job: Arc) -> Galley { num_indices: 0, pixels_per_point: fonts.pixels_per_point(), elided: true, + intrinsic_size: Vec2::ZERO, }; } @@ -94,6 +95,8 @@ pub fn layout(fonts: &mut FontsImpl, job: Arc) -> Galley { let point_scale = PointScale::new(fonts.pixels_per_point()); + let intrinsic_size = calculate_intrinsic_size(point_scale, &job, ¶graphs); + let mut elided = false; let mut rows = rows_from_paragraphs(paragraphs, &job, &mut elided); if elided { @@ -124,7 +127,7 @@ pub fn layout(fonts: &mut FontsImpl, job: Arc) -> Galley { } // Calculate the Y positions and tessellate the text: - galley_from_rows(point_scale, job, rows, elided) + galley_from_rows(point_scale, job, rows, elided, intrinsic_size) } // Ignores the Y coordinate. @@ -190,6 +193,46 @@ fn layout_section( } } +/// Calculate the intrinsic size of the text. +/// +/// The result is eventually passed to `Response::intrinsic_size`. +/// This works by calculating the size of each `Paragraph` (instead of each `Row`). +fn calculate_intrinsic_size( + point_scale: PointScale, + job: &LayoutJob, + paragraphs: &[Paragraph], +) -> Vec2 { + let mut intrinsic_size = Vec2::ZERO; + for (idx, paragraph) in paragraphs.iter().enumerate() { + if paragraph.glyphs.is_empty() { + if idx == 0 { + intrinsic_size.y += point_scale.round_to_pixel(paragraph.empty_paragraph_height); + } + continue; + } + intrinsic_size.x = f32::max( + paragraph + .glyphs + .last() + .map(|l| l.max_x()) + .unwrap_or_default(), + intrinsic_size.x, + ); + + let mut height = paragraph + .glyphs + .iter() + .map(|g| g.line_height) + .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .unwrap_or(paragraph.empty_paragraph_height); + if idx == 0 { + height = f32::max(height, job.first_row_min_height); + } + intrinsic_size.y += point_scale.round_to_pixel(height); + } + intrinsic_size +} + // Ignores the Y coordinate. fn rows_from_paragraphs( paragraphs: Vec, @@ -610,6 +653,7 @@ fn galley_from_rows( job: Arc, mut rows: Vec, elided: bool, + intrinsic_size: Vec2, ) -> Galley { let mut first_row_min_height = job.first_row_min_height; let mut cursor_y = 0.0; @@ -680,6 +724,7 @@ fn galley_from_rows( num_vertices, num_indices, pixels_per_point: point_scale.pixels_per_point, + intrinsic_size, }; if galley.job.round_output_to_gui { diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index 016bfe104..36d92479e 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -560,6 +560,12 @@ pub struct Galley { /// so that we can warn if this has changed once we get to /// tessellation. pub pixels_per_point: f32, + + /// This is the size that a non-wrapped, non-truncated, non-justified version of the text + /// would have. + /// + /// Useful for advanced layouting. + pub intrinsic_size: Vec2, } #[derive(Clone, Debug, PartialEq)] @@ -821,6 +827,8 @@ impl Galley { .at_most(rect.min.x + self.job.wrap.max_width) .floor_ui(); } + + self.intrinsic_size = self.intrinsic_size.round_ui(); } /// Append each galley under the previous one. @@ -836,6 +844,7 @@ impl Galley { num_vertices: 0, num_indices: 0, pixels_per_point, + intrinsic_size: Vec2::ZERO, }; for (i, galley) in galleys.iter().enumerate() { @@ -872,6 +881,9 @@ impl Galley { // Note that if `galley.elided` is true this will be the last `Galley` in // the vector and the loop will end. merged_galley.elided |= galley.elided; + merged_galley.intrinsic_size.x = + f32::max(merged_galley.intrinsic_size.x, galley.intrinsic_size.x); + merged_galley.intrinsic_size.y += galley.intrinsic_size.y; } if merged_galley.job.round_output_to_gui { diff --git a/tests/egui_tests/tests/test_atoms.rs b/tests/egui_tests/tests/test_atoms.rs index abc9f2d05..98e90c1c5 100644 --- a/tests/egui_tests/tests/test_atoms.rs +++ b/tests/egui_tests/tests/test_atoms.rs @@ -69,3 +69,35 @@ fn single_test(name: &str, mut f: impl FnMut(&mut Ui)) -> SnapshotResult { harness.try_snapshot(name) } + +#[test] +fn test_intrinsic_size() { + let mut intrinsic_size = None; + for wrapping in [ + TextWrapMode::Extend, + TextWrapMode::Wrap, + TextWrapMode::Truncate, + ] { + _ = HarnessBuilder::default() + .with_size(Vec2::new(100.0, 100.0)) + .build_ui(|ui| { + ui.style_mut().wrap_mode = Some(wrapping); + let response = ui.add(Button::new( + "Hello world this is a long text that should be wrapped.", + )); + if let Some(current_intrinsic_size) = intrinsic_size { + assert_eq!( + Some(current_intrinsic_size), + response.intrinsic_size, + "For wrapping: {wrapping:?}" + ); + } + assert!( + response.intrinsic_size.is_some(), + "intrinsic_size should be set for `Button`" + ); + intrinsic_size = response.intrinsic_size; + }); + } + assert_eq!(intrinsic_size.unwrap().round(), Vec2::new(305.0, 18.0)); +} From fbe0aadf63ddc40634a4d26918a428d15e621422 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Wed, 9 Jul 2025 10:12:47 +0200 Subject: [PATCH 19/19] Add `Popup::from_toggle_button_response` (#7315) Adds a convenience constructor for `Popup` --- crates/egui/src/containers/popup.rs | 94 ++++++++++++++++------------- 1 file changed, 51 insertions(+), 43 deletions(-) diff --git a/crates/egui/src/containers/popup.rs b/crates/egui/src/containers/popup.rs index b16b61648..13b095d35 100644 --- a/crates/egui/src/containers/popup.rs +++ b/crates/egui/src/containers/popup.rs @@ -211,6 +211,57 @@ impl<'a> Popup<'a> { } } + /// 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 relative to some widget, + /// toggling the open state based on the widget's click state. + /// + /// See [`Self::menu`] and [`Self::context_menu`] for common use cases. + pub fn from_toggle_button_response(button_response: &Response) -> Self { + Self::from_response(button_response) + .open_memory(button_response.clicked().then_some(SetOpenCommand::Toggle)) + } + + /// Show a popup when the widget was clicked. + /// Sets the layout to `Layout::top_down_justified(Align::Min)`. + pub fn menu(button_response: &Response) -> Self { + Self::from_toggle_button_response(button_response) + .kind(PopupKind::Menu) + .layout(Layout::top_down_justified(Align::Min)) + .style(menu_style) + .gap(0.0) + } + + /// 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::menu(response) + .open_memory(if response.secondary_clicked() { + Some(SetOpenCommand::Bool(true)) + } else if response.clicked() { + // Explicitly close the menu if the widget was clicked + // Without this, the context menu would stay open if the user clicks the widget + Some(SetOpenCommand::Bool(false)) + } else { + None + }) + .at_pointer_fixed() + } + /// Set the kind of the popup. Used for [`Area::kind`] and [`Area::order`]. #[inline] pub fn kind(mut self, kind: PopupKind) -> Self { @@ -243,49 +294,6 @@ impl<'a> Popup<'a> { 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(response.clicked().then_some(SetOpenCommand::Toggle)) - .kind(PopupKind::Menu) - .layout(Layout::top_down_justified(Align::Min)) - .style(menu_style) - .gap(0.0) - } - - /// 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::menu(response) - .open_memory(if response.secondary_clicked() { - Some(SetOpenCommand::Bool(true)) - } else if response.clicked() { - // Explicitly close the menu if the widget was clicked - // Without this, the context menu would stay open if the user clicks the widget - Some(SetOpenCommand::Bool(false)) - } else { - None - }) - .at_pointer_fixed() - } - /// Force the popup to be open or closed. #[inline] pub fn open(mut self, open: bool) -> Self {