From 249f8bcb9361495fe9245919aa1b16bca424b52b Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Thu, 2 Jan 2025 14:29:50 +0100 Subject: [PATCH 1/9] Use `u8` in `Rounding`, and introduce `Roundingf` (#5563) * Part of https://github.com/emilk/egui/issues/4019 As part of the work on adding a custom `Border` to everything, I want to make sure that the size of `RectShape`, `Frame` and the future `Border` is kept small (for performance reasons). This PR changes the storage of the corner radius of rectangles from four `f32` (one for each corner) into four `u8`. This mean the corner radius can only be an integer in the range 0-255 (in ui points). This should be enough for most people. If you want to manipulate rounding using `f32`, there is a new `Roundingf` to fill that niche. --- CHANGELOG.md | 2 +- crates/egui/src/containers/window.rs | 16 +- crates/egui/src/style.rs | 37 ++-- crates/egui/src/widgets/color_picker.rs | 2 +- crates/egui/src/widgets/progress_bar.rs | 3 +- crates/egui/src/widgets/slider.rs | 4 +- crates/egui_demo_lib/src/demo/pan_zoom.rs | 2 +- crates/epaint/src/lib.rs | 8 +- crates/epaint/src/rounding.rs | 220 ++++++++++++++++++++ crates/epaint/src/roundingf.rs | 236 ++++++++++++++++++++++ crates/epaint/src/shadow.rs | 2 +- crates/epaint/src/shape.rs | 210 +------------------ crates/epaint/src/tessellator.rs | 8 +- 13 files changed, 508 insertions(+), 242 deletions(-) create mode 100644 crates/epaint/src/rounding.rs create mode 100644 crates/epaint/src/roundingf.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4642da637..b6733405c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1230,7 +1230,7 @@ egui_extras::install_image_loaders(egui_ctx); * [Tweaked the default visuals style](https://github.com/emilk/egui/pull/450). * Plot: Renamed `Curve` to `Line`. * `TopPanel::top` is now `TopBottomPanel::top`. -* `SidePanel::left` no longet takes the default width by argument, but by a builder call. +* `SidePanel::left` no longer takes the default width by argument, but by a builder call. * `SidePanel::left` is resizable by default. ### 🐛 Fixed diff --git a/crates/egui/src/containers/window.rs b/crates/egui/src/containers/window.rs index c48f60099..f1c67300b 100644 --- a/crates/egui/src/containers/window.rs +++ b/crates/egui/src/containers/window.rs @@ -8,7 +8,9 @@ use crate::{ TextStyle, Ui, UiKind, Vec2b, WidgetInfo, WidgetRect, WidgetText, WidgetType, }; use emath::GuiRounding as _; -use epaint::{emath, pos2, vec2, Galley, Pos2, Rect, RectShape, Rounding, Shape, Stroke, Vec2}; +use epaint::{ + emath, pos2, vec2, Galley, Pos2, Rect, RectShape, Rounding, Roundingf, Shape, Stroke, Vec2, +}; use super::scroll_area::ScrollBarVisibility; use super::{area, resize, Area, Frame, Resize, ScrollArea}; @@ -486,8 +488,9 @@ impl<'open> Window<'open> { let style = ctx.style(); let spacing = window_margin.top + window_margin.bottom; let height = ctx.fonts(|f| title.font_height(f, &style)) + spacing; - window_frame.rounding.ne = window_frame.rounding.ne.clamp(0.0, height / 2.0); - window_frame.rounding.nw = window_frame.rounding.nw.clamp(0.0, height / 2.0); + let half_height = (height / 2.0).round() as _; + window_frame.rounding.ne = window_frame.rounding.ne.clamp(0, half_height); + window_frame.rounding.nw = window_frame.rounding.nw.clamp(0, half_height); (height, spacing) } else { (0.0, 0.0) @@ -603,8 +606,8 @@ impl<'open> Window<'open> { let mut round = window_frame.rounding; if !is_collapsed { - round.se = 0.0; - round.sw = 0.0; + round.se = 0; + round.sw = 0; } area_content_ui.painter().set( @@ -682,6 +685,7 @@ fn paint_resize_corner( }; // Adjust the corner offset to accommodate for window rounding + let radius = radius as f32; let offset = ((2.0_f32.sqrt() * (1.0 + radius) - radius) * 45.0_f32.to_radians().cos()).max(2.0); @@ -1022,7 +1026,7 @@ fn paint_frame_interaction(ui: &Ui, rect: Rect, interaction: ResizeInteraction) bottom = interaction.bottom.hover; } - let rounding = ui.visuals().window_rounding; + let rounding = Roundingf::from(ui.visuals().window_rounding); let Rect { min, max } = rect; let mut points = Vec::new(); diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index 33ab21a68..af35972c4 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -1291,7 +1291,7 @@ impl Visuals { warn_fg_color: Color32::from_rgb(255, 143, 0), // orange error_fg_color: Color32::from_rgb(255, 0, 0), // red - window_rounding: Rounding::same(6.0), + window_rounding: Rounding::same(6), window_shadow: Shadow { offset: vec2(10.0, 20.0), blur: 15.0, @@ -1302,7 +1302,7 @@ impl Visuals { window_stroke: Stroke::new(1.0, Color32::from_gray(60)), window_highlight_topmost: true, - menu_rounding: Rounding::same(6.0), + menu_rounding: Rounding::same(6), panel_fill: Color32::from_gray(27), @@ -1412,7 +1412,7 @@ impl Widgets { bg_fill: Color32::from_gray(27), bg_stroke: Stroke::new(1.0, Color32::from_gray(60)), // separators, indentation lines fg_stroke: Stroke::new(1.0, Color32::from_gray(140)), // normal text color - rounding: Rounding::same(2.0), + rounding: Rounding::same(2), expansion: 0.0, }, inactive: WidgetVisuals { @@ -1420,7 +1420,7 @@ impl Widgets { bg_fill: Color32::from_gray(60), // checkbox background bg_stroke: Default::default(), fg_stroke: Stroke::new(1.0, Color32::from_gray(180)), // button text - rounding: Rounding::same(2.0), + rounding: Rounding::same(2), expansion: 0.0, }, hovered: WidgetVisuals { @@ -1428,7 +1428,7 @@ impl Widgets { bg_fill: Color32::from_gray(70), bg_stroke: Stroke::new(1.0, Color32::from_gray(150)), // e.g. hover over window edge or button fg_stroke: Stroke::new(1.5, Color32::from_gray(240)), - rounding: Rounding::same(3.0), + rounding: Rounding::same(3), expansion: 1.0, }, active: WidgetVisuals { @@ -1436,7 +1436,7 @@ impl Widgets { bg_fill: Color32::from_gray(55), bg_stroke: Stroke::new(1.0, Color32::WHITE), fg_stroke: Stroke::new(2.0, Color32::WHITE), - rounding: Rounding::same(2.0), + rounding: Rounding::same(2), expansion: 1.0, }, open: WidgetVisuals { @@ -1444,7 +1444,7 @@ impl Widgets { bg_fill: Color32::from_gray(27), bg_stroke: Stroke::new(1.0, Color32::from_gray(60)), fg_stroke: Stroke::new(1.0, Color32::from_gray(210)), - rounding: Rounding::same(2.0), + rounding: Rounding::same(2), expansion: 0.0, }, } @@ -1457,7 +1457,7 @@ impl Widgets { bg_fill: Color32::from_gray(248), bg_stroke: Stroke::new(1.0, Color32::from_gray(190)), // separators, indentation lines fg_stroke: Stroke::new(1.0, Color32::from_gray(80)), // normal text color - rounding: Rounding::same(2.0), + rounding: Rounding::same(2), expansion: 0.0, }, inactive: WidgetVisuals { @@ -1465,7 +1465,7 @@ impl Widgets { bg_fill: Color32::from_gray(230), // checkbox background bg_stroke: Default::default(), fg_stroke: Stroke::new(1.0, Color32::from_gray(60)), // button text - rounding: Rounding::same(2.0), + rounding: Rounding::same(2), expansion: 0.0, }, hovered: WidgetVisuals { @@ -1473,7 +1473,7 @@ impl Widgets { bg_fill: Color32::from_gray(220), bg_stroke: Stroke::new(1.0, Color32::from_gray(105)), // e.g. hover over window edge or button fg_stroke: Stroke::new(1.5, Color32::BLACK), - rounding: Rounding::same(3.0), + rounding: Rounding::same(3), expansion: 1.0, }, active: WidgetVisuals { @@ -1481,7 +1481,7 @@ impl Widgets { bg_fill: Color32::from_gray(165), bg_stroke: Stroke::new(1.0, Color32::BLACK), fg_stroke: Stroke::new(2.0, Color32::BLACK), - rounding: Rounding::same(2.0), + rounding: Rounding::same(2), expansion: 1.0, }, open: WidgetVisuals { @@ -1489,7 +1489,7 @@ impl Widgets { bg_fill: Color32::from_gray(220), bg_stroke: Stroke::new(1.0, Color32::from_gray(160)), fg_stroke: Stroke::new(1.0, Color32::BLACK), - rounding: Rounding::same(2.0), + rounding: Rounding::same(2), expansion: 0.0, }, } @@ -2420,9 +2420,16 @@ impl Widget for &mut Rounding { // Apply the checkbox: if same { - *self = Rounding::same((self.nw + self.ne + self.sw + self.se) / 4.0); - } else if self.is_same() { - self.se *= 1.00001; // prevent collapsing into sameness + *self = Rounding::from(self.average()); + } else { + // Make sure we aren't same: + if self.is_same() { + if self.average() == 0.0 { + self.se = 1; + } else { + self.se -= 1; + } + } } response diff --git a/crates/egui/src/widgets/color_picker.rs b/crates/egui/src/widgets/color_picker.rs index 9a6059059..ea1b53aef 100644 --- a/crates/egui/src/widgets/color_picker.rs +++ b/crates/egui/src/widgets/color_picker.rs @@ -99,7 +99,7 @@ fn color_button(ui: &mut Ui, color: Color32, open: bool) -> Response { show_color_at(ui.painter(), color, rect); - let rounding = visuals.rounding.at_most(2.0); // Can't do more rounding because the background grid doesn't do any rounding + let rounding = visuals.rounding.at_most(2); // Can't do more rounding because the background grid doesn't do any rounding ui.painter() .rect_stroke(rect, rounding, (2.0, visuals.bg_fill)); // fill is intentional, because default style has no border } diff --git a/crates/egui/src/widgets/progress_bar.rs b/crates/egui/src/widgets/progress_bar.rs index 5e099f6a6..7f926d02b 100644 --- a/crates/egui/src/widgets/progress_bar.rs +++ b/crates/egui/src/widgets/progress_bar.rs @@ -138,7 +138,8 @@ impl Widget for ProgressBar { let rounding = rounding.unwrap_or_else(|| corner_radius.into()); ui.painter() .rect(outer_rect, rounding, visuals.extreme_bg_color, Stroke::NONE); - let min_width = 2.0 * rounding.sw.at_least(rounding.nw).at_most(corner_radius); + let min_width = + 2.0 * f32::max(rounding.sw as _, rounding.nw as _).at_most(corner_radius); let filled_width = (outer_rect.width() * progress).at_least(min_width); let inner_rect = Rect::from_min_size(outer_rect.min, vec2(filled_width, outer_rect.height())); diff --git a/crates/egui/src/widgets/slider.rs b/crates/egui/src/widgets/slider.rs index 7dc3a5cd5..71f4c8499 100644 --- a/crates/egui/src/widgets/slider.rs +++ b/crates/egui/src/widgets/slider.rs @@ -780,10 +780,10 @@ impl<'a> Slider<'a> { // The trailing rect has to be drawn differently depending on the orientation. match self.orientation { SliderOrientation::Horizontal => { - trailing_rail_rect.max.x = center.x + rounding.nw; + trailing_rail_rect.max.x = center.x + rounding.nw as f32; } SliderOrientation::Vertical => { - trailing_rail_rect.min.y = center.y - rounding.se; + trailing_rail_rect.min.y = center.y - rounding.se as f32; } }; diff --git a/crates/egui_demo_lib/src/demo/pan_zoom.rs b/crates/egui_demo_lib/src/demo/pan_zoom.rs index f8411a740..421dc521a 100644 --- a/crates/egui_demo_lib/src/demo/pan_zoom.rs +++ b/crates/egui_demo_lib/src/demo/pan_zoom.rs @@ -127,7 +127,7 @@ impl crate::View for PanZoom { .show(ui.ctx(), |ui| { ui.set_clip_rect(transform.inverse() * rect); egui::Frame::default() - .rounding(egui::Rounding::same(4.0)) + .rounding(egui::Rounding::same(4)) .inner_margin(egui::Margin::same(8.0)) .stroke(ui.ctx().style().visuals.window_stroke) .fill(ui.style().visuals.panel_fill) diff --git a/crates/epaint/src/lib.rs b/crates/epaint/src/lib.rs index b769275cc..1cc9e6c96 100644 --- a/crates/epaint/src/lib.rs +++ b/crates/epaint/src/lib.rs @@ -29,6 +29,8 @@ pub mod image; mod margin; mod mesh; pub mod mutex; +mod rounding; +mod roundingf; mod shadow; mod shape; pub mod shape_transform; @@ -47,10 +49,12 @@ pub use self::{ image::{ColorImage, FontImage, ImageData, ImageDelta}, margin::Margin, mesh::{Mesh, Mesh16, Vertex}, + rounding::Rounding, + roundingf::Roundingf, shadow::Shadow, shape::{ - CircleShape, EllipseShape, PaintCallback, PaintCallbackInfo, PathShape, RectShape, - Rounding, Shape, TextShape, + CircleShape, EllipseShape, PaintCallback, PaintCallbackInfo, PathShape, RectShape, Shape, + TextShape, }, stats::PaintStats, stroke::{PathStroke, Stroke, StrokeKind}, diff --git a/crates/epaint/src/rounding.rs b/crates/epaint/src/rounding.rs new file mode 100644 index 000000000..12695f387 --- /dev/null +++ b/crates/epaint/src/rounding.rs @@ -0,0 +1,220 @@ +/// How rounded the corners of things should be. +/// +/// The rounding uses `u8` to save space, +/// so the amount of rounding is limited to integers in the range `[0, 255]`. +/// +/// For calculations, you may want to use [`crate::Roundingf`] instead, which uses `f32`. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Rounding { + /// Radius of the rounding of the North-West (left top) corner. + pub nw: u8, + + /// Radius of the rounding of the North-East (right top) corner. + pub ne: u8, + + /// Radius of the rounding of the South-West (left bottom) corner. + pub sw: u8, + + /// Radius of the rounding of the South-East (right bottom) corner. + pub se: u8, +} + +impl Default for Rounding { + #[inline] + fn default() -> Self { + Self::ZERO + } +} + +impl From for Rounding { + #[inline] + fn from(radius: u8) -> Self { + Self::same(radius) + } +} + +impl From for Rounding { + #[inline] + fn from(radius: f32) -> Self { + Self::same(radius.round() as u8) + } +} + +impl Rounding { + /// No rounding on any corner. + pub const ZERO: Self = Self { + nw: 0, + ne: 0, + sw: 0, + se: 0, + }; + + /// Same rounding on all four corners. + #[inline] + pub const fn same(radius: u8) -> Self { + Self { + nw: radius, + ne: radius, + sw: radius, + se: radius, + } + } + + /// Do all corners have the same rounding? + #[inline] + pub fn is_same(self) -> bool { + self.nw == self.ne && self.nw == self.sw && self.nw == self.se + } + + /// Make sure each corner has a rounding of at least this. + #[inline] + pub fn at_least(self, min: u8) -> Self { + Self { + nw: self.nw.max(min), + ne: self.ne.max(min), + sw: self.sw.max(min), + se: self.se.max(min), + } + } + + /// Make sure each corner has a rounding of at most this. + #[inline] + pub fn at_most(self, max: u8) -> Self { + Self { + nw: self.nw.min(max), + ne: self.ne.min(max), + sw: self.sw.min(max), + se: self.se.min(max), + } + } + + /// Average rounding of the corners. + pub fn average(&self) -> f32 { + (self.nw as f32 + self.ne as f32 + self.sw as f32 + self.se as f32) / 4.0 + } +} + +impl std::ops::Add for Rounding { + type Output = Self; + #[inline] + fn add(self, rhs: Self) -> Self { + Self { + nw: self.nw + rhs.nw, + ne: self.ne + rhs.ne, + sw: self.sw + rhs.sw, + se: self.se + rhs.se, + } + } +} + +impl std::ops::AddAssign for Rounding { + #[inline] + fn add_assign(&mut self, rhs: Self) { + *self = Self { + nw: self.nw + rhs.nw, + ne: self.ne + rhs.ne, + sw: self.sw + rhs.sw, + se: self.se + rhs.se, + }; + } +} + +impl std::ops::AddAssign for Rounding { + #[inline] + fn add_assign(&mut self, rhs: u8) { + *self = Self { + nw: self.nw.saturating_add(rhs), + ne: self.ne.saturating_add(rhs), + sw: self.sw.saturating_add(rhs), + se: self.se.saturating_add(rhs), + }; + } +} + +impl std::ops::Sub for Rounding { + type Output = Self; + #[inline] + fn sub(self, rhs: Self) -> Self { + Self { + nw: self.nw.saturating_sub(rhs.nw), + ne: self.ne.saturating_sub(rhs.ne), + sw: self.sw.saturating_sub(rhs.sw), + se: self.se.saturating_sub(rhs.se), + } + } +} + +impl std::ops::SubAssign for Rounding { + #[inline] + fn sub_assign(&mut self, rhs: Self) { + *self = Self { + nw: self.nw.saturating_sub(rhs.nw), + ne: self.ne.saturating_sub(rhs.ne), + sw: self.sw.saturating_sub(rhs.sw), + se: self.se.saturating_sub(rhs.se), + }; + } +} + +impl std::ops::SubAssign for Rounding { + #[inline] + fn sub_assign(&mut self, rhs: u8) { + *self = Self { + nw: self.nw.saturating_sub(rhs), + ne: self.ne.saturating_sub(rhs), + sw: self.sw.saturating_sub(rhs), + se: self.se.saturating_sub(rhs), + }; + } +} + +impl std::ops::Div for Rounding { + type Output = Self; + #[inline] + fn div(self, rhs: f32) -> Self { + Self { + nw: (self.nw as f32 / rhs) as u8, + ne: (self.ne as f32 / rhs) as u8, + sw: (self.sw as f32 / rhs) as u8, + se: (self.se as f32 / rhs) as u8, + } + } +} + +impl std::ops::DivAssign for Rounding { + #[inline] + fn div_assign(&mut self, rhs: f32) { + *self = Self { + nw: (self.nw as f32 / rhs) as u8, + ne: (self.ne as f32 / rhs) as u8, + sw: (self.sw as f32 / rhs) as u8, + se: (self.se as f32 / rhs) as u8, + }; + } +} + +impl std::ops::Mul for Rounding { + type Output = Self; + #[inline] + fn mul(self, rhs: f32) -> Self { + Self { + nw: (self.nw as f32 * rhs) as u8, + ne: (self.ne as f32 * rhs) as u8, + sw: (self.sw as f32 * rhs) as u8, + se: (self.se as f32 * rhs) as u8, + } + } +} + +impl std::ops::MulAssign for Rounding { + #[inline] + fn mul_assign(&mut self, rhs: f32) { + *self = Self { + nw: (self.nw as f32 * rhs) as u8, + ne: (self.ne as f32 * rhs) as u8, + sw: (self.sw as f32 * rhs) as u8, + se: (self.se as f32 * rhs) as u8, + }; + } +} diff --git a/crates/epaint/src/roundingf.rs b/crates/epaint/src/roundingf.rs new file mode 100644 index 000000000..b49cbc77f --- /dev/null +++ b/crates/epaint/src/roundingf.rs @@ -0,0 +1,236 @@ +use crate::Rounding; + +/// How rounded the corners of things should be, in `f32`. +/// +/// This is used for calculations, but storage is usually done with the more compact [`Rounding`]. +#[derive(Copy, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Roundingf { + /// Radius of the rounding of the North-West (left top) corner. + pub nw: f32, + + /// Radius of the rounding of the North-East (right top) corner. + pub ne: f32, + + /// Radius of the rounding of the South-West (left bottom) corner. + pub sw: f32, + + /// Radius of the rounding of the South-East (right bottom) corner. + pub se: f32, +} + +impl From for Roundingf { + #[inline] + fn from(rounding: Rounding) -> Self { + Self { + nw: rounding.nw as f32, + ne: rounding.ne as f32, + sw: rounding.sw as f32, + se: rounding.se as f32, + } + } +} + +impl From for Rounding { + #[inline] + fn from(rounding: Roundingf) -> Self { + Self { + nw: rounding.nw.round() as u8, + ne: rounding.ne.round() as u8, + sw: rounding.sw.round() as u8, + se: rounding.se.round() as u8, + } + } +} + +impl Default for Roundingf { + #[inline] + fn default() -> Self { + Self::ZERO + } +} + +impl From for Roundingf { + #[inline] + fn from(radius: f32) -> Self { + Self { + nw: radius, + ne: radius, + sw: radius, + se: radius, + } + } +} + +impl Roundingf { + /// No rounding on any corner. + pub const ZERO: Self = Self { + nw: 0.0, + ne: 0.0, + sw: 0.0, + se: 0.0, + }; + + /// Same rounding on all four corners. + #[inline] + pub const fn same(radius: f32) -> Self { + Self { + nw: radius, + ne: radius, + sw: radius, + se: radius, + } + } + + /// Do all corners have the same rounding? + #[inline] + pub fn is_same(&self) -> bool { + self.nw == self.ne && self.nw == self.sw && self.nw == self.se + } + + /// Make sure each corner has a rounding of at least this. + #[inline] + pub fn at_least(&self, min: f32) -> Self { + Self { + nw: self.nw.max(min), + ne: self.ne.max(min), + sw: self.sw.max(min), + se: self.se.max(min), + } + } + + /// Make sure each corner has a rounding of at most this. + #[inline] + pub fn at_most(&self, max: f32) -> Self { + Self { + nw: self.nw.min(max), + ne: self.ne.min(max), + sw: self.sw.min(max), + se: self.se.min(max), + } + } +} + +impl std::ops::Add for Roundingf { + type Output = Self; + #[inline] + fn add(self, rhs: Self) -> Self { + Self { + nw: self.nw + rhs.nw, + ne: self.ne + rhs.ne, + sw: self.sw + rhs.sw, + se: self.se + rhs.se, + } + } +} + +impl std::ops::AddAssign for Roundingf { + #[inline] + fn add_assign(&mut self, rhs: Self) { + *self = Self { + nw: self.nw + rhs.nw, + ne: self.ne + rhs.ne, + sw: self.sw + rhs.sw, + se: self.se + rhs.se, + }; + } +} + +impl std::ops::AddAssign for Roundingf { + #[inline] + fn add_assign(&mut self, rhs: f32) { + *self = Self { + nw: self.nw + rhs, + ne: self.ne + rhs, + sw: self.sw + rhs, + se: self.se + rhs, + }; + } +} + +impl std::ops::Sub for Roundingf { + type Output = Self; + #[inline] + fn sub(self, rhs: Self) -> Self { + Self { + nw: self.nw - rhs.nw, + ne: self.ne - rhs.ne, + sw: self.sw - rhs.sw, + se: self.se - rhs.se, + } + } +} + +impl std::ops::SubAssign for Roundingf { + #[inline] + fn sub_assign(&mut self, rhs: Self) { + *self = Self { + nw: self.nw - rhs.nw, + ne: self.ne - rhs.ne, + sw: self.sw - rhs.sw, + se: self.se - rhs.se, + }; + } +} + +impl std::ops::SubAssign for Roundingf { + #[inline] + fn sub_assign(&mut self, rhs: f32) { + *self = Self { + nw: self.nw - rhs, + ne: self.ne - rhs, + sw: self.sw - rhs, + se: self.se - rhs, + }; + } +} + +impl std::ops::Div for Roundingf { + type Output = Self; + #[inline] + fn div(self, rhs: f32) -> Self { + Self { + nw: self.nw / rhs, + ne: self.ne / rhs, + sw: self.sw / rhs, + se: self.se / rhs, + } + } +} + +impl std::ops::DivAssign for Roundingf { + #[inline] + fn div_assign(&mut self, rhs: f32) { + *self = Self { + nw: self.nw / rhs, + ne: self.ne / rhs, + sw: self.sw / rhs, + se: self.se / rhs, + }; + } +} + +impl std::ops::Mul for Roundingf { + type Output = Self; + #[inline] + fn mul(self, rhs: f32) -> Self { + Self { + nw: self.nw * rhs, + ne: self.ne * rhs, + sw: self.sw * rhs, + se: self.se * rhs, + } + } +} + +impl std::ops::MulAssign for Roundingf { + #[inline] + fn mul_assign(&mut self, rhs: f32) { + *self = Self { + nw: self.nw * rhs, + ne: self.ne * rhs, + sw: self.sw * rhs, + se: self.se * rhs, + }; + } +} diff --git a/crates/epaint/src/shadow.rs b/crates/epaint/src/shadow.rs index bb386d21d..5a68e45ba 100644 --- a/crates/epaint/src/shadow.rs +++ b/crates/epaint/src/shadow.rs @@ -47,7 +47,7 @@ impl Shadow { } = *self; let rect = rect.translate(offset).expand(spread); - let rounding = rounding.into() + Rounding::same(spread.abs()); + let rounding = rounding.into() + Rounding::from(spread.abs()); RectShape::filled(rect, rounding, color).with_blur_width(blur) } diff --git a/crates/epaint/src/shape.rs b/crates/epaint/src/shape.rs index 56af703b6..1fc393c72 100644 --- a/crates/epaint/src/shape.rs +++ b/crates/epaint/src/shape.rs @@ -5,7 +5,7 @@ use std::{any::Any, sync::Arc}; use crate::{ stroke::PathStroke, text::{FontId, Fonts, Galley}, - Color32, Mesh, Stroke, TextureId, + Color32, Mesh, Rounding, Stroke, TextureId, }; use emath::{pos2, Align2, Pos2, Rangef, Rect, TSTransform, Vec2}; @@ -779,214 +779,6 @@ impl From for Shape { } } -#[derive(Copy, Clone, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -/// How rounded the corners of things should be -pub struct Rounding { - /// Radius of the rounding of the North-West (left top) corner. - pub nw: f32, - - /// Radius of the rounding of the North-East (right top) corner. - pub ne: f32, - - /// Radius of the rounding of the South-West (left bottom) corner. - pub sw: f32, - - /// Radius of the rounding of the South-East (right bottom) corner. - pub se: f32, -} - -impl Default for Rounding { - #[inline] - fn default() -> Self { - Self::ZERO - } -} - -impl From for Rounding { - #[inline] - fn from(radius: f32) -> Self { - Self { - nw: radius, - ne: radius, - sw: radius, - se: radius, - } - } -} - -impl Rounding { - /// No rounding on any corner. - pub const ZERO: Self = Self { - nw: 0.0, - ne: 0.0, - sw: 0.0, - se: 0.0, - }; - - #[inline] - pub const fn same(radius: f32) -> Self { - Self { - nw: radius, - ne: radius, - sw: radius, - se: radius, - } - } - - /// Do all corners have the same rounding? - #[inline] - pub fn is_same(&self) -> bool { - self.nw == self.ne && self.nw == self.sw && self.nw == self.se - } - - /// Make sure each corner has a rounding of at least this. - #[inline] - pub fn at_least(&self, min: f32) -> Self { - Self { - nw: self.nw.max(min), - ne: self.ne.max(min), - sw: self.sw.max(min), - se: self.se.max(min), - } - } - - /// Make sure each corner has a rounding of at most this. - #[inline] - pub fn at_most(&self, max: f32) -> Self { - Self { - nw: self.nw.min(max), - ne: self.ne.min(max), - sw: self.sw.min(max), - se: self.se.min(max), - } - } -} - -impl std::ops::Add for Rounding { - type Output = Self; - #[inline] - fn add(self, rhs: Self) -> Self { - Self { - nw: self.nw + rhs.nw, - ne: self.ne + rhs.ne, - sw: self.sw + rhs.sw, - se: self.se + rhs.se, - } - } -} - -impl std::ops::AddAssign for Rounding { - #[inline] - fn add_assign(&mut self, rhs: Self) { - *self = Self { - nw: self.nw + rhs.nw, - ne: self.ne + rhs.ne, - sw: self.sw + rhs.sw, - se: self.se + rhs.se, - }; - } -} - -impl std::ops::AddAssign for Rounding { - #[inline] - fn add_assign(&mut self, rhs: f32) { - *self = Self { - nw: self.nw + rhs, - ne: self.ne + rhs, - sw: self.sw + rhs, - se: self.se + rhs, - }; - } -} - -impl std::ops::Sub for Rounding { - type Output = Self; - #[inline] - fn sub(self, rhs: Self) -> Self { - Self { - nw: self.nw - rhs.nw, - ne: self.ne - rhs.ne, - sw: self.sw - rhs.sw, - se: self.se - rhs.se, - } - } -} - -impl std::ops::SubAssign for Rounding { - #[inline] - fn sub_assign(&mut self, rhs: Self) { - *self = Self { - nw: self.nw - rhs.nw, - ne: self.ne - rhs.ne, - sw: self.sw - rhs.sw, - se: self.se - rhs.se, - }; - } -} - -impl std::ops::SubAssign for Rounding { - #[inline] - fn sub_assign(&mut self, rhs: f32) { - *self = Self { - nw: self.nw - rhs, - ne: self.ne - rhs, - sw: self.sw - rhs, - se: self.se - rhs, - }; - } -} - -impl std::ops::Div for Rounding { - type Output = Self; - #[inline] - fn div(self, rhs: f32) -> Self { - Self { - nw: self.nw / rhs, - ne: self.ne / rhs, - sw: self.sw / rhs, - se: self.se / rhs, - } - } -} - -impl std::ops::DivAssign for Rounding { - #[inline] - fn div_assign(&mut self, rhs: f32) { - *self = Self { - nw: self.nw / rhs, - ne: self.ne / rhs, - sw: self.sw / rhs, - se: self.se / rhs, - }; - } -} - -impl std::ops::Mul for Rounding { - type Output = Self; - #[inline] - fn mul(self, rhs: f32) -> Self { - Self { - nw: self.nw * rhs, - ne: self.ne * rhs, - sw: self.sw * rhs, - se: self.se * rhs, - } - } -} - -impl std::ops::MulAssign for Rounding { - #[inline] - fn mul_assign(&mut self, rhs: f32) { - *self = Self { - nw: self.nw * rhs, - ne: self.ne * rhs, - sw: self.sw * rhs, - se: self.se * rhs, - }; - } -} - // ---------------------------------------------------------------------------- /// How to paint some text on screen. diff --git a/crates/epaint/src/tessellator.rs b/crates/epaint/src/tessellator.rs index e04eb03be..5e8b131cf 100644 --- a/crates/epaint/src/tessellator.rs +++ b/crates/epaint/src/tessellator.rs @@ -525,7 +525,7 @@ impl Path { pub mod path { //! Helpers for constructing paths - use crate::shape::Rounding; + use crate::Rounding; use emath::{pos2, Pos2, Rect}; /// overwrites existing points @@ -548,6 +548,8 @@ pub mod path { // Duplicated vertices can happen when one side is all rounding, with no straight edge between. let eps = f32::EPSILON * rect.size().max_elem(); + let r = crate::Roundingf::from(r); + add_circle_quadrant(path, pos2(max.x - r.se, max.y - r.se), r.se, 0.0); // south east if rect.width() <= r.se + r.sw + eps { @@ -628,7 +630,7 @@ pub mod path { let half_width = rect.width() * 0.5; let half_height = rect.height() * 0.5; let max_cr = half_width.min(half_height); - rounding.at_most(max_cr).at_least(0.0) + rounding.at_most(max_cr.floor() as _).at_least(0) } } @@ -1741,7 +1743,7 @@ impl Tessellator { .at_most(rect.size().min_elem() - eps) .at_least(0.0); - rounding += Rounding::same(0.5 * blur_width); + rounding += Rounding::from(0.5 * blur_width); self.feathering = self.feathering.max(blur_width); } From cf7150c6a3770419cb45e0c2d907bf60b07363dc Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Thu, 2 Jan 2025 14:55:49 +0100 Subject: [PATCH 2/9] Refactor: put each shape into its own file (#5564) Much easier to navigate the code --- CONTRIBUTING.md | 3 +- Cargo.toml | 4 +- crates/epaint/src/lib.rs | 12 +- crates/epaint/src/shape.rs | 1102 ----------------- .../src/{bezier.rs => shapes/bezier_shape.rs} | 2 +- crates/epaint/src/shapes/circle_shape.rs | 52 + crates/epaint/src/shapes/ellipse_shape.rs | 54 + crates/epaint/src/shapes/mod.rs | 19 + crates/epaint/src/shapes/paint_callback.rs | 103 ++ crates/epaint/src/shapes/path_shape.rs | 81 ++ crates/epaint/src/shapes/rect_shape.rs | 125 ++ crates/epaint/src/shapes/shape.rs | 540 ++++++++ crates/epaint/src/shapes/text_shape.rs | 97 ++ crates/epaint/src/viewport.rs | 54 + 14 files changed, 1136 insertions(+), 1112 deletions(-) delete mode 100644 crates/epaint/src/shape.rs rename crates/epaint/src/{bezier.rs => shapes/bezier_shape.rs} (99%) create mode 100644 crates/epaint/src/shapes/circle_shape.rs create mode 100644 crates/epaint/src/shapes/ellipse_shape.rs create mode 100644 crates/epaint/src/shapes/mod.rs create mode 100644 crates/epaint/src/shapes/paint_callback.rs create mode 100644 crates/epaint/src/shapes/path_shape.rs create mode 100644 crates/epaint/src/shapes/rect_shape.rs create mode 100644 crates/epaint/src/shapes/shape.rs create mode 100644 crates/epaint/src/shapes/text_shape.rs create mode 100644 crates/epaint/src/viewport.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cfeb0c247..48c9e9ae5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ For small things, just go ahead an open a PR. For bigger things, please file an Browse through [`ARCHITECTURE.md`](ARCHITECTURE.md) to get a sense of how all pieces connects. You can test your code locally by running `./scripts/check.sh`. -There are snapshots test that might need to be updated. +There are snapshots test that might need to be updated. Run the tests with `UPDATE_SNAPSHOTS=true cargo test --workspace --all-features` to update all of them. For more info about the tests see [egui_kittest](./crates/egui_kittest/README.md). @@ -102,6 +102,7 @@ While using an immediate mode gui is simple, implementing one is a lot more tric * Avoid double negatives * Flip `if !condition {} else {}` * Sets of things should be lexicographically sorted (e.g. crate dependencies in `Cargo.toml`) +* Put each type in their own file, unless they are trivial (e.g. a `struct` with no `impl`) * Break the above rules when it makes sense diff --git a/Cargo.toml b/Cargo.toml index 799e4f984..1dcae33b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -266,11 +266,10 @@ use_self = "warn" useless_transmute = "warn" verbose_file_reads = "warn" wildcard_dependencies = "warn" -wildcard_imports = "warn" zero_sized_map_values = "warn" -# TODO(emilk): enable more of these lints: +# TODO(emilk): maybe enable more of these lints? iter_over_hash_type = "allow" missing_assert_message = "allow" should_panic_without_expect = "allow" @@ -284,3 +283,4 @@ let_underscore_untyped = "allow" manual_range_contains = "allow" # this one is just worse imho self_named_module_files = "allow" # Disabled waiting on https://github.com/rust-lang/rust-clippy/issues/9602 significant_drop_tightening = "allow" # Too many false positives +wildcard_imports = "allow" # `use crate::*` is useful to avoid merge conflicts when adding/removing imports diff --git a/crates/epaint/src/lib.rs b/crates/epaint/src/lib.rs index 1cc9e6c96..5aeb96762 100644 --- a/crates/epaint/src/lib.rs +++ b/crates/epaint/src/lib.rs @@ -23,7 +23,6 @@ #![allow(clippy::float_cmp)] #![allow(clippy::manual_range_contains)] -mod bezier; pub mod color; pub mod image; mod margin; @@ -32,8 +31,8 @@ pub mod mutex; mod rounding; mod roundingf; mod shadow; -mod shape; pub mod shape_transform; +mod shapes; pub mod stats; mod stroke; pub mod tessellator; @@ -42,9 +41,9 @@ mod texture_atlas; mod texture_handle; pub mod textures; pub mod util; +mod viewport; pub use self::{ - bezier::{CubicBezierShape, QuadraticBezierShape}, color::ColorMode, image::{ColorImage, FontImage, ImageData, ImageDelta}, margin::Margin, @@ -52,9 +51,9 @@ pub use self::{ rounding::Rounding, roundingf::Roundingf, shadow::Shadow, - shape::{ - CircleShape, EllipseShape, PaintCallback, PaintCallbackInfo, PathShape, RectShape, Shape, - TextShape, + shapes::{ + CircleShape, CubicBezierShape, EllipseShape, PaintCallback, PaintCallbackInfo, PathShape, + QuadraticBezierShape, RectShape, Shape, TextShape, }, stats::PaintStats, stroke::{PathStroke, Stroke, StrokeKind}, @@ -63,6 +62,7 @@ pub use self::{ texture_atlas::TextureAtlas, texture_handle::TextureHandle, textures::TextureManager, + viewport::ViewportInPixels, }; #[allow(deprecated)] diff --git a/crates/epaint/src/shape.rs b/crates/epaint/src/shape.rs deleted file mode 100644 index 1fc393c72..000000000 --- a/crates/epaint/src/shape.rs +++ /dev/null @@ -1,1102 +0,0 @@ -//! The different shapes that can be painted. - -use std::{any::Any, sync::Arc}; - -use crate::{ - stroke::PathStroke, - text::{FontId, Fonts, Galley}, - Color32, Mesh, Rounding, Stroke, TextureId, -}; -use emath::{pos2, Align2, Pos2, Rangef, Rect, TSTransform, Vec2}; - -pub use crate::{CubicBezierShape, QuadraticBezierShape}; - -/// A paint primitive such as a circle or a piece of text. -/// Coordinates are all screen space points (not physical pixels). -/// -/// You should generally recreate your [`Shape`]s each frame, -/// but storing them should also be fine with one exception: -/// [`Shape::Text`] depends on the current `pixels_per_point` (dpi scale) -/// and so must be recreated every time `pixels_per_point` changes. -#[must_use = "Add a Shape to a Painter"] -#[derive(Clone, Debug, PartialEq)] -pub enum Shape { - /// Paint nothing. This can be useful as a placeholder. - Noop, - - /// Recursively nest more shapes - sometimes a convenience to be able to do. - /// For performance reasons it is better to avoid it. - Vec(Vec), - - /// Circle with optional outline and fill. - Circle(CircleShape), - - /// Ellipse with optional outline and fill. - Ellipse(EllipseShape), - - /// A line between two points. - LineSegment { points: [Pos2; 2], stroke: Stroke }, - - /// A series of lines between points. - /// The path can have a stroke and/or fill (if closed). - Path(PathShape), - - /// Rectangle with optional outline and fill. - Rect(RectShape), - - /// Text. - /// - /// This needs to be recreated if `pixels_per_point` (dpi scale) changes. - Text(TextShape), - - /// A general triangle mesh. - /// - /// Can be used to display images. - Mesh(Mesh), - - /// A quadratic [Bézier Curve](https://en.wikipedia.org/wiki/B%C3%A9zier_curve). - QuadraticBezier(QuadraticBezierShape), - - /// A cubic [Bézier Curve](https://en.wikipedia.org/wiki/B%C3%A9zier_curve). - CubicBezier(CubicBezierShape), - - /// Backend-specific painting. - Callback(PaintCallback), -} - -#[test] -fn shape_impl_send_sync() { - fn assert_send_sync() {} - assert_send_sync::(); -} - -impl From> for Shape { - #[inline(always)] - fn from(shapes: Vec) -> Self { - Self::Vec(shapes) - } -} - -impl From for Shape { - #[inline(always)] - fn from(mesh: Mesh) -> Self { - Self::Mesh(mesh) - } -} - -/// ## Constructors -impl Shape { - /// A line between two points. - /// More efficient than calling [`Self::line`]. - #[inline] - pub fn line_segment(points: [Pos2; 2], stroke: impl Into) -> Self { - Self::LineSegment { - points, - stroke: stroke.into(), - } - } - - /// A horizontal line. - pub fn hline(x: impl Into, y: f32, stroke: impl Into) -> Self { - let x = x.into(); - Self::LineSegment { - points: [pos2(x.min, y), pos2(x.max, y)], - stroke: stroke.into(), - } - } - - /// A vertical line. - pub fn vline(x: f32, y: impl Into, stroke: impl Into) -> Self { - let y = y.into(); - Self::LineSegment { - points: [pos2(x, y.min), pos2(x, y.max)], - stroke: stroke.into(), - } - } - - /// A line through many points. - /// - /// Use [`Self::line_segment`] instead if your line only connects two points. - #[inline] - pub fn line(points: Vec, stroke: impl Into) -> Self { - Self::Path(PathShape::line(points, stroke)) - } - - /// A line that closes back to the start point again. - #[inline] - pub fn closed_line(points: Vec, stroke: impl Into) -> Self { - Self::Path(PathShape::closed_line(points, stroke)) - } - - /// Turn a line into equally spaced dots. - pub fn dotted_line( - path: &[Pos2], - color: impl Into, - spacing: f32, - radius: f32, - ) -> Vec { - let mut shapes = Vec::new(); - points_from_line(path, spacing, radius, color.into(), &mut shapes); - shapes - } - - /// Turn a line into dashes. - pub fn dashed_line( - path: &[Pos2], - stroke: impl Into, - dash_length: f32, - gap_length: f32, - ) -> Vec { - let mut shapes = Vec::new(); - dashes_from_line( - path, - stroke.into(), - &[dash_length], - &[gap_length], - &mut shapes, - 0., - ); - shapes - } - - /// Turn a line into dashes with different dash/gap lengths and a start offset. - pub fn dashed_line_with_offset( - path: &[Pos2], - stroke: impl Into, - dash_lengths: &[f32], - gap_lengths: &[f32], - dash_offset: f32, - ) -> Vec { - let mut shapes = Vec::new(); - dashes_from_line( - path, - stroke.into(), - dash_lengths, - gap_lengths, - &mut shapes, - dash_offset, - ); - shapes - } - - /// Turn a line into dashes. If you need to create many dashed lines use this instead of - /// [`Self::dashed_line`]. - pub fn dashed_line_many( - points: &[Pos2], - stroke: impl Into, - dash_length: f32, - gap_length: f32, - shapes: &mut Vec, - ) { - dashes_from_line( - points, - stroke.into(), - &[dash_length], - &[gap_length], - shapes, - 0., - ); - } - - /// Turn a line into dashes with different dash/gap lengths and a start offset. If you need to - /// create many dashed lines use this instead of [`Self::dashed_line_with_offset`]. - pub fn dashed_line_many_with_offset( - points: &[Pos2], - stroke: impl Into, - dash_lengths: &[f32], - gap_lengths: &[f32], - dash_offset: f32, - shapes: &mut Vec, - ) { - dashes_from_line( - points, - stroke.into(), - dash_lengths, - gap_lengths, - shapes, - dash_offset, - ); - } - - /// A convex polygon with a fill and optional stroke. - /// - /// The most performant winding order is clockwise. - #[inline] - pub fn convex_polygon( - points: Vec, - fill: impl Into, - stroke: impl Into, - ) -> Self { - Self::Path(PathShape::convex_polygon(points, fill, stroke)) - } - - #[inline] - pub fn circle_filled(center: Pos2, radius: f32, fill_color: impl Into) -> Self { - Self::Circle(CircleShape::filled(center, radius, fill_color)) - } - - #[inline] - pub fn circle_stroke(center: Pos2, radius: f32, stroke: impl Into) -> Self { - Self::Circle(CircleShape::stroke(center, radius, stroke)) - } - - #[inline] - pub fn ellipse_filled(center: Pos2, radius: Vec2, fill_color: impl Into) -> Self { - Self::Ellipse(EllipseShape::filled(center, radius, fill_color)) - } - - #[inline] - pub fn ellipse_stroke(center: Pos2, radius: Vec2, stroke: impl Into) -> Self { - Self::Ellipse(EllipseShape::stroke(center, radius, stroke)) - } - - #[inline] - pub fn rect_filled( - rect: Rect, - rounding: impl Into, - fill_color: impl Into, - ) -> Self { - Self::Rect(RectShape::filled(rect, rounding, fill_color)) - } - - /// The stroke extends _outside_ the [`Rect`]. - #[inline] - pub fn rect_stroke( - rect: Rect, - rounding: impl Into, - stroke: impl Into, - ) -> Self { - Self::Rect(RectShape::stroke(rect, rounding, stroke)) - } - - #[allow(clippy::needless_pass_by_value)] - pub fn text( - fonts: &Fonts, - pos: Pos2, - anchor: Align2, - text: impl ToString, - font_id: FontId, - color: Color32, - ) -> Self { - let galley = fonts.layout_no_wrap(text.to_string(), font_id, color); - let rect = anchor.anchor_size(pos, galley.size()); - Self::galley(rect.min, galley, color) - } - - /// Any uncolored parts of the [`Galley`] (using [`Color32::PLACEHOLDER`]) will be replaced with the given color. - /// - /// Any non-placeholder color in the galley takes precedence over this fallback color. - #[inline] - pub fn galley(pos: Pos2, galley: Arc, fallback_color: Color32) -> Self { - TextShape::new(pos, galley, fallback_color).into() - } - - /// All text color in the [`Galley`] will be replaced with the given color. - #[inline] - pub fn galley_with_override_text_color( - pos: Pos2, - galley: Arc, - text_color: Color32, - ) -> Self { - TextShape::new(pos, galley, text_color) - .with_override_text_color(text_color) - .into() - } - - #[inline] - #[deprecated = "Use `Shape::galley` or `Shape::galley_with_override_text_color` instead"] - pub fn galley_with_color(pos: Pos2, galley: Arc, text_color: Color32) -> Self { - Self::galley_with_override_text_color(pos, galley, text_color) - } - - #[inline] - pub fn mesh(mesh: Mesh) -> Self { - debug_assert!(mesh.is_valid()); - Self::Mesh(mesh) - } - - /// An image at the given position. - /// - /// `uv` should normally be `Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0))` - /// unless you want to crop or flip the image. - /// - /// `tint` is a color multiplier. Use [`Color32::WHITE`] if you don't want to tint the image. - pub fn image(texture_id: TextureId, rect: Rect, uv: Rect, tint: Color32) -> Self { - let mut mesh = Mesh::with_texture(texture_id); - mesh.add_rect_with_uv(rect, uv, tint); - Self::mesh(mesh) - } - - /// The visual bounding rectangle (includes stroke widths) - pub fn visual_bounding_rect(&self) -> Rect { - match self { - Self::Noop => Rect::NOTHING, - Self::Vec(shapes) => { - let mut rect = Rect::NOTHING; - for shape in shapes { - rect = rect.union(shape.visual_bounding_rect()); - } - rect - } - Self::Circle(circle_shape) => circle_shape.visual_bounding_rect(), - Self::Ellipse(ellipse_shape) => ellipse_shape.visual_bounding_rect(), - Self::LineSegment { points, stroke } => { - if stroke.is_empty() { - Rect::NOTHING - } else { - Rect::from_two_pos(points[0], points[1]).expand(stroke.width / 2.0) - } - } - Self::Path(path_shape) => path_shape.visual_bounding_rect(), - Self::Rect(rect_shape) => rect_shape.visual_bounding_rect(), - Self::Text(text_shape) => text_shape.visual_bounding_rect(), - Self::Mesh(mesh) => mesh.calc_bounds(), - Self::QuadraticBezier(bezier) => bezier.visual_bounding_rect(), - Self::CubicBezier(bezier) => bezier.visual_bounding_rect(), - Self::Callback(custom) => custom.rect, - } - } -} - -/// ## Inspection and transforms -impl Shape { - #[inline(always)] - pub fn texture_id(&self) -> super::TextureId { - if let Self::Mesh(mesh) = self { - mesh.texture_id - } else if let Self::Rect(rect_shape) = self { - rect_shape.fill_texture_id - } else { - super::TextureId::default() - } - } - - /// Scale the shape by `factor`, in-place. - /// - /// A wrapper around [`Self::transform`]. - #[inline(always)] - pub fn scale(&mut self, factor: f32) { - self.transform(TSTransform::from_scaling(factor)); - } - - /// Move the shape by `delta`, in-place. - /// - /// A wrapper around [`Self::transform`]. - #[inline(always)] - pub fn translate(&mut self, delta: Vec2) { - self.transform(TSTransform::from_translation(delta)); - } - - /// Move the shape by this many points, in-place. - /// - /// If using a [`PaintCallback`], note that only the rect is scaled as opposed - /// to other shapes where the stroke is also scaled. - pub fn transform(&mut self, transform: TSTransform) { - match self { - Self::Noop => {} - Self::Vec(shapes) => { - for shape in shapes { - shape.transform(transform); - } - } - Self::Circle(circle_shape) => { - circle_shape.center = transform * circle_shape.center; - circle_shape.radius *= transform.scaling; - circle_shape.stroke.width *= transform.scaling; - } - Self::Ellipse(ellipse_shape) => { - ellipse_shape.center = transform * ellipse_shape.center; - ellipse_shape.radius *= transform.scaling; - ellipse_shape.stroke.width *= transform.scaling; - } - Self::LineSegment { points, stroke } => { - for p in points { - *p = transform * *p; - } - stroke.width *= transform.scaling; - } - Self::Path(path_shape) => { - for p in &mut path_shape.points { - *p = transform * *p; - } - path_shape.stroke.width *= transform.scaling; - } - Self::Rect(rect_shape) => { - rect_shape.rect = transform * rect_shape.rect; - rect_shape.stroke.width *= transform.scaling; - rect_shape.rounding *= transform.scaling; - } - Self::Text(text_shape) => { - text_shape.pos = transform * text_shape.pos; - - // Scale text: - let galley = Arc::make_mut(&mut text_shape.galley); - for row in &mut galley.rows { - row.visuals.mesh_bounds = transform.scaling * row.visuals.mesh_bounds; - for v in &mut row.visuals.mesh.vertices { - v.pos = Pos2::new(transform.scaling * v.pos.x, transform.scaling * v.pos.y); - } - } - - galley.mesh_bounds = transform.scaling * galley.mesh_bounds; - galley.rect = transform.scaling * galley.rect; - } - Self::Mesh(mesh) => { - mesh.transform(transform); - } - Self::QuadraticBezier(bezier_shape) => { - bezier_shape.points[0] = transform * bezier_shape.points[0]; - bezier_shape.points[1] = transform * bezier_shape.points[1]; - bezier_shape.points[2] = transform * bezier_shape.points[2]; - bezier_shape.stroke.width *= transform.scaling; - } - Self::CubicBezier(cubic_curve) => { - for p in &mut cubic_curve.points { - *p = transform * *p; - } - cubic_curve.stroke.width *= transform.scaling; - } - Self::Callback(shape) => { - shape.rect = transform * shape.rect; - } - } - } -} - -// ---------------------------------------------------------------------------- - -/// How to paint a circle. -#[derive(Copy, Clone, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub struct CircleShape { - pub center: Pos2, - pub radius: f32, - pub fill: Color32, - pub stroke: Stroke, -} - -impl CircleShape { - #[inline] - pub fn filled(center: Pos2, radius: f32, fill_color: impl Into) -> Self { - Self { - center, - radius, - fill: fill_color.into(), - stroke: Default::default(), - } - } - - #[inline] - pub fn stroke(center: Pos2, radius: f32, stroke: impl Into) -> Self { - Self { - center, - radius, - fill: Default::default(), - stroke: stroke.into(), - } - } - - /// The visual bounding rectangle (includes stroke width) - pub fn visual_bounding_rect(&self) -> Rect { - if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() { - Rect::NOTHING - } else { - Rect::from_center_size( - self.center, - Vec2::splat(self.radius * 2.0 + self.stroke.width), - ) - } - } -} - -impl From for Shape { - #[inline(always)] - fn from(shape: CircleShape) -> Self { - Self::Circle(shape) - } -} - -// ---------------------------------------------------------------------------- - -/// How to paint an ellipse. -#[derive(Copy, Clone, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub struct EllipseShape { - pub center: Pos2, - - /// Radius is the vector (a, b) where the width of the Ellipse is 2a and the height is 2b - pub radius: Vec2, - pub fill: Color32, - pub stroke: Stroke, -} - -impl EllipseShape { - #[inline] - pub fn filled(center: Pos2, radius: Vec2, fill_color: impl Into) -> Self { - Self { - center, - radius, - fill: fill_color.into(), - stroke: Default::default(), - } - } - - #[inline] - pub fn stroke(center: Pos2, radius: Vec2, stroke: impl Into) -> Self { - Self { - center, - radius, - fill: Default::default(), - stroke: stroke.into(), - } - } - - /// The visual bounding rectangle (includes stroke width) - pub fn visual_bounding_rect(&self) -> Rect { - if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() { - Rect::NOTHING - } else { - Rect::from_center_size( - self.center, - self.radius * 2.0 + Vec2::splat(self.stroke.width), - ) - } - } -} - -impl From for Shape { - #[inline(always)] - fn from(shape: EllipseShape) -> Self { - Self::Ellipse(shape) - } -} - -// ---------------------------------------------------------------------------- - -/// A path which can be stroked and/or filled (if closed). -#[derive(Clone, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub struct PathShape { - /// Filled paths should prefer clockwise order. - pub points: Vec, - - /// If true, connect the first and last of the points together. - /// This is required if `fill != TRANSPARENT`. - pub closed: bool, - - /// Fill is only supported for convex polygons. - pub fill: Color32, - - /// Color and thickness of the line. - pub stroke: PathStroke, - // TODO(emilk): Add texture support either by supplying uv for each point, - // or by some transform from points to uv (e.g. a callback or a linear transform matrix). -} - -impl PathShape { - /// A line through many points. - /// - /// Use [`Shape::line_segment`] instead if your line only connects two points. - #[inline] - pub fn line(points: Vec, stroke: impl Into) -> Self { - Self { - points, - closed: false, - fill: Default::default(), - stroke: stroke.into(), - } - } - - /// A line that closes back to the start point again. - #[inline] - pub fn closed_line(points: Vec, stroke: impl Into) -> Self { - Self { - points, - closed: true, - fill: Default::default(), - stroke: stroke.into(), - } - } - - /// A convex polygon with a fill and optional stroke. - /// - /// The most performant winding order is clockwise. - #[inline] - pub fn convex_polygon( - points: Vec, - fill: impl Into, - stroke: impl Into, - ) -> Self { - Self { - points, - closed: true, - fill: fill.into(), - stroke: stroke.into(), - } - } - - /// The visual bounding rectangle (includes stroke width) - #[inline] - pub fn visual_bounding_rect(&self) -> Rect { - if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() { - Rect::NOTHING - } else { - Rect::from_points(&self.points).expand(self.stroke.width / 2.0) - } - } -} - -impl From for Shape { - #[inline(always)] - fn from(shape: PathShape) -> Self { - Self::Path(shape) - } -} - -// ---------------------------------------------------------------------------- - -/// How to paint a rectangle. -#[derive(Copy, Clone, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub struct RectShape { - pub rect: Rect, - - /// How rounded the corners are. Use `Rounding::ZERO` for no rounding. - pub rounding: Rounding, - - /// How to fill the rectangle. - pub fill: Color32, - - /// The thickness and color of the outline. - /// - /// The stroke extends _outside_ the edge of [`Self::rect`], - /// i.e. using [`crate::StrokeKind::Outside`]. - /// - /// This means the [`Self::visual_bounding_rect`] is `rect.size() + 2.0 * stroke.width`. - pub stroke: Stroke, - - /// If larger than zero, the edges of the rectangle - /// (for both fill and stroke) will be blurred. - /// - /// This can be used to produce shadows and glow effects. - /// - /// The blur is currently implemented using a simple linear blur in sRGBA gamma space. - pub blur_width: f32, - - /// If the rect should be filled with a texture, which one? - /// - /// The texture is multiplied with [`Self::fill`]. - pub fill_texture_id: TextureId, - - /// What UV coordinates to use for the texture? - /// - /// To display a texture, set [`Self::fill_texture_id`], - /// and set this to `Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0))`. - /// - /// Use [`Rect::ZERO`] to turn off texturing. - pub uv: Rect, -} - -impl RectShape { - /// The stroke extends _outside_ the [`Rect`]. - #[inline] - pub fn new( - rect: Rect, - rounding: impl Into, - fill_color: impl Into, - stroke: impl Into, - ) -> Self { - Self { - rect, - rounding: rounding.into(), - fill: fill_color.into(), - stroke: stroke.into(), - blur_width: 0.0, - fill_texture_id: Default::default(), - uv: Rect::ZERO, - } - } - - #[inline] - pub fn filled( - rect: Rect, - rounding: impl Into, - fill_color: impl Into, - ) -> Self { - Self { - rect, - rounding: rounding.into(), - fill: fill_color.into(), - stroke: Default::default(), - blur_width: 0.0, - fill_texture_id: Default::default(), - uv: Rect::ZERO, - } - } - - /// The stroke extends _outside_ the [`Rect`]. - #[inline] - pub fn stroke(rect: Rect, rounding: impl Into, stroke: impl Into) -> Self { - Self { - rect, - rounding: rounding.into(), - fill: Default::default(), - stroke: stroke.into(), - blur_width: 0.0, - fill_texture_id: Default::default(), - uv: Rect::ZERO, - } - } - - /// If larger than zero, the edges of the rectangle - /// (for both fill and stroke) will be blurred. - /// - /// This can be used to produce shadows and glow effects. - /// - /// The blur is currently implemented using a simple linear blur in `sRGBA` gamma space. - #[inline] - pub fn with_blur_width(mut self, blur_width: f32) -> Self { - self.blur_width = blur_width; - self - } - - /// The visual bounding rectangle (includes stroke width) - #[inline] - pub fn visual_bounding_rect(&self) -> Rect { - if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() { - Rect::NOTHING - } else { - let Stroke { width, .. } = self.stroke; // Make sure we remember to update this if we change `stroke` to `PathStroke` - self.rect.expand(width + self.blur_width / 2.0) - } - } -} - -impl From for Shape { - #[inline(always)] - fn from(shape: RectShape) -> Self { - Self::Rect(shape) - } -} - -// ---------------------------------------------------------------------------- - -/// How to paint some text on screen. -/// -/// This needs to be recreated if `pixels_per_point` (dpi scale) changes. -#[derive(Clone, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub struct TextShape { - /// Top left corner of the first character. - pub pos: Pos2, - - /// The laid out text, from [`Fonts::layout_job`]. - pub galley: Arc, - - /// Add this underline to the whole text. - /// You can also set an underline when creating the galley. - pub underline: Stroke, - - /// Any [`Color32::PLACEHOLDER`] in the galley will be replaced by the given color. - /// Affects everything: backgrounds, glyphs, strikethrough, underline, etc. - pub fallback_color: Color32, - - /// If set, the text color in the galley will be ignored and replaced - /// with the given color. - /// - /// This only affects the glyphs and will NOT replace background color nor strikethrough/underline color. - pub override_text_color: Option, - - /// If set, the text will be rendered with the given opacity in gamma space - /// Affects everything: backgrounds, glyphs, strikethrough, underline, etc. - pub opacity_factor: f32, - - /// Rotate text by this many radians clockwise. - /// The pivot is `pos` (the upper left corner of the text). - pub angle: f32, -} - -impl TextShape { - /// The given fallback color will be used for any uncolored part of the galley (using [`Color32::PLACEHOLDER`]). - /// - /// Any non-placeholder color in the galley takes precedence over this fallback color. - #[inline] - pub fn new(pos: Pos2, galley: Arc, fallback_color: Color32) -> Self { - Self { - pos, - galley, - underline: Stroke::NONE, - fallback_color, - override_text_color: None, - opacity_factor: 1.0, - angle: 0.0, - } - } - - /// The visual bounding rectangle - #[inline] - pub fn visual_bounding_rect(&self) -> Rect { - self.galley.mesh_bounds.translate(self.pos.to_vec2()) - } - - #[inline] - pub fn with_underline(mut self, underline: Stroke) -> Self { - self.underline = underline; - self - } - - /// Use the given color for the text, regardless of what color is already in the galley. - #[inline] - pub fn with_override_text_color(mut self, override_text_color: Color32) -> Self { - self.override_text_color = Some(override_text_color); - self - } - - /// Rotate text by this many radians clockwise. - /// The pivot is `pos` (the upper left corner of the text). - #[inline] - pub fn with_angle(mut self, angle: f32) -> Self { - self.angle = angle; - self - } - - /// Render text with this opacity in gamma space - #[inline] - pub fn with_opacity_factor(mut self, opacity_factor: f32) -> Self { - self.opacity_factor = opacity_factor; - self - } -} - -impl From for Shape { - #[inline(always)] - fn from(shape: TextShape) -> Self { - Self::Text(shape) - } -} - -// ---------------------------------------------------------------------------- - -/// Creates equally spaced filled circles from a line. -fn points_from_line( - path: &[Pos2], - spacing: f32, - radius: f32, - color: Color32, - shapes: &mut Vec, -) { - let mut position_on_segment = 0.0; - path.windows(2).for_each(|window| { - let (start, end) = (window[0], window[1]); - let vector = end - start; - let segment_length = vector.length(); - while position_on_segment < segment_length { - let new_point = start + vector * (position_on_segment / segment_length); - shapes.push(Shape::circle_filled(new_point, radius, color)); - position_on_segment += spacing; - } - position_on_segment -= segment_length; - }); -} - -/// Creates dashes from a line. -fn dashes_from_line( - path: &[Pos2], - stroke: Stroke, - dash_lengths: &[f32], - gap_lengths: &[f32], - shapes: &mut Vec, - dash_offset: f32, -) { - assert_eq!(dash_lengths.len(), gap_lengths.len()); - let mut position_on_segment = dash_offset; - let mut drawing_dash = false; - let mut step = 0; - let steps = dash_lengths.len(); - path.windows(2).for_each(|window| { - let (start, end) = (window[0], window[1]); - let vector = end - start; - let segment_length = vector.length(); - - let mut start_point = start; - while position_on_segment < segment_length { - let new_point = start + vector * (position_on_segment / segment_length); - if drawing_dash { - // This is the end point. - shapes.push(Shape::line_segment([start_point, new_point], stroke)); - position_on_segment += gap_lengths[step]; - // Increment step counter - step += 1; - if step >= steps { - step = 0; - } - } else { - // Start a new dash. - start_point = new_point; - position_on_segment += dash_lengths[step]; - } - drawing_dash = !drawing_dash; - } - - // If the segment ends and the dash is not finished, add the segment's end point. - if drawing_dash { - shapes.push(Shape::line_segment([start_point, end], stroke)); - } - - position_on_segment -= segment_length; - }); -} - -// ---------------------------------------------------------------------------- - -/// Information passed along with [`PaintCallback`] ([`Shape::Callback`]). -pub struct PaintCallbackInfo { - /// Viewport in points. - /// - /// This specifies where on the screen to paint, and the borders of this - /// Rect is the [-1, +1] of the Normalized Device Coordinates. - /// - /// Note than only a portion of this may be visible due to [`Self::clip_rect`]. - /// - /// This comes from [`PaintCallback::rect`]. - pub viewport: Rect, - - /// Clip rectangle in points. - pub clip_rect: Rect, - - /// Pixels per point. - pub pixels_per_point: f32, - - /// Full size of the screen, in pixels. - pub screen_size_px: [u32; 2], -} - -/// Size of the viewport in whole, physical pixels. -pub struct ViewportInPixels { - /// Physical pixel offset for left side of the viewport. - pub left_px: i32, - - /// Physical pixel offset for top side of the viewport. - pub top_px: i32, - - /// Physical pixel offset for bottom side of the viewport. - /// - /// This is what `glViewport`, `glScissor` etc expects for the y axis. - pub from_bottom_px: i32, - - /// Viewport width in physical pixels. - pub width_px: i32, - - /// Viewport height in physical pixels. - pub height_px: i32, -} - -impl ViewportInPixels { - fn from_points(rect: &Rect, pixels_per_point: f32, screen_size_px: [u32; 2]) -> Self { - // Fractional pixel values for viewports are generally valid, but may cause sampling issues - // and rounding errors might cause us to get out of bounds. - - // Round: - let left_px = (pixels_per_point * rect.min.x).round() as i32; // inclusive - let top_px = (pixels_per_point * rect.min.y).round() as i32; // inclusive - let right_px = (pixels_per_point * rect.max.x).round() as i32; // exclusive - let bottom_px = (pixels_per_point * rect.max.y).round() as i32; // exclusive - - // Clamp to screen: - let screen_width = screen_size_px[0] as i32; - let screen_height = screen_size_px[1] as i32; - let left_px = left_px.clamp(0, screen_width); - let right_px = right_px.clamp(left_px, screen_width); - let top_px = top_px.clamp(0, screen_height); - let bottom_px = bottom_px.clamp(top_px, screen_height); - - let width_px = right_px - left_px; - let height_px = bottom_px - top_px; - - Self { - left_px, - top_px, - from_bottom_px: screen_height - height_px - top_px, - width_px, - height_px, - } - } -} - -#[test] -fn test_viewport_rounding() { - for i in 0..=10_000 { - // Two adjacent viewports should never overlap: - let x = i as f32 / 97.0; - let left = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)).with_max_x(x); - let right = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)).with_min_x(x); - - for pixels_per_point in [0.618, 1.0, std::f32::consts::PI] { - let left = ViewportInPixels::from_points(&left, pixels_per_point, [100, 100]); - let right = ViewportInPixels::from_points(&right, pixels_per_point, [100, 100]); - assert_eq!(left.left_px + left.width_px, right.left_px); - } - } -} - -impl PaintCallbackInfo { - /// The viewport rectangle. This is what you would use in e.g. `glViewport`. - pub fn viewport_in_pixels(&self) -> ViewportInPixels { - ViewportInPixels::from_points(&self.viewport, self.pixels_per_point, self.screen_size_px) - } - - /// The "scissor" or "clip" rectangle. This is what you would use in e.g. `glScissor`. - pub fn clip_rect_in_pixels(&self) -> ViewportInPixels { - ViewportInPixels::from_points(&self.clip_rect, self.pixels_per_point, self.screen_size_px) - } -} - -/// If you want to paint some 3D shapes inside an egui region, you can use this. -/// -/// This is advanced usage, and is backend specific. -#[derive(Clone)] -pub struct PaintCallback { - /// Where to paint. - /// - /// This will become [`PaintCallbackInfo::viewport`]. - pub rect: Rect, - - /// Paint something custom (e.g. 3D stuff). - /// - /// The concrete value of `callback` depends on the rendering backend used. For instance, the - /// `glow` backend requires that callback be an `egui_glow::CallbackFn` while the `wgpu` - /// backend requires a `egui_wgpu::Callback`. - /// - /// If the type cannot be downcast to the type expected by the current backend the callback - /// will not be drawn. - /// - /// The rendering backend is responsible for first setting the active viewport to - /// [`Self::rect`]. - /// - /// The rendering backend is also responsible for restoring any state, such as the bound shader - /// program, vertex array, etc. - /// - /// Shape has to be clone, therefore this has to be an `Arc` instead of a `Box`. - pub callback: Arc, -} - -impl std::fmt::Debug for PaintCallback { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("CustomShape") - .field("rect", &self.rect) - .finish_non_exhaustive() - } -} - -impl std::cmp::PartialEq for PaintCallback { - fn eq(&self, other: &Self) -> bool { - self.rect.eq(&other.rect) && Arc::ptr_eq(&self.callback, &other.callback) - } -} - -impl From for Shape { - #[inline(always)] - fn from(shape: PaintCallback) -> Self { - Self::Callback(shape) - } -} diff --git a/crates/epaint/src/bezier.rs b/crates/epaint/src/shapes/bezier_shape.rs similarity index 99% rename from crates/epaint/src/bezier.rs rename to crates/epaint/src/shapes/bezier_shape.rs index cab0d29f9..7d291c7fd 100644 --- a/crates/epaint/src/bezier.rs +++ b/crates/epaint/src/shapes/bezier_shape.rs @@ -3,7 +3,7 @@ use std::ops::Range; -use crate::{shape::Shape, Color32, PathShape, PathStroke}; +use crate::{Color32, PathShape, PathStroke, Shape}; use emath::{Pos2, Rect, RectTransform}; // ---------------------------------------------------------------------------- diff --git a/crates/epaint/src/shapes/circle_shape.rs b/crates/epaint/src/shapes/circle_shape.rs new file mode 100644 index 000000000..a86ae3f6d --- /dev/null +++ b/crates/epaint/src/shapes/circle_shape.rs @@ -0,0 +1,52 @@ +use crate::{Color32, Pos2, Rect, Shape, Stroke, Vec2}; + +/// How to paint a circle. +#[derive(Copy, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct CircleShape { + pub center: Pos2, + pub radius: f32, + pub fill: Color32, + pub stroke: Stroke, +} + +impl CircleShape { + #[inline] + pub fn filled(center: Pos2, radius: f32, fill_color: impl Into) -> Self { + Self { + center, + radius, + fill: fill_color.into(), + stroke: Default::default(), + } + } + + #[inline] + pub fn stroke(center: Pos2, radius: f32, stroke: impl Into) -> Self { + Self { + center, + radius, + fill: Default::default(), + stroke: stroke.into(), + } + } + + /// The visual bounding rectangle (includes stroke width) + pub fn visual_bounding_rect(&self) -> Rect { + if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() { + Rect::NOTHING + } else { + Rect::from_center_size( + self.center, + Vec2::splat(self.radius * 2.0 + self.stroke.width), + ) + } + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: CircleShape) -> Self { + Self::Circle(shape) + } +} diff --git a/crates/epaint/src/shapes/ellipse_shape.rs b/crates/epaint/src/shapes/ellipse_shape.rs new file mode 100644 index 000000000..310638d0f --- /dev/null +++ b/crates/epaint/src/shapes/ellipse_shape.rs @@ -0,0 +1,54 @@ +use crate::*; + +/// How to paint an ellipse. +#[derive(Copy, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct EllipseShape { + pub center: Pos2, + + /// Radius is the vector (a, b) where the width of the Ellipse is 2a and the height is 2b + pub radius: Vec2, + pub fill: Color32, + pub stroke: Stroke, +} + +impl EllipseShape { + #[inline] + pub fn filled(center: Pos2, radius: Vec2, fill_color: impl Into) -> Self { + Self { + center, + radius, + fill: fill_color.into(), + stroke: Default::default(), + } + } + + #[inline] + pub fn stroke(center: Pos2, radius: Vec2, stroke: impl Into) -> Self { + Self { + center, + radius, + fill: Default::default(), + stroke: stroke.into(), + } + } + + /// The visual bounding rectangle (includes stroke width) + pub fn visual_bounding_rect(&self) -> Rect { + if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() { + Rect::NOTHING + } else { + Rect::from_center_size( + self.center, + self.radius * 2.0 + Vec2::splat(self.stroke.width), + ) + } + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: EllipseShape) -> Self { + Self::Ellipse(shape) + } +} diff --git a/crates/epaint/src/shapes/mod.rs b/crates/epaint/src/shapes/mod.rs new file mode 100644 index 000000000..8a42b2c9b --- /dev/null +++ b/crates/epaint/src/shapes/mod.rs @@ -0,0 +1,19 @@ +mod bezier_shape; +mod circle_shape; +mod ellipse_shape; +mod paint_callback; +mod path_shape; +mod rect_shape; +mod shape; +mod text_shape; + +pub use self::{ + bezier_shape::{CubicBezierShape, QuadraticBezierShape}, + circle_shape::CircleShape, + ellipse_shape::EllipseShape, + paint_callback::{PaintCallback, PaintCallbackInfo}, + path_shape::PathShape, + rect_shape::RectShape, + shape::Shape, + text_shape::TextShape, +}; diff --git a/crates/epaint/src/shapes/paint_callback.rs b/crates/epaint/src/shapes/paint_callback.rs new file mode 100644 index 000000000..00882f0f2 --- /dev/null +++ b/crates/epaint/src/shapes/paint_callback.rs @@ -0,0 +1,103 @@ +use std::{any::Any, sync::Arc}; + +use crate::*; + +/// Information passed along with [`PaintCallback`] ([`Shape::Callback`]). +pub struct PaintCallbackInfo { + /// Viewport in points. + /// + /// This specifies where on the screen to paint, and the borders of this + /// Rect is the [-1, +1] of the Normalized Device Coordinates. + /// + /// Note than only a portion of this may be visible due to [`Self::clip_rect`]. + /// + /// This comes from [`PaintCallback::rect`]. + pub viewport: Rect, + + /// Clip rectangle in points. + pub clip_rect: Rect, + + /// Pixels per point. + pub pixels_per_point: f32, + + /// Full size of the screen, in pixels. + pub screen_size_px: [u32; 2], +} + +#[test] +fn test_viewport_rounding() { + for i in 0..=10_000 { + // Two adjacent viewports should never overlap: + let x = i as f32 / 97.0; + let left = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)).with_max_x(x); + let right = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)).with_min_x(x); + + for pixels_per_point in [0.618, 1.0, std::f32::consts::PI] { + let left = ViewportInPixels::from_points(&left, pixels_per_point, [100, 100]); + let right = ViewportInPixels::from_points(&right, pixels_per_point, [100, 100]); + assert_eq!(left.left_px + left.width_px, right.left_px); + } + } +} + +impl PaintCallbackInfo { + /// The viewport rectangle. This is what you would use in e.g. `glViewport`. + pub fn viewport_in_pixels(&self) -> ViewportInPixels { + ViewportInPixels::from_points(&self.viewport, self.pixels_per_point, self.screen_size_px) + } + + /// The "scissor" or "clip" rectangle. This is what you would use in e.g. `glScissor`. + pub fn clip_rect_in_pixels(&self) -> ViewportInPixels { + ViewportInPixels::from_points(&self.clip_rect, self.pixels_per_point, self.screen_size_px) + } +} + +/// If you want to paint some 3D shapes inside an egui region, you can use this. +/// +/// This is advanced usage, and is backend specific. +#[derive(Clone)] +pub struct PaintCallback { + /// Where to paint. + /// + /// This will become [`PaintCallbackInfo::viewport`]. + pub rect: Rect, + + /// Paint something custom (e.g. 3D stuff). + /// + /// The concrete value of `callback` depends on the rendering backend used. For instance, the + /// `glow` backend requires that callback be an `egui_glow::CallbackFn` while the `wgpu` + /// backend requires a `egui_wgpu::Callback`. + /// + /// If the type cannot be downcast to the type expected by the current backend the callback + /// will not be drawn. + /// + /// The rendering backend is responsible for first setting the active viewport to + /// [`Self::rect`]. + /// + /// The rendering backend is also responsible for restoring any state, such as the bound shader + /// program, vertex array, etc. + /// + /// Shape has to be clone, therefore this has to be an `Arc` instead of a `Box`. + pub callback: Arc, +} + +impl std::fmt::Debug for PaintCallback { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CustomShape") + .field("rect", &self.rect) + .finish_non_exhaustive() + } +} + +impl std::cmp::PartialEq for PaintCallback { + fn eq(&self, other: &Self) -> bool { + self.rect.eq(&other.rect) && Arc::ptr_eq(&self.callback, &other.callback) + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: PaintCallback) -> Self { + Self::Callback(shape) + } +} diff --git a/crates/epaint/src/shapes/path_shape.rs b/crates/epaint/src/shapes/path_shape.rs new file mode 100644 index 000000000..8486055a1 --- /dev/null +++ b/crates/epaint/src/shapes/path_shape.rs @@ -0,0 +1,81 @@ +use crate::*; + +/// A path which can be stroked and/or filled (if closed). +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct PathShape { + /// Filled paths should prefer clockwise order. + pub points: Vec, + + /// If true, connect the first and last of the points together. + /// This is required if `fill != TRANSPARENT`. + pub closed: bool, + + /// Fill is only supported for convex polygons. + pub fill: Color32, + + /// Color and thickness of the line. + pub stroke: PathStroke, + // TODO(emilk): Add texture support either by supplying uv for each point, + // or by some transform from points to uv (e.g. a callback or a linear transform matrix). +} + +impl PathShape { + /// A line through many points. + /// + /// Use [`Shape::line_segment`] instead if your line only connects two points. + #[inline] + pub fn line(points: Vec, stroke: impl Into) -> Self { + Self { + points, + closed: false, + fill: Default::default(), + stroke: stroke.into(), + } + } + + /// A line that closes back to the start point again. + #[inline] + pub fn closed_line(points: Vec, stroke: impl Into) -> Self { + Self { + points, + closed: true, + fill: Default::default(), + stroke: stroke.into(), + } + } + + /// A convex polygon with a fill and optional stroke. + /// + /// The most performant winding order is clockwise. + #[inline] + pub fn convex_polygon( + points: Vec, + fill: impl Into, + stroke: impl Into, + ) -> Self { + Self { + points, + closed: true, + fill: fill.into(), + stroke: stroke.into(), + } + } + + /// The visual bounding rectangle (includes stroke width) + #[inline] + pub fn visual_bounding_rect(&self) -> Rect { + if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() { + Rect::NOTHING + } else { + Rect::from_points(&self.points).expand(self.stroke.width / 2.0) + } + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: PathShape) -> Self { + Self::Path(shape) + } +} diff --git a/crates/epaint/src/shapes/rect_shape.rs b/crates/epaint/src/shapes/rect_shape.rs new file mode 100644 index 000000000..a36ae08c9 --- /dev/null +++ b/crates/epaint/src/shapes/rect_shape.rs @@ -0,0 +1,125 @@ +use crate::*; + +/// How to paint a rectangle. +#[derive(Copy, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct RectShape { + pub rect: Rect, + + /// How rounded the corners are. Use `Rounding::ZERO` for no rounding. + pub rounding: Rounding, + + /// How to fill the rectangle. + pub fill: Color32, + + /// The thickness and color of the outline. + /// + /// The stroke extends _outside_ the edge of [`Self::rect`], + /// i.e. using [`crate::StrokeKind::Outside`]. + /// + /// This means the [`Self::visual_bounding_rect`] is `rect.size() + 2.0 * stroke.width`. + pub stroke: Stroke, + + /// If larger than zero, the edges of the rectangle + /// (for both fill and stroke) will be blurred. + /// + /// This can be used to produce shadows and glow effects. + /// + /// The blur is currently implemented using a simple linear blur in sRGBA gamma space. + pub blur_width: f32, + + /// If the rect should be filled with a texture, which one? + /// + /// The texture is multiplied with [`Self::fill`]. + pub fill_texture_id: TextureId, + + /// What UV coordinates to use for the texture? + /// + /// To display a texture, set [`Self::fill_texture_id`], + /// and set this to `Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0))`. + /// + /// Use [`Rect::ZERO`] to turn off texturing. + pub uv: Rect, +} + +impl RectShape { + /// The stroke extends _outside_ the [`Rect`]. + #[inline] + pub fn new( + rect: Rect, + rounding: impl Into, + fill_color: impl Into, + stroke: impl Into, + ) -> Self { + Self { + rect, + rounding: rounding.into(), + fill: fill_color.into(), + stroke: stroke.into(), + blur_width: 0.0, + fill_texture_id: Default::default(), + uv: Rect::ZERO, + } + } + + #[inline] + pub fn filled( + rect: Rect, + rounding: impl Into, + fill_color: impl Into, + ) -> Self { + Self { + rect, + rounding: rounding.into(), + fill: fill_color.into(), + stroke: Default::default(), + blur_width: 0.0, + fill_texture_id: Default::default(), + uv: Rect::ZERO, + } + } + + /// The stroke extends _outside_ the [`Rect`]. + #[inline] + pub fn stroke(rect: Rect, rounding: impl Into, stroke: impl Into) -> Self { + Self { + rect, + rounding: rounding.into(), + fill: Default::default(), + stroke: stroke.into(), + blur_width: 0.0, + fill_texture_id: Default::default(), + uv: Rect::ZERO, + } + } + + /// If larger than zero, the edges of the rectangle + /// (for both fill and stroke) will be blurred. + /// + /// This can be used to produce shadows and glow effects. + /// + /// The blur is currently implemented using a simple linear blur in `sRGBA` gamma space. + #[inline] + pub fn with_blur_width(mut self, blur_width: f32) -> Self { + self.blur_width = blur_width; + self + } + + /// The visual bounding rectangle (includes stroke width) + #[inline] + pub fn visual_bounding_rect(&self) -> Rect { + if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() { + Rect::NOTHING + } else { + let Stroke { width, .. } = self.stroke; // Make sure we remember to update this if we change `stroke` to `PathStroke` + self.rect.expand(width + self.blur_width / 2.0) + } + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: RectShape) -> Self { + Self::Rect(shape) + } +} diff --git a/crates/epaint/src/shapes/shape.rs b/crates/epaint/src/shapes/shape.rs new file mode 100644 index 000000000..945e5563d --- /dev/null +++ b/crates/epaint/src/shapes/shape.rs @@ -0,0 +1,540 @@ +//! The different shapes that can be painted. + +use std::sync::Arc; + +use emath::{pos2, Align2, Pos2, Rangef, Rect, TSTransform, Vec2}; + +use crate::{ + stroke::PathStroke, + text::{FontId, Fonts, Galley}, + Color32, Mesh, Rounding, Stroke, TextureId, +}; + +use super::{ + CircleShape, CubicBezierShape, EllipseShape, PaintCallback, PathShape, QuadraticBezierShape, + RectShape, TextShape, +}; + +/// A paint primitive such as a circle or a piece of text. +/// Coordinates are all screen space points (not physical pixels). +/// +/// You should generally recreate your [`Shape`]s each frame, +/// but storing them should also be fine with one exception: +/// [`Shape::Text`] depends on the current `pixels_per_point` (dpi scale) +/// and so must be recreated every time `pixels_per_point` changes. +#[must_use = "Add a Shape to a Painter"] +#[derive(Clone, Debug, PartialEq)] +pub enum Shape { + /// Paint nothing. This can be useful as a placeholder. + Noop, + + /// Recursively nest more shapes - sometimes a convenience to be able to do. + /// For performance reasons it is better to avoid it. + Vec(Vec), + + /// Circle with optional outline and fill. + Circle(CircleShape), + + /// Ellipse with optional outline and fill. + Ellipse(EllipseShape), + + /// A line between two points. + LineSegment { points: [Pos2; 2], stroke: Stroke }, + + /// A series of lines between points. + /// The path can have a stroke and/or fill (if closed). + Path(PathShape), + + /// Rectangle with optional outline and fill. + Rect(RectShape), + + /// Text. + /// + /// This needs to be recreated if `pixels_per_point` (dpi scale) changes. + Text(TextShape), + + /// A general triangle mesh. + /// + /// Can be used to display images. + Mesh(Mesh), + + /// A quadratic [Bézier Curve](https://en.wikipedia.org/wiki/B%C3%A9zier_curve). + QuadraticBezier(QuadraticBezierShape), + + /// A cubic [Bézier Curve](https://en.wikipedia.org/wiki/B%C3%A9zier_curve). + CubicBezier(CubicBezierShape), + + /// Backend-specific painting. + Callback(PaintCallback), +} + +#[test] +fn shape_impl_send_sync() { + fn assert_send_sync() {} + assert_send_sync::(); +} + +impl From> for Shape { + #[inline(always)] + fn from(shapes: Vec) -> Self { + Self::Vec(shapes) + } +} + +impl From for Shape { + #[inline(always)] + fn from(mesh: Mesh) -> Self { + Self::Mesh(mesh) + } +} + +/// ## Constructors +impl Shape { + /// A line between two points. + /// More efficient than calling [`Self::line`]. + #[inline] + pub fn line_segment(points: [Pos2; 2], stroke: impl Into) -> Self { + Self::LineSegment { + points, + stroke: stroke.into(), + } + } + + /// A horizontal line. + pub fn hline(x: impl Into, y: f32, stroke: impl Into) -> Self { + let x = x.into(); + Self::LineSegment { + points: [pos2(x.min, y), pos2(x.max, y)], + stroke: stroke.into(), + } + } + + /// A vertical line. + pub fn vline(x: f32, y: impl Into, stroke: impl Into) -> Self { + let y = y.into(); + Self::LineSegment { + points: [pos2(x, y.min), pos2(x, y.max)], + stroke: stroke.into(), + } + } + + /// A line through many points. + /// + /// Use [`Self::line_segment`] instead if your line only connects two points. + #[inline] + pub fn line(points: Vec, stroke: impl Into) -> Self { + Self::Path(PathShape::line(points, stroke)) + } + + /// A line that closes back to the start point again. + #[inline] + pub fn closed_line(points: Vec, stroke: impl Into) -> Self { + Self::Path(PathShape::closed_line(points, stroke)) + } + + /// Turn a line into equally spaced dots. + pub fn dotted_line( + path: &[Pos2], + color: impl Into, + spacing: f32, + radius: f32, + ) -> Vec { + let mut shapes = Vec::new(); + points_from_line(path, spacing, radius, color.into(), &mut shapes); + shapes + } + + /// Turn a line into dashes. + pub fn dashed_line( + path: &[Pos2], + stroke: impl Into, + dash_length: f32, + gap_length: f32, + ) -> Vec { + let mut shapes = Vec::new(); + dashes_from_line( + path, + stroke.into(), + &[dash_length], + &[gap_length], + &mut shapes, + 0., + ); + shapes + } + + /// Turn a line into dashes with different dash/gap lengths and a start offset. + pub fn dashed_line_with_offset( + path: &[Pos2], + stroke: impl Into, + dash_lengths: &[f32], + gap_lengths: &[f32], + dash_offset: f32, + ) -> Vec { + let mut shapes = Vec::new(); + dashes_from_line( + path, + stroke.into(), + dash_lengths, + gap_lengths, + &mut shapes, + dash_offset, + ); + shapes + } + + /// Turn a line into dashes. If you need to create many dashed lines use this instead of + /// [`Self::dashed_line`]. + pub fn dashed_line_many( + points: &[Pos2], + stroke: impl Into, + dash_length: f32, + gap_length: f32, + shapes: &mut Vec, + ) { + dashes_from_line( + points, + stroke.into(), + &[dash_length], + &[gap_length], + shapes, + 0., + ); + } + + /// Turn a line into dashes with different dash/gap lengths and a start offset. If you need to + /// create many dashed lines use this instead of [`Self::dashed_line_with_offset`]. + pub fn dashed_line_many_with_offset( + points: &[Pos2], + stroke: impl Into, + dash_lengths: &[f32], + gap_lengths: &[f32], + dash_offset: f32, + shapes: &mut Vec, + ) { + dashes_from_line( + points, + stroke.into(), + dash_lengths, + gap_lengths, + shapes, + dash_offset, + ); + } + + /// A convex polygon with a fill and optional stroke. + /// + /// The most performant winding order is clockwise. + #[inline] + pub fn convex_polygon( + points: Vec, + fill: impl Into, + stroke: impl Into, + ) -> Self { + Self::Path(PathShape::convex_polygon(points, fill, stroke)) + } + + #[inline] + pub fn circle_filled(center: Pos2, radius: f32, fill_color: impl Into) -> Self { + Self::Circle(CircleShape::filled(center, radius, fill_color)) + } + + #[inline] + pub fn circle_stroke(center: Pos2, radius: f32, stroke: impl Into) -> Self { + Self::Circle(CircleShape::stroke(center, radius, stroke)) + } + + #[inline] + pub fn ellipse_filled(center: Pos2, radius: Vec2, fill_color: impl Into) -> Self { + Self::Ellipse(EllipseShape::filled(center, radius, fill_color)) + } + + #[inline] + pub fn ellipse_stroke(center: Pos2, radius: Vec2, stroke: impl Into) -> Self { + Self::Ellipse(EllipseShape::stroke(center, radius, stroke)) + } + + #[inline] + pub fn rect_filled( + rect: Rect, + rounding: impl Into, + fill_color: impl Into, + ) -> Self { + Self::Rect(RectShape::filled(rect, rounding, fill_color)) + } + + /// The stroke extends _outside_ the [`Rect`]. + #[inline] + pub fn rect_stroke( + rect: Rect, + rounding: impl Into, + stroke: impl Into, + ) -> Self { + Self::Rect(RectShape::stroke(rect, rounding, stroke)) + } + + #[allow(clippy::needless_pass_by_value)] + pub fn text( + fonts: &Fonts, + pos: Pos2, + anchor: Align2, + text: impl ToString, + font_id: FontId, + color: Color32, + ) -> Self { + let galley = fonts.layout_no_wrap(text.to_string(), font_id, color); + let rect = anchor.anchor_size(pos, galley.size()); + Self::galley(rect.min, galley, color) + } + + /// Any uncolored parts of the [`Galley`] (using [`Color32::PLACEHOLDER`]) will be replaced with the given color. + /// + /// Any non-placeholder color in the galley takes precedence over this fallback color. + #[inline] + pub fn galley(pos: Pos2, galley: Arc, fallback_color: Color32) -> Self { + TextShape::new(pos, galley, fallback_color).into() + } + + /// All text color in the [`Galley`] will be replaced with the given color. + #[inline] + pub fn galley_with_override_text_color( + pos: Pos2, + galley: Arc, + text_color: Color32, + ) -> Self { + TextShape::new(pos, galley, text_color) + .with_override_text_color(text_color) + .into() + } + + #[inline] + #[deprecated = "Use `Shape::galley` or `Shape::galley_with_override_text_color` instead"] + pub fn galley_with_color(pos: Pos2, galley: Arc, text_color: Color32) -> Self { + Self::galley_with_override_text_color(pos, galley, text_color) + } + + #[inline] + pub fn mesh(mesh: Mesh) -> Self { + debug_assert!(mesh.is_valid()); + Self::Mesh(mesh) + } + + /// An image at the given position. + /// + /// `uv` should normally be `Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0))` + /// unless you want to crop or flip the image. + /// + /// `tint` is a color multiplier. Use [`Color32::WHITE`] if you don't want to tint the image. + pub fn image(texture_id: TextureId, rect: Rect, uv: Rect, tint: Color32) -> Self { + let mut mesh = Mesh::with_texture(texture_id); + mesh.add_rect_with_uv(rect, uv, tint); + Self::mesh(mesh) + } + + /// The visual bounding rectangle (includes stroke widths) + pub fn visual_bounding_rect(&self) -> Rect { + match self { + Self::Noop => Rect::NOTHING, + Self::Vec(shapes) => { + let mut rect = Rect::NOTHING; + for shape in shapes { + rect = rect.union(shape.visual_bounding_rect()); + } + rect + } + Self::Circle(circle_shape) => circle_shape.visual_bounding_rect(), + Self::Ellipse(ellipse_shape) => ellipse_shape.visual_bounding_rect(), + Self::LineSegment { points, stroke } => { + if stroke.is_empty() { + Rect::NOTHING + } else { + Rect::from_two_pos(points[0], points[1]).expand(stroke.width / 2.0) + } + } + Self::Path(path_shape) => path_shape.visual_bounding_rect(), + Self::Rect(rect_shape) => rect_shape.visual_bounding_rect(), + Self::Text(text_shape) => text_shape.visual_bounding_rect(), + Self::Mesh(mesh) => mesh.calc_bounds(), + Self::QuadraticBezier(bezier) => bezier.visual_bounding_rect(), + Self::CubicBezier(bezier) => bezier.visual_bounding_rect(), + Self::Callback(custom) => custom.rect, + } + } +} + +/// ## Inspection and transforms +impl Shape { + #[inline(always)] + pub fn texture_id(&self) -> crate::TextureId { + if let Self::Mesh(mesh) = self { + mesh.texture_id + } else if let Self::Rect(rect_shape) = self { + rect_shape.fill_texture_id + } else { + crate::TextureId::default() + } + } + + /// Scale the shape by `factor`, in-place. + /// + /// A wrapper around [`Self::transform`]. + #[inline(always)] + pub fn scale(&mut self, factor: f32) { + self.transform(TSTransform::from_scaling(factor)); + } + + /// Move the shape by `delta`, in-place. + /// + /// A wrapper around [`Self::transform`]. + #[inline(always)] + pub fn translate(&mut self, delta: Vec2) { + self.transform(TSTransform::from_translation(delta)); + } + + /// Move the shape by this many points, in-place. + /// + /// If using a [`PaintCallback`], note that only the rect is scaled as opposed + /// to other shapes where the stroke is also scaled. + pub fn transform(&mut self, transform: TSTransform) { + match self { + Self::Noop => {} + Self::Vec(shapes) => { + for shape in shapes { + shape.transform(transform); + } + } + Self::Circle(circle_shape) => { + circle_shape.center = transform * circle_shape.center; + circle_shape.radius *= transform.scaling; + circle_shape.stroke.width *= transform.scaling; + } + Self::Ellipse(ellipse_shape) => { + ellipse_shape.center = transform * ellipse_shape.center; + ellipse_shape.radius *= transform.scaling; + ellipse_shape.stroke.width *= transform.scaling; + } + Self::LineSegment { points, stroke } => { + for p in points { + *p = transform * *p; + } + stroke.width *= transform.scaling; + } + Self::Path(path_shape) => { + for p in &mut path_shape.points { + *p = transform * *p; + } + path_shape.stroke.width *= transform.scaling; + } + Self::Rect(rect_shape) => { + rect_shape.rect = transform * rect_shape.rect; + rect_shape.stroke.width *= transform.scaling; + rect_shape.rounding *= transform.scaling; + } + Self::Text(text_shape) => { + text_shape.pos = transform * text_shape.pos; + + // Scale text: + let galley = Arc::make_mut(&mut text_shape.galley); + for row in &mut galley.rows { + row.visuals.mesh_bounds = transform.scaling * row.visuals.mesh_bounds; + for v in &mut row.visuals.mesh.vertices { + v.pos = Pos2::new(transform.scaling * v.pos.x, transform.scaling * v.pos.y); + } + } + + galley.mesh_bounds = transform.scaling * galley.mesh_bounds; + galley.rect = transform.scaling * galley.rect; + } + Self::Mesh(mesh) => { + mesh.transform(transform); + } + Self::QuadraticBezier(bezier_shape) => { + bezier_shape.points[0] = transform * bezier_shape.points[0]; + bezier_shape.points[1] = transform * bezier_shape.points[1]; + bezier_shape.points[2] = transform * bezier_shape.points[2]; + bezier_shape.stroke.width *= transform.scaling; + } + Self::CubicBezier(cubic_curve) => { + for p in &mut cubic_curve.points { + *p = transform * *p; + } + cubic_curve.stroke.width *= transform.scaling; + } + Self::Callback(shape) => { + shape.rect = transform * shape.rect; + } + } + } +} + +// ---------------------------------------------------------------------------- + +/// Creates equally spaced filled circles from a line. +fn points_from_line( + path: &[Pos2], + spacing: f32, + radius: f32, + color: Color32, + shapes: &mut Vec, +) { + let mut position_on_segment = 0.0; + path.windows(2).for_each(|window| { + let (start, end) = (window[0], window[1]); + let vector = end - start; + let segment_length = vector.length(); + while position_on_segment < segment_length { + let new_point = start + vector * (position_on_segment / segment_length); + shapes.push(Shape::circle_filled(new_point, radius, color)); + position_on_segment += spacing; + } + position_on_segment -= segment_length; + }); +} + +/// Creates dashes from a line. +fn dashes_from_line( + path: &[Pos2], + stroke: Stroke, + dash_lengths: &[f32], + gap_lengths: &[f32], + shapes: &mut Vec, + dash_offset: f32, +) { + assert_eq!(dash_lengths.len(), gap_lengths.len()); + let mut position_on_segment = dash_offset; + let mut drawing_dash = false; + let mut step = 0; + let steps = dash_lengths.len(); + path.windows(2).for_each(|window| { + let (start, end) = (window[0], window[1]); + let vector = end - start; + let segment_length = vector.length(); + + let mut start_point = start; + while position_on_segment < segment_length { + let new_point = start + vector * (position_on_segment / segment_length); + if drawing_dash { + // This is the end point. + shapes.push(Shape::line_segment([start_point, new_point], stroke)); + position_on_segment += gap_lengths[step]; + // Increment step counter + step += 1; + if step >= steps { + step = 0; + } + } else { + // Start a new dash. + start_point = new_point; + position_on_segment += dash_lengths[step]; + } + drawing_dash = !drawing_dash; + } + + // If the segment ends and the dash is not finished, add the segment's end point. + if drawing_dash { + shapes.push(Shape::line_segment([start_point, end], stroke)); + } + + position_on_segment -= segment_length; + }); +} diff --git a/crates/epaint/src/shapes/text_shape.rs b/crates/epaint/src/shapes/text_shape.rs new file mode 100644 index 000000000..30287187f --- /dev/null +++ b/crates/epaint/src/shapes/text_shape.rs @@ -0,0 +1,97 @@ +use std::sync::Arc; + +use crate::*; + +/// How to paint some text on screen. +/// +/// This needs to be recreated if `pixels_per_point` (dpi scale) changes. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct TextShape { + /// Top left corner of the first character. + pub pos: Pos2, + + /// The laid out text, from [`Fonts::layout_job`]. + pub galley: Arc, + + /// Add this underline to the whole text. + /// You can also set an underline when creating the galley. + pub underline: Stroke, + + /// Any [`Color32::PLACEHOLDER`] in the galley will be replaced by the given color. + /// Affects everything: backgrounds, glyphs, strikethrough, underline, etc. + pub fallback_color: Color32, + + /// If set, the text color in the galley will be ignored and replaced + /// with the given color. + /// + /// This only affects the glyphs and will NOT replace background color nor strikethrough/underline color. + pub override_text_color: Option, + + /// If set, the text will be rendered with the given opacity in gamma space + /// Affects everything: backgrounds, glyphs, strikethrough, underline, etc. + pub opacity_factor: f32, + + /// Rotate text by this many radians clockwise. + /// The pivot is `pos` (the upper left corner of the text). + pub angle: f32, +} + +impl TextShape { + /// The given fallback color will be used for any uncolored part of the galley (using [`Color32::PLACEHOLDER`]). + /// + /// Any non-placeholder color in the galley takes precedence over this fallback color. + #[inline] + pub fn new(pos: Pos2, galley: Arc, fallback_color: Color32) -> Self { + Self { + pos, + galley, + underline: Stroke::NONE, + fallback_color, + override_text_color: None, + opacity_factor: 1.0, + angle: 0.0, + } + } + + /// The visual bounding rectangle + #[inline] + pub fn visual_bounding_rect(&self) -> Rect { + self.galley.mesh_bounds.translate(self.pos.to_vec2()) + } + + #[inline] + pub fn with_underline(mut self, underline: Stroke) -> Self { + self.underline = underline; + self + } + + /// Use the given color for the text, regardless of what color is already in the galley. + #[inline] + pub fn with_override_text_color(mut self, override_text_color: Color32) -> Self { + self.override_text_color = Some(override_text_color); + self + } + + /// Rotate text by this many radians clockwise. + /// The pivot is `pos` (the upper left corner of the text). + #[inline] + pub fn with_angle(mut self, angle: f32) -> Self { + self.angle = angle; + self + } + + /// Render text with this opacity in gamma space + #[inline] + pub fn with_opacity_factor(mut self, opacity_factor: f32) -> Self { + self.opacity_factor = opacity_factor; + self + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: TextShape) -> Self { + Self::Text(shape) + } +} diff --git a/crates/epaint/src/viewport.rs b/crates/epaint/src/viewport.rs new file mode 100644 index 000000000..01011b5c8 --- /dev/null +++ b/crates/epaint/src/viewport.rs @@ -0,0 +1,54 @@ +use crate::Rect; + +/// Size of the viewport in whole, physical pixels. +pub struct ViewportInPixels { + /// Physical pixel offset for left side of the viewport. + pub left_px: i32, + + /// Physical pixel offset for top side of the viewport. + pub top_px: i32, + + /// Physical pixel offset for bottom side of the viewport. + /// + /// This is what `glViewport`, `glScissor` etc expects for the y axis. + pub from_bottom_px: i32, + + /// Viewport width in physical pixels. + pub width_px: i32, + + /// Viewport height in physical pixels. + pub height_px: i32, +} + +impl ViewportInPixels { + /// Convert from ui points. + pub fn from_points(rect: &Rect, pixels_per_point: f32, screen_size_px: [u32; 2]) -> Self { + // Fractional pixel values for viewports are generally valid, but may cause sampling issues + // and rounding errors might cause us to get out of bounds. + + // Round: + let left_px = (pixels_per_point * rect.min.x).round() as i32; // inclusive + let top_px = (pixels_per_point * rect.min.y).round() as i32; // inclusive + let right_px = (pixels_per_point * rect.max.x).round() as i32; // exclusive + let bottom_px = (pixels_per_point * rect.max.y).round() as i32; // exclusive + + // Clamp to screen: + let screen_width = screen_size_px[0] as i32; + let screen_height = screen_size_px[1] as i32; + let left_px = left_px.clamp(0, screen_width); + let right_px = right_px.clamp(left_px, screen_width); + let top_px = top_px.clamp(0, screen_height); + let bottom_px = bottom_px.clamp(top_px, screen_height); + + let width_px = right_px - left_px; + let height_px = bottom_px - top_px; + + Self { + left_px, + top_px, + from_bottom_px: screen_height - height_px - top_px, + width_px, + height_px, + } + } +} From 64f077588cc911378db98d791c76079a966908ca Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Thu, 2 Jan 2025 14:56:27 +0100 Subject: [PATCH 3/9] Improve kittest snapshot output: print absolute path to diff file --- crates/egui_kittest/src/snapshot.rs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/crates/egui_kittest/src/snapshot.rs b/crates/egui_kittest/src/snapshot.rs index 5be6c2754..f4d480b64 100644 --- a/crates/egui_kittest/src/snapshot.rs +++ b/crates/egui_kittest/src/snapshot.rs @@ -106,24 +106,28 @@ impl Display for SnapshotError { diff, diff_path, } => { + let diff_path = std::path::absolute(diff_path).unwrap_or(diff_path.clone()); write!( f, "'{name}' Image did not match snapshot. Diff: {diff}, {diff_path:?}. {HOW_TO_UPDATE_SCREENSHOTS}" ) } - Self::OpenSnapshot { path, err } => match err { - ImageError::IoError(io) => match io.kind() { - ErrorKind::NotFound => { - write!(f, "Missing snapshot: {path:?}. {HOW_TO_UPDATE_SCREENSHOTS}") - } + Self::OpenSnapshot { path, err } => { + let path = std::path::absolute(path).unwrap_or(path.clone()); + match err { + ImageError::IoError(io) => match io.kind() { + ErrorKind::NotFound => { + write!(f, "Missing snapshot: {path:?}. {HOW_TO_UPDATE_SCREENSHOTS}") + } + err => { + write!(f, "Error reading snapshot: {err:?}\nAt: {path:?}. {HOW_TO_UPDATE_SCREENSHOTS}") + } + }, err => { - write!(f, "Error reading snapshot: {err:?}\nAt: {path:?}. {HOW_TO_UPDATE_SCREENSHOTS}") + write!(f, "Error decoding snapshot: {err:?}\nAt: {path:?}. Make sure git-lfs is setup correctly. Read the instructions here: https://github.com/emilk/egui/blob/master/CONTRIBUTING.md#making-a-pr") } - }, - err => { - write!(f, "Error decoding snapshot: {err:?}\nAt: {path:?}. Make sure git-lfs is setup correctly. Read the instructions here: https://github.com/emilk/egui/blob/master/CONTRIBUTING.md#making-a-pr") } - }, + } Self::SizeMismatch { name, expected, @@ -135,6 +139,7 @@ impl Display for SnapshotError { ) } Self::WriteSnapshot { path, err } => { + let path = std::path::absolute(path).unwrap_or(path.clone()); write!(f, "Error writing snapshot: {err:?}\nAt: {path:?}") } } From 72ac2113ddf1763498436c5898fe9e15d53e5a6a Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Thu, 2 Jan 2025 15:32:43 +0100 Subject: [PATCH 4/9] Fix stroke of custom_window_frame example --- examples/custom_window_frame/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/custom_window_frame/src/main.rs b/examples/custom_window_frame/src/main.rs index de3658594..86e82759f 100644 --- a/examples/custom_window_frame/src/main.rs +++ b/examples/custom_window_frame/src/main.rs @@ -49,7 +49,7 @@ fn custom_window_frame(ctx: &egui::Context, title: &str, add_contents: impl FnOn fill: ctx.style().visuals.window_fill(), rounding: 10.0.into(), stroke: ctx.style().visuals.widgets.noninteractive.fg_stroke, - outer_margin: 0.5.into(), // so the stroke is within the bounds + outer_margin: 1.0.into(), // so the stroke is within the bounds ..Default::default() }; From aeea70d9e768488605fb866dd14bbf0e91a75d61 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Thu, 2 Jan 2025 15:34:28 +0100 Subject: [PATCH 5/9] Add `epaint::Brush` for controlling `RectShape` texturing (#5565) Also wraps `Shape::Mesh` in an `Arc`. No new features, but decreases size of `Shape` from 72 bytes to 64. --- crates/egui/src/widgets/image.rs | 15 ++---- crates/epaint/src/brush.rs | 19 +++++++ crates/epaint/src/lib.rs | 2 + crates/epaint/src/shape_transform.rs | 17 +++--- crates/epaint/src/shapes/rect_shape.rs | 72 +++++++++++++++----------- crates/epaint/src/shapes/shape.rs | 30 +++++++++-- crates/epaint/src/tessellator.rs | 14 +++-- 7 files changed, 111 insertions(+), 58 deletions(-) create mode 100644 crates/epaint/src/brush.rs diff --git a/crates/egui/src/widgets/image.rs b/crates/egui/src/widgets/image.rs index 3abcf5fd5..d1976a12c 100644 --- a/crates/egui/src/widgets/image.rs +++ b/crates/egui/src/widgets/image.rs @@ -9,7 +9,7 @@ use epaint::{ use crate::{ load::{Bytes, SizeHint, SizedTexture, TextureLoadResult, TexturePoll}, pos2, Color32, Context, Id, Mesh, Painter, Rect, Response, Rounding, Sense, Shape, Spinner, - Stroke, TextStyle, TextureOptions, Ui, Vec2, Widget, WidgetInfo, WidgetType, + TextStyle, TextureOptions, Ui, Vec2, Widget, WidgetInfo, WidgetType, }; /// A widget which displays an image. @@ -822,15 +822,10 @@ pub fn paint_texture_at( painter.add(Shape::mesh(mesh)); } None => { - painter.add(RectShape { - rect, - rounding: options.rounding, - fill: options.tint, - stroke: Stroke::NONE, - blur_width: 0.0, - fill_texture_id: texture.id, - uv: options.uv, - }); + painter.add( + RectShape::filled(rect, options.rounding, options.tint) + .with_texture(texture.id, options.uv), + ); } } } diff --git a/crates/epaint/src/brush.rs b/crates/epaint/src/brush.rs new file mode 100644 index 000000000..a414194a1 --- /dev/null +++ b/crates/epaint/src/brush.rs @@ -0,0 +1,19 @@ +use crate::{Rect, TextureId}; + +/// Controls texturing of a [`crate::RectShape`]. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Brush { + /// If the rect should be filled with a texture, which one? + /// + /// The texture is multiplied with [`crate::RectShape::fill`]. + pub fill_texture_id: TextureId, + + /// What UV coordinates to use for the texture? + /// + /// To display a texture, set [`Self::fill_texture_id`], + /// and set this to `Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0))`. + /// + /// Use [`Rect::ZERO`] to turn off texturing. + pub uv: Rect, +} diff --git a/crates/epaint/src/lib.rs b/crates/epaint/src/lib.rs index 5aeb96762..226821c16 100644 --- a/crates/epaint/src/lib.rs +++ b/crates/epaint/src/lib.rs @@ -23,6 +23,7 @@ #![allow(clippy::float_cmp)] #![allow(clippy::manual_range_contains)] +mod brush; pub mod color; pub mod image; mod margin; @@ -44,6 +45,7 @@ pub mod util; mod viewport; pub use self::{ + brush::Brush, color::ColorMode, image::{ColorImage, FontImage, ImageData, ImageDelta}, margin::Margin, diff --git a/crates/epaint/src/shape_transform.rs b/crates/epaint/src/shape_transform.rs index 263f9cf07..25ff0d469 100644 --- a/crates/epaint/src/shape_transform.rs +++ b/crates/epaint/src/shape_transform.rs @@ -64,8 +64,7 @@ pub fn adjust_colors( fill, stroke, blur_width: _, - fill_texture_id: _, - uv: _, + brush: _, }) => { adjust_color(fill); adjust_color(&mut stroke.color); @@ -87,7 +86,7 @@ pub fn adjust_colors( } if !galley.is_empty() { - let galley = std::sync::Arc::make_mut(galley); + let galley = Arc::make_mut(galley); for row in &mut galley.rows { for vertex in &mut row.visuals.mesh.vertices { adjust_color(&mut vertex.color); @@ -96,11 +95,13 @@ pub fn adjust_colors( } } - Shape::Mesh(Mesh { - indices: _, - vertices, - texture_id: _, - }) => { + Shape::Mesh(mesh) => { + let Mesh { + indices: _, + vertices, + texture_id: _, + } = Arc::make_mut(mesh); + for v in vertices { adjust_color(&mut v.color); } diff --git a/crates/epaint/src/shapes/rect_shape.rs b/crates/epaint/src/shapes/rect_shape.rs index a36ae08c9..b0750aa89 100644 --- a/crates/epaint/src/shapes/rect_shape.rs +++ b/crates/epaint/src/shapes/rect_shape.rs @@ -1,7 +1,9 @@ +use std::sync::Arc; + use crate::*; /// How to paint a rectangle. -#[derive(Copy, Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct RectShape { pub rect: Rect, @@ -28,18 +30,23 @@ pub struct RectShape { /// The blur is currently implemented using a simple linear blur in sRGBA gamma space. pub blur_width: f32, - /// If the rect should be filled with a texture, which one? + /// Controls texturing, if any. /// - /// The texture is multiplied with [`Self::fill`]. - pub fill_texture_id: TextureId, + /// Since most rectangles do not have a texture, this is optional and in an `Arc`, + /// so that [`RectShape`] is kept small.. + pub brush: Option>, +} - /// What UV coordinates to use for the texture? - /// - /// To display a texture, set [`Self::fill_texture_id`], - /// and set this to `Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0))`. - /// - /// Use [`Rect::ZERO`] to turn off texturing. - pub uv: Rect, +#[test] +fn rect_shape_size() { + assert_eq!( + std::mem::size_of::(), 48, + "RectShape changed size! If it shrank - good! Update this test. If it grew - bad! Try to find a way to avoid it." + ); + assert!( + std::mem::size_of::() <= 64, + "RectShape is getting way too big!" + ); } impl RectShape { @@ -57,8 +64,7 @@ impl RectShape { fill: fill_color.into(), stroke: stroke.into(), blur_width: 0.0, - fill_texture_id: Default::default(), - uv: Rect::ZERO, + brush: Default::default(), } } @@ -68,29 +74,14 @@ impl RectShape { rounding: impl Into, fill_color: impl Into, ) -> Self { - Self { - rect, - rounding: rounding.into(), - fill: fill_color.into(), - stroke: Default::default(), - blur_width: 0.0, - fill_texture_id: Default::default(), - uv: Rect::ZERO, - } + Self::new(rect, rounding, fill_color, Stroke::NONE) } /// The stroke extends _outside_ the [`Rect`]. #[inline] pub fn stroke(rect: Rect, rounding: impl Into, stroke: impl Into) -> Self { - Self { - rect, - rounding: rounding.into(), - fill: Default::default(), - stroke: stroke.into(), - blur_width: 0.0, - fill_texture_id: Default::default(), - uv: Rect::ZERO, - } + let fill = Color32::TRANSPARENT; + Self::new(rect, rounding, fill, stroke) } /// If larger than zero, the edges of the rectangle @@ -105,6 +96,16 @@ impl RectShape { self } + /// Set the texture to use when painting this rectangle, if any. + #[inline] + pub fn with_texture(mut self, fill_texture_id: TextureId, uv: Rect) -> Self { + self.brush = Some(Arc::new(Brush { + fill_texture_id, + uv, + })); + self + } + /// The visual bounding rectangle (includes stroke width) #[inline] pub fn visual_bounding_rect(&self) -> Rect { @@ -115,6 +116,15 @@ impl RectShape { self.rect.expand(width + self.blur_width / 2.0) } } + + /// The texture to use when painting this rectangle, if any. + /// + /// If no texture is set, this will return [`TextureId::default`]. + pub fn fill_texture_id(&self) -> TextureId { + self.brush + .as_ref() + .map_or_else(TextureId::default, |brush| brush.fill_texture_id) + } } impl From for Shape { diff --git a/crates/epaint/src/shapes/shape.rs b/crates/epaint/src/shapes/shape.rs index 945e5563d..62af1b581 100644 --- a/crates/epaint/src/shapes/shape.rs +++ b/crates/epaint/src/shapes/shape.rs @@ -56,7 +56,9 @@ pub enum Shape { /// A general triangle mesh. /// /// Can be used to display images. - Mesh(Mesh), + /// + /// Wrapped in an [`Arc`] to minimize the size of [`Shape`]. + Mesh(Arc), /// A quadratic [Bézier Curve](https://en.wikipedia.org/wiki/B%C3%A9zier_curve). QuadraticBezier(QuadraticBezierShape), @@ -68,6 +70,18 @@ pub enum Shape { Callback(PaintCallback), } +#[test] +fn shape_size() { + assert_eq!( + std::mem::size_of::(), 64, + "Shape changed size! If it shrank - good! Update this test. If it grew - bad! Try to find a way to avoid it." + ); + assert!( + std::mem::size_of::() <= 64, + "Shape is getting way too big!" + ); +} + #[test] fn shape_impl_send_sync() { fn assert_send_sync() {} @@ -84,6 +98,13 @@ impl From> for Shape { impl From for Shape { #[inline(always)] fn from(mesh: Mesh) -> Self { + Self::Mesh(mesh.into()) + } +} + +impl From> for Shape { + #[inline(always)] + fn from(mesh: Arc) -> Self { Self::Mesh(mesh) } } @@ -314,7 +335,8 @@ impl Shape { } #[inline] - pub fn mesh(mesh: Mesh) -> Self { + pub fn mesh(mesh: impl Into>) -> Self { + let mesh = mesh.into(); debug_assert!(mesh.is_valid()); Self::Mesh(mesh) } @@ -369,7 +391,7 @@ impl Shape { if let Self::Mesh(mesh) = self { mesh.texture_id } else if let Self::Rect(rect_shape) = self { - rect_shape.fill_texture_id + rect_shape.fill_texture_id() } else { crate::TextureId::default() } @@ -446,7 +468,7 @@ impl Shape { galley.rect = transform.scaling * galley.rect; } Self::Mesh(mesh) => { - mesh.transform(transform); + Arc::make_mut(mesh).transform(transform); } Self::QuadraticBezier(bezier_shape) => { bezier_shape.points[0] = transform * bezier_shape.points[0]; diff --git a/crates/epaint/src/tessellator.rs b/crates/epaint/src/tessellator.rs index 5e8b131cf..57d30b11b 100644 --- a/crates/epaint/src/tessellator.rs +++ b/crates/epaint/src/tessellator.rs @@ -1406,7 +1406,7 @@ impl Tessellator { return; } - out.append(mesh); + out.append_ref(&mesh); } Shape::LineSegment { points, stroke } => { self.tessellate_line_segment(points, stroke, out); @@ -1693,14 +1693,14 @@ impl Tessellator { /// * `rect`: the rectangle to tessellate. /// * `out`: triangles are appended to this. pub fn tessellate_rect(&mut self, rect: &RectShape, out: &mut Mesh) { + let brush = rect.brush.as_ref(); let RectShape { mut rect, mut rounding, fill, stroke, mut blur_width, - fill_texture_id, - uv, + .. } = *rect; if self.options.coarse_tessellation_culling @@ -1775,7 +1775,11 @@ impl Tessellator { path.add_line_loop(&self.scratchpad_points); let path_stroke = PathStroke::from(stroke).outside(); - if uv.is_positive() { + if let Some(brush) = brush { + let crate::Brush { + fill_texture_id, + uv, + } = **brush; // Textured let uv_from_pos = |p: Pos2| { pos2( @@ -2173,7 +2177,7 @@ impl Tessellator { profiling::scope!("distribute results", tessellated.len().to_string()); for (index, mesh) in tessellated { - shapes[index].shape = Shape::Mesh(mesh); + shapes[index].shape = Shape::Mesh(mesh.into()); } } From d58d13781d2c35f6fe9b26c609efd1aca34fa084 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Thu, 2 Jan 2025 16:05:52 +0100 Subject: [PATCH 6/9] Store `Margin` using `i8` to reduce its size (#5567) Adds `Marginf` to fill the previous niche. This is all in a pursuit to shrink the sizes of often-used structs, to improve performance (less cache misses, less memcpy:s, etc). * On the path towards https://github.com/emilk/egui/issues/4019 --- crates/egui/src/containers/frame.rs | 28 +- crates/egui/src/containers/window.rs | 2 +- crates/egui/src/style.rs | 18 +- crates/egui/src/widgets/text_edit/builder.rs | 2 +- crates/egui_demo_lib/src/demo/pan_zoom.rs | 2 +- crates/egui_extras/src/datepicker/button.rs | 6 +- crates/epaint/src/lib.rs | 2 + crates/epaint/src/margin.rs | 173 ++++++----- crates/epaint/src/marginf.rs | 299 +++++++++++++++++++ crates/epaint/src/shadow.rs | 6 +- examples/custom_keypad/src/keypad.rs | 6 +- tests/test_ui_stack/src/main.rs | 12 +- 12 files changed, 449 insertions(+), 107 deletions(-) create mode 100644 crates/epaint/src/marginf.rs diff --git a/crates/egui/src/containers/frame.rs b/crates/egui/src/containers/frame.rs index cf1f5a3ac..6a526a7e9 100644 --- a/crates/egui/src/containers/frame.rs +++ b/crates/egui/src/containers/frame.rs @@ -4,7 +4,7 @@ use crate::{ epaint, layers::ShapeIdx, InnerResponse, Response, Sense, Style, Ui, UiBuilder, UiKind, UiStackInfo, }; -use epaint::{Color32, Margin, Rect, Rounding, Shadow, Shape, Stroke}; +use epaint::{Color32, Margin, Marginf, Rect, Rounding, Shadow, Shape, Stroke}; /// Add a background, frame and/or margin to a rectangular background of a [`Ui`]. /// @@ -73,6 +73,18 @@ pub struct Frame { pub stroke: Stroke, } +#[test] +fn frame_size() { + assert_eq!( + std::mem::size_of::(), 44, + "Frame changed size! If it shrank - good! Update this test. If it grew - bad! Try to find a way to avoid it." + ); + assert!( + std::mem::size_of::() <= 64, + "Frame is getting way too big!" + ); +} + impl Frame { pub fn none() -> Self { Self::default() @@ -81,7 +93,7 @@ impl Frame { /// For when you want to group a few widgets together within a frame. pub fn group(style: &Style) -> Self { Self { - inner_margin: Margin::same(6.0), // same and symmetric looks best in corners when nesting groups + inner_margin: Margin::same(6), // same and symmetric looks best in corners when nesting groups rounding: style.visuals.widgets.noninteractive.rounding, stroke: style.visuals.widgets.noninteractive.bg_stroke, ..Default::default() @@ -90,7 +102,7 @@ impl Frame { pub fn side_top_panel(style: &Style) -> Self { Self { - inner_margin: Margin::symmetric(8.0, 2.0), + inner_margin: Margin::symmetric(8, 2), fill: style.visuals.panel_fill, ..Default::default() } @@ -98,7 +110,7 @@ impl Frame { pub fn central_panel(style: &Style) -> Self { Self { - inner_margin: Margin::same(8.0), + inner_margin: Margin::same(8), fill: style.visuals.panel_fill, ..Default::default() } @@ -143,7 +155,7 @@ impl Frame { /// and in dark mode this will be very dark. pub fn canvas(style: &Style) -> Self { Self { - inner_margin: Margin::same(2.0), + inner_margin: Margin::same(2), rounding: style.visuals.widgets.noninteractive.rounding, fill: style.visuals.extreme_bg_color, stroke: style.visuals.window_stroke(), @@ -213,10 +225,10 @@ impl Frame { } impl Frame { - /// inner margin plus outer margin. + /// Inner margin plus outer margin. #[inline] - pub fn total_margin(&self) -> Margin { - self.inner_margin + self.outer_margin + pub fn total_margin(&self) -> Marginf { + Marginf::from(self.inner_margin) + Marginf::from(self.outer_margin) } } diff --git a/crates/egui/src/containers/window.rs b/crates/egui/src/containers/window.rs index f1c67300b..d487a27dd 100644 --- a/crates/egui/src/containers/window.rs +++ b/crates/egui/src/containers/window.rs @@ -486,7 +486,7 @@ impl<'open> Window<'open> { // Calculate roughly how much larger the window size is compared to the inner rect let (title_bar_height, title_content_spacing) = if with_title_bar { let style = ctx.style(); - let spacing = window_margin.top + window_margin.bottom; + let spacing = window_margin.sum().y; let height = ctx.fonts(|f| title.font_height(f, &style)) + spacing; let half_height = (height / 2.0).round() as _; window_frame.rounding.ne = window_frame.rounding.ne.clamp(0, half_height); diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index af35972c4..1e23ee004 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -1238,8 +1238,8 @@ impl Default for Spacing { fn default() -> Self { Self { item_spacing: vec2(8.0, 3.0), - window_margin: Margin::same(6.0), - menu_margin: Margin::same(6.0), + window_margin: Margin::same(6), + menu_margin: Margin::same(6), button_padding: vec2(4.0, 1.0), indent: 18.0, // match checkbox/radio-button with `button_padding.x + icon_width + icon_spacing` interact_size: vec2(40.0, 18.0), @@ -2371,9 +2371,17 @@ impl Widget for &mut Margin { // Apply the checkbox: if same { - *self = Margin::same((self.left + self.right + self.top + self.bottom) / 4.0); - } else if self.is_same() { - self.right *= 1.00001; // prevent collapsing into sameness + *self = + Margin::from((self.leftf() + self.rightf() + self.topf() + self.bottomf()) / 4.0); + } else { + // Make sure it is not same: + if self.is_same() { + if self.right == i8::MAX { + self.right = i8::MAX - 1; + } else { + self.right += 1; + } + } } response diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index 587f498b0..be96c00fa 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -126,7 +126,7 @@ impl<'t> TextEdit<'t> { layouter: None, password: false, frame: true, - margin: Margin::symmetric(4.0, 2.0), + margin: Margin::symmetric(4, 2), multiline: true, interactive: true, desired_width: None, diff --git a/crates/egui_demo_lib/src/demo/pan_zoom.rs b/crates/egui_demo_lib/src/demo/pan_zoom.rs index 421dc521a..e51b5b9d7 100644 --- a/crates/egui_demo_lib/src/demo/pan_zoom.rs +++ b/crates/egui_demo_lib/src/demo/pan_zoom.rs @@ -128,7 +128,7 @@ impl crate::View for PanZoom { ui.set_clip_rect(transform.inverse() * rect); egui::Frame::default() .rounding(egui::Rounding::same(4)) - .inner_margin(egui::Margin::same(8.0)) + .inner_margin(egui::Margin::same(8)) .stroke(ui.ctx().style().visuals.window_stroke) .fill(ui.style().visuals.panel_fill) .show(ui, |ui| { diff --git a/crates/egui_extras/src/datepicker/button.rs b/crates/egui_extras/src/datepicker/button.rs index 539935126..81a97c12a 100644 --- a/crates/egui_extras/src/datepicker/button.rs +++ b/crates/egui_extras/src/datepicker/button.rs @@ -134,14 +134,14 @@ impl<'a> Widget for DatePickerButton<'a> { let mut pos = button_response.rect.left_bottom(); let width_with_padding = width + ui.style().spacing.item_spacing.x - + ui.style().spacing.window_margin.left - + ui.style().spacing.window_margin.right; + + ui.style().spacing.window_margin.leftf() + + ui.style().spacing.window_margin.rightf(); if pos.x + width_with_padding > ui.clip_rect().right() { pos.x = button_response.rect.right() - width_with_padding; } // Check to make sure the calendar never is displayed out of window - pos.x = pos.x.max(ui.style().spacing.window_margin.left); + pos.x = pos.x.max(ui.style().spacing.window_margin.leftf()); //TODO(elwerene): Better positioning diff --git a/crates/epaint/src/lib.rs b/crates/epaint/src/lib.rs index 226821c16..b1d0045ee 100644 --- a/crates/epaint/src/lib.rs +++ b/crates/epaint/src/lib.rs @@ -27,6 +27,7 @@ mod brush; pub mod color; pub mod image; mod margin; +mod marginf; mod mesh; pub mod mutex; mod rounding; @@ -49,6 +50,7 @@ pub use self::{ color::ColorMode, image::{ColorImage, FontImage, ImageData, ImageDelta}, margin::Margin, + marginf::Marginf, mesh::{Mesh, Mesh16, Vertex}, rounding::Rounding, roundingf::Roundingf, diff --git a/crates/epaint/src/margin.rs b/crates/epaint/src/margin.rs index e2ade58f9..e8fc530aa 100644 --- a/crates/epaint/src/margin.rs +++ b/crates/epaint/src/margin.rs @@ -4,27 +4,33 @@ use emath::{vec2, Rect, Vec2}; /// often used to express padding or spacing. /// /// Can be added and subtracted to/from [`Rect`]s. -#[derive(Clone, Copy, Debug, Default, PartialEq)] +/// +/// Negative margins are possible, but may produce weird behavior. +/// Use with care. +/// +/// All values are stored as [`i8`] to keep the size of [`Margin`] small. +/// If you want floats, use [`crate::Marginf`] instead. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct Margin { - pub left: f32, - pub right: f32, - pub top: f32, - pub bottom: f32, + pub left: i8, + pub right: i8, + pub top: i8, + pub bottom: i8, } impl Margin { pub const ZERO: Self = Self { - left: 0.0, - right: 0.0, - top: 0.0, - bottom: 0.0, + left: 0, + right: 0, + top: 0, + bottom: 0, }; /// The same margin on every side. #[doc(alias = "symmetric")] #[inline] - pub const fn same(margin: f32) -> Self { + pub const fn same(margin: i8) -> Self { Self { left: margin, right: margin, @@ -35,7 +41,7 @@ impl Margin { /// Margins with the same size on opposing sides #[inline] - pub const fn symmetric(x: f32, y: f32) -> Self { + pub const fn symmetric(x: i8, y: i8) -> Self { Self { left: x, right: x, @@ -44,53 +50,84 @@ impl Margin { } } + /// Left margin, as `f32` + #[inline] + pub const fn leftf(self) -> f32 { + self.left as _ + } + + /// Right margin, as `f32` + #[inline] + pub const fn rightf(self) -> f32 { + self.right as _ + } + + /// Top margin, as `f32` + #[inline] + pub const fn topf(self) -> f32 { + self.top as _ + } + + /// Bottom margin, as `f32` + #[inline] + pub const fn bottomf(self) -> f32 { + self.bottom as _ + } + /// Total margins on both sides #[inline] - pub fn sum(&self) -> Vec2 { - vec2(self.left + self.right, self.top + self.bottom) + pub fn sum(self) -> Vec2 { + vec2(self.leftf() + self.rightf(), self.topf() + self.bottomf()) } #[inline] - pub const fn left_top(&self) -> Vec2 { - vec2(self.left, self.top) + pub const fn left_top(self) -> Vec2 { + vec2(self.leftf(), self.topf()) } #[inline] - pub const fn right_bottom(&self) -> Vec2 { - vec2(self.right, self.bottom) + pub const fn right_bottom(self) -> Vec2 { + vec2(self.rightf(), self.bottomf()) } /// Are the margin on every side the same? #[doc(alias = "symmetric")] #[inline] - pub fn is_same(&self) -> bool { + pub const fn is_same(self) -> bool { self.left == self.right && self.left == self.top && self.left == self.bottom } #[deprecated = "Use `rect + margin` instead"] #[inline] - pub fn expand_rect(&self, rect: Rect) -> Rect { + pub fn expand_rect(self, rect: Rect) -> Rect { Rect::from_min_max(rect.min - self.left_top(), rect.max + self.right_bottom()) } #[deprecated = "Use `rect - margin` instead"] #[inline] - pub fn shrink_rect(&self, rect: Rect) -> Rect { + pub fn shrink_rect(self, rect: Rect) -> Rect { Rect::from_min_max(rect.min + self.left_top(), rect.max - self.right_bottom()) } } +impl From for Margin { + #[inline] + fn from(v: i8) -> Self { + Self::same(v) + } +} + impl From for Margin { #[inline] fn from(v: f32) -> Self { - Self::same(v) + Self::same(v.round() as _) } } impl From for Margin { #[inline] fn from(v: Vec2) -> Self { - Self::symmetric(v.x, v.y) + Self::symmetric(v.x.round() as _, v.y.round() as _) } } @@ -101,37 +138,34 @@ impl std::ops::Add for Margin { #[inline] fn add(self, other: Self) -> Self { Self { - left: self.left + other.left, - right: self.right + other.right, - top: self.top + other.top, - bottom: self.bottom + other.bottom, + left: self.left.saturating_add(other.left), + right: self.right.saturating_add(other.right), + top: self.top.saturating_add(other.top), + bottom: self.bottom.saturating_add(other.bottom), } } } -/// `Margin + f32` -impl std::ops::Add for Margin { +/// `Margin + i8` +impl std::ops::Add for Margin { type Output = Self; #[inline] - fn add(self, v: f32) -> Self { + fn add(self, v: i8) -> Self { Self { - left: self.left + v, - right: self.right + v, - top: self.top + v, - bottom: self.bottom + v, + left: self.left.saturating_add(v), + right: self.right.saturating_add(v), + top: self.top.saturating_add(v), + bottom: self.bottom.saturating_add(v), } } } -/// `Margind += f32` -impl std::ops::AddAssign for Margin { +/// `Margin += i8` +impl std::ops::AddAssign for Margin { #[inline] - fn add_assign(&mut self, v: f32) { - self.left += v; - self.right += v; - self.top += v; - self.bottom += v; + fn add_assign(&mut self, v: i8) { + *self = *self + v; } } @@ -142,10 +176,10 @@ impl std::ops::Mul for Margin { #[inline] fn mul(self, v: f32) -> Self { Self { - left: self.left * v, - right: self.right * v, - top: self.top * v, - bottom: self.bottom * v, + left: (self.leftf() * v).round() as _, + right: (self.rightf() * v).round() as _, + top: (self.topf() * v).round() as _, + bottom: (self.bottomf() * v).round() as _, } } } @@ -154,10 +188,7 @@ impl std::ops::Mul for Margin { impl std::ops::MulAssign for Margin { #[inline] fn mul_assign(&mut self, v: f32) { - self.left *= v; - self.right *= v; - self.top *= v; - self.bottom *= v; + *self = *self * v; } } @@ -167,12 +198,8 @@ impl std::ops::Div for Margin { #[inline] fn div(self, v: f32) -> Self { - Self { - left: self.left / v, - right: self.right / v, - top: self.top / v, - bottom: self.bottom / v, - } + #![allow(clippy::suspicious_arithmetic_impl)] + self * v.recip() } } @@ -180,10 +207,7 @@ impl std::ops::Div for Margin { impl std::ops::DivAssign for Margin { #[inline] fn div_assign(&mut self, v: f32) { - self.left /= v; - self.right /= v; - self.top /= v; - self.bottom /= v; + *self = *self / v; } } @@ -194,37 +218,34 @@ impl std::ops::Sub for Margin { #[inline] fn sub(self, other: Self) -> Self { Self { - left: self.left - other.left, - right: self.right - other.right, - top: self.top - other.top, - bottom: self.bottom - other.bottom, + left: self.left.saturating_sub(other.left), + right: self.right.saturating_sub(other.right), + top: self.top.saturating_sub(other.top), + bottom: self.bottom.saturating_sub(other.bottom), } } } -/// `Margin - f32` -impl std::ops::Sub for Margin { +/// `Margin - i8` +impl std::ops::Sub for Margin { type Output = Self; #[inline] - fn sub(self, v: f32) -> Self { + fn sub(self, v: i8) -> Self { Self { - left: self.left - v, - right: self.right - v, - top: self.top - v, - bottom: self.bottom - v, + left: self.left.saturating_sub(v), + right: self.right.saturating_sub(v), + top: self.top.saturating_sub(v), + bottom: self.bottom.saturating_sub(v), } } } -/// `Margin -= f32` -impl std::ops::SubAssign for Margin { +/// `Margin -= i8` +impl std::ops::SubAssign for Margin { #[inline] - fn sub_assign(&mut self, v: f32) { - self.left -= v; - self.right -= v; - self.top -= v; - self.bottom -= v; + fn sub_assign(&mut self, v: i8) { + *self = *self - v; } } diff --git a/crates/epaint/src/marginf.rs b/crates/epaint/src/marginf.rs new file mode 100644 index 000000000..eb3a1fbc9 --- /dev/null +++ b/crates/epaint/src/marginf.rs @@ -0,0 +1,299 @@ +use emath::{vec2, Rect, Vec2}; + +use crate::Margin; + +/// A value for all four sides of a rectangle, +/// often used to express padding or spacing. +/// +/// Can be added and subtracted to/from [`Rect`]s. +/// +/// For storage, use [`crate::Margin`] instead. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Marginf { + pub left: f32, + pub right: f32, + pub top: f32, + pub bottom: f32, +} + +impl From for Marginf { + #[inline] + fn from(margin: Margin) -> Self { + Self { + left: margin.left as _, + right: margin.right as _, + top: margin.top as _, + bottom: margin.bottom as _, + } + } +} + +impl From for Margin { + #[inline] + fn from(marginf: Marginf) -> Self { + Self { + left: marginf.left as _, + right: marginf.right as _, + top: marginf.top as _, + bottom: marginf.bottom as _, + } + } +} + +impl Marginf { + pub const ZERO: Self = Self { + left: 0.0, + right: 0.0, + top: 0.0, + bottom: 0.0, + }; + + /// The same margin on every side. + #[doc(alias = "symmetric")] + #[inline] + pub const fn same(margin: f32) -> Self { + Self { + left: margin, + right: margin, + top: margin, + bottom: margin, + } + } + + /// Margins with the same size on opposing sides + #[inline] + pub const fn symmetric(x: f32, y: f32) -> Self { + Self { + left: x, + right: x, + top: y, + bottom: y, + } + } + + /// Total margins on both sides + #[inline] + pub fn sum(&self) -> Vec2 { + vec2(self.left + self.right, self.top + self.bottom) + } + + #[inline] + pub const fn left_top(&self) -> Vec2 { + vec2(self.left, self.top) + } + + #[inline] + pub const fn right_bottom(&self) -> Vec2 { + vec2(self.right, self.bottom) + } + + /// Are the margin on every side the same? + #[doc(alias = "symmetric")] + #[inline] + pub fn is_same(&self) -> bool { + self.left == self.right && self.left == self.top && self.left == self.bottom + } + + #[deprecated = "Use `rect + margin` instead"] + #[inline] + pub fn expand_rect(&self, rect: Rect) -> Rect { + Rect::from_min_max(rect.min - self.left_top(), rect.max + self.right_bottom()) + } + + #[deprecated = "Use `rect - margin` instead"] + #[inline] + pub fn shrink_rect(&self, rect: Rect) -> Rect { + Rect::from_min_max(rect.min + self.left_top(), rect.max - self.right_bottom()) + } +} + +impl From for Marginf { + #[inline] + fn from(v: f32) -> Self { + Self::same(v) + } +} + +impl From for Marginf { + #[inline] + fn from(v: Vec2) -> Self { + Self::symmetric(v.x, v.y) + } +} + +/// `Marginf + Marginf` +impl std::ops::Add for Marginf { + type Output = Self; + + #[inline] + fn add(self, other: Self) -> Self { + Self { + left: self.left + other.left, + right: self.right + other.right, + top: self.top + other.top, + bottom: self.bottom + other.bottom, + } + } +} + +/// `Marginf + f32` +impl std::ops::Add for Marginf { + type Output = Self; + + #[inline] + fn add(self, v: f32) -> Self { + Self { + left: self.left + v, + right: self.right + v, + top: self.top + v, + bottom: self.bottom + v, + } + } +} + +/// `Margind += f32` +impl std::ops::AddAssign for Marginf { + #[inline] + fn add_assign(&mut self, v: f32) { + self.left += v; + self.right += v; + self.top += v; + self.bottom += v; + } +} + +/// `Marginf * f32` +impl std::ops::Mul for Marginf { + type Output = Self; + + #[inline] + fn mul(self, v: f32) -> Self { + Self { + left: self.left * v, + right: self.right * v, + top: self.top * v, + bottom: self.bottom * v, + } + } +} + +/// `Marginf *= f32` +impl std::ops::MulAssign for Marginf { + #[inline] + fn mul_assign(&mut self, v: f32) { + self.left *= v; + self.right *= v; + self.top *= v; + self.bottom *= v; + } +} + +/// `Marginf / f32` +impl std::ops::Div for Marginf { + type Output = Self; + + #[inline] + fn div(self, v: f32) -> Self { + Self { + left: self.left / v, + right: self.right / v, + top: self.top / v, + bottom: self.bottom / v, + } + } +} + +/// `Marginf /= f32` +impl std::ops::DivAssign for Marginf { + #[inline] + fn div_assign(&mut self, v: f32) { + self.left /= v; + self.right /= v; + self.top /= v; + self.bottom /= v; + } +} + +/// `Marginf - Marginf` +impl std::ops::Sub for Marginf { + type Output = Self; + + #[inline] + fn sub(self, other: Self) -> Self { + Self { + left: self.left - other.left, + right: self.right - other.right, + top: self.top - other.top, + bottom: self.bottom - other.bottom, + } + } +} + +/// `Marginf - f32` +impl std::ops::Sub for Marginf { + type Output = Self; + + #[inline] + fn sub(self, v: f32) -> Self { + Self { + left: self.left - v, + right: self.right - v, + top: self.top - v, + bottom: self.bottom - v, + } + } +} + +/// `Marginf -= f32` +impl std::ops::SubAssign for Marginf { + #[inline] + fn sub_assign(&mut self, v: f32) { + self.left -= v; + self.right -= v; + self.top -= v; + self.bottom -= v; + } +} + +/// `Rect + Marginf` +impl std::ops::Add for Rect { + type Output = Self; + + #[inline] + fn add(self, margin: Marginf) -> Self { + Self::from_min_max( + self.min - margin.left_top(), + self.max + margin.right_bottom(), + ) + } +} + +/// `Rect += Marginf` +impl std::ops::AddAssign for Rect { + #[inline] + fn add_assign(&mut self, margin: Marginf) { + *self = *self + margin; + } +} + +/// `Rect - Marginf` +impl std::ops::Sub for Rect { + type Output = Self; + + #[inline] + fn sub(self, margin: Marginf) -> Self { + Self::from_min_max( + self.min + margin.left_top(), + self.max - margin.right_bottom(), + ) + } +} + +/// `Rect -= Marginf` +impl std::ops::SubAssign for Rect { + #[inline] + fn sub_assign(&mut self, margin: Marginf) { + *self = *self - margin; + } +} diff --git a/crates/epaint/src/shadow.rs b/crates/epaint/src/shadow.rs index 5a68e45ba..3f8145e50 100644 --- a/crates/epaint/src/shadow.rs +++ b/crates/epaint/src/shadow.rs @@ -1,4 +1,4 @@ -use super::{Color32, Margin, Rect, RectShape, Rounding, Vec2}; +use crate::{Color32, Marginf, Rect, RectShape, Rounding, Vec2}; /// The color and fuzziness of a fuzzy shape. /// @@ -53,14 +53,14 @@ impl Shadow { } /// How much larger than the parent rect are we in each direction? - pub fn margin(&self) -> Margin { + pub fn margin(&self) -> Marginf { let Self { offset, blur, spread, color: _, } = *self; - Margin { + Marginf { left: spread + 0.5 * blur - offset.x, right: spread + 0.5 * blur + offset.x, top: spread + 0.5 * blur - offset.y, diff --git a/examples/custom_keypad/src/keypad.rs b/examples/custom_keypad/src/keypad.rs index d4d0cb0d8..1934ff602 100644 --- a/examples/custom_keypad/src/keypad.rs +++ b/examples/custom_keypad/src/keypad.rs @@ -92,10 +92,10 @@ impl Keypad { ui.vertical(|ui| { let window_margin = ui.spacing().window_margin; let size_1x1 = vec2(32.0, 26.0); - let _size_1x2 = vec2(32.0, 52.0 + window_margin.top); - let _size_2x1 = vec2(64.0 + window_margin.left, 26.0); + let _size_1x2 = vec2(32.0, 52.0 + window_margin.topf()); + let _size_2x1 = vec2(64.0 + window_margin.leftf(), 26.0); - ui.spacing_mut().item_spacing = Vec2::splat(window_margin.left); + ui.spacing_mut().item_spacing = Vec2::splat(window_margin.leftf()); ui.horizontal(|ui| { if ui.add_sized(size_1x1, Button::new("1")).clicked() { diff --git a/tests/test_ui_stack/src/main.rs b/tests/test_ui_stack/src/main.rs index 1007e2fb8..a2f09af5c 100644 --- a/tests/test_ui_stack/src/main.rs +++ b/tests/test_ui_stack/src/main.rs @@ -64,8 +64,8 @@ impl eframe::App for MyApp { ui.add_space(20.0); egui::Frame { stroke: ui.visuals().noninteractive().bg_stroke, - inner_margin: egui::Margin::same(4.0), - outer_margin: egui::Margin::same(4.0), + inner_margin: egui::Margin::same(4), + outer_margin: egui::Margin::same(4), ..Default::default() } .show(ui, |ui| { @@ -74,8 +74,8 @@ impl eframe::App for MyApp { egui::Frame { stroke: ui.visuals().noninteractive().bg_stroke, - inner_margin: egui::Margin::same(8.0), - outer_margin: egui::Margin::same(6.0), + inner_margin: egui::Margin::same(8), + outer_margin: egui::Margin::same(6), ..Default::default() } .show(ui, |ui| { @@ -128,7 +128,7 @@ impl eframe::App for MyApp { ui.label("UI nesting test:"); egui::Frame { stroke: ui.visuals().noninteractive().bg_stroke, - inner_margin: egui::Margin::same(4.0), + inner_margin: egui::Margin::same(4), ..Default::default() } .show(ui, |ui| { @@ -267,7 +267,7 @@ fn stack_ui(ui: &mut egui::Ui) { fn stack_ui_impl(ui: &mut egui::Ui, stack: &egui::UiStack) { egui::Frame { stroke: ui.style().noninteractive().fg_stroke, - inner_margin: egui::Margin::same(4.0), + inner_margin: egui::Margin::same(4), ..Default::default() } .show(ui, |ui| { From ee4ab08c8a208f4044a8c571326c9414e7a1c8a6 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Thu, 2 Jan 2025 16:22:44 +0100 Subject: [PATCH 7/9] Shrink size of `Shadow` by using `i8/u8` instead of `f32` (#5568) * Part of https://github.com/emilk/egui/issues/4019 --- crates/egui/src/containers/frame.rs | 2 +- crates/egui/src/style.rs | 28 ++++++------- crates/egui_demo_lib/src/demo/frame_demo.rs | 6 +-- crates/epaint/src/shadow.rs | 44 ++++++++++++++------- 4 files changed, 47 insertions(+), 33 deletions(-) diff --git a/crates/egui/src/containers/frame.rs b/crates/egui/src/containers/frame.rs index 6a526a7e9..62c5d0b58 100644 --- a/crates/egui/src/containers/frame.rs +++ b/crates/egui/src/containers/frame.rs @@ -76,7 +76,7 @@ pub struct Frame { #[test] fn frame_size() { assert_eq!( - std::mem::size_of::(), 44, + std::mem::size_of::(), 32, "Frame changed size! If it shrank - good! Update this test. If it grew - bad! Try to find a way to avoid it." ); assert!( diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index 1e23ee004..1d5a1b25c 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -1293,9 +1293,9 @@ impl Visuals { window_rounding: Rounding::same(6), window_shadow: Shadow { - offset: vec2(10.0, 20.0), - blur: 15.0, - spread: 0.0, + offset: [10, 20], + blur: 15, + spread: 0, color: Color32::from_black_alpha(96), }, window_fill: Color32::from_gray(27), @@ -1307,9 +1307,9 @@ impl Visuals { panel_fill: Color32::from_gray(27), popup_shadow: Shadow { - offset: vec2(6.0, 10.0), - blur: 8.0, - spread: 0.0, + offset: [6, 10], + blur: 8, + spread: 0, color: Color32::from_black_alpha(96), }, @@ -1349,9 +1349,9 @@ impl Visuals { error_fg_color: Color32::from_rgb(255, 0, 0), // red window_shadow: Shadow { - offset: vec2(10.0, 20.0), - blur: 15.0, - spread: 0.0, + offset: [10, 20], + blur: 15, + spread: 0, color: Color32::from_black_alpha(25), }, window_fill: Color32::from_gray(248), @@ -1360,9 +1360,9 @@ impl Visuals { panel_fill: Color32::from_gray(248), popup_shadow: Shadow { - offset: vec2(6.0, 10.0), - blur: 8.0, - spread: 0.0, + offset: [6, 10], + blur: 8, + spread: 0, color: Color32::from_black_alpha(25), }, @@ -2456,13 +2456,13 @@ impl Widget for &mut Shadow { ui.vertical(|ui| { crate::Grid::new("shadow_ui").show(ui, |ui| { ui.add( - DragValue::new(&mut offset.x) + DragValue::new(&mut offset[0]) .speed(1.0) .range(-100.0..=100.0) .prefix("x: "), ); ui.add( - DragValue::new(&mut offset.y) + DragValue::new(&mut offset[1]) .speed(1.0) .range(-100.0..=100.0) .prefix("y: "), diff --git a/crates/egui_demo_lib/src/demo/frame_demo.rs b/crates/egui_demo_lib/src/demo/frame_demo.rs index e982d9799..b772eb750 100644 --- a/crates/egui_demo_lib/src/demo/frame_demo.rs +++ b/crates/egui_demo_lib/src/demo/frame_demo.rs @@ -12,9 +12,9 @@ impl Default for FrameDemo { outer_margin: 24.0.into(), rounding: 14.0.into(), shadow: egui::Shadow { - offset: [8.0, 12.0].into(), - blur: 16.0, - spread: 0.0, + offset: [8, 12], + blur: 16, + spread: 0, color: egui::Color32::from_black_alpha(180), }, fill: egui::Color32::from_rgba_unmultiplied(97, 0, 255, 128), diff --git a/crates/epaint/src/shadow.rs b/crates/epaint/src/shadow.rs index 3f8145e50..959049ace 100644 --- a/crates/epaint/src/shadow.rs +++ b/crates/epaint/src/shadow.rs @@ -5,33 +5,41 @@ use crate::{Color32, Marginf, Rect, RectShape, Rounding, Vec2}; /// Can be used for a rectangular shadow with a soft penumbra. /// /// Very similar to a box-shadow in CSS. -#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct Shadow { /// Move the shadow by this much. /// /// For instance, a value of `[1.0, 2.0]` will move the shadow 1 point to the right and 2 points down, /// causing a drop-shadow effect. - pub offset: Vec2, + pub offset: [i8; 2], /// The width of the blur, i.e. the width of the fuzzy penumbra. /// - /// A value of 0.0 means a sharp shadow. - pub blur: f32, + /// A value of 0 means a sharp shadow. + pub blur: u8, /// Expand the shadow in all directions by this much. - pub spread: f32, + pub spread: u8, /// Color of the opaque center of the shadow. pub color: Color32, } +#[test] +fn shadow_size() { + assert_eq!( + std::mem::size_of::(), 8, + "Shadow changed size! If it shrank - good! Update this test. If it grew - bad! Try to find a way to avoid it." + ); +} + impl Shadow { /// No shadow at all. pub const NONE: Self = Self { - offset: Vec2::ZERO, - blur: 0.0, - spread: 0.0, + offset: [0, 0], + blur: 0, + spread: 0, color: Color32::TRANSPARENT, }; @@ -45,11 +53,14 @@ impl Shadow { spread, color, } = *self; + let [offset_x, offset_y] = offset; - let rect = rect.translate(offset).expand(spread); - let rounding = rounding.into() + Rounding::from(spread.abs()); + let rect = rect + .translate(Vec2::new(offset_x as _, offset_y as _)) + .expand(spread as _); + let rounding = rounding.into() + Rounding::from(spread); - RectShape::filled(rect, rounding, color).with_blur_width(blur) + RectShape::filled(rect, rounding, color).with_blur_width(blur as _) } /// How much larger than the parent rect are we in each direction? @@ -60,11 +71,14 @@ impl Shadow { spread, color: _, } = *self; + let spread = spread as f32; + let blur = blur as f32; + let [offset_x, offset_y] = offset; Marginf { - left: spread + 0.5 * blur - offset.x, - right: spread + 0.5 * blur + offset.x, - top: spread + 0.5 * blur - offset.y, - bottom: spread + 0.5 * blur + offset.y, + left: spread + 0.5 * blur - offset_x as f32, + right: spread + 0.5 * blur + offset_x as f32, + top: spread + 0.5 * blur - offset_y as f32, + bottom: spread + 0.5 * blur + offset_y as f32, } } } From 46b58e5bcca0bb34861b5671958be872419bee90 Mon Sep 17 00:00:00 2001 From: lucasmerlin Date: Thu, 2 Jan 2025 17:48:39 +0100 Subject: [PATCH 8/9] Add `Harness::new_eframe` and `TestRenderer` trait (#5539) Co-authored-by: Andreas Reich --- Cargo.lock | 2 + crates/eframe/src/epi.rs | 67 +++++- crates/eframe/src/web/web_painter_wgpu.rs | 2 +- crates/egui-wgpu/src/lib.rs | 15 +- crates/egui-wgpu/src/winit.rs | 2 +- crates/egui_demo_app/Cargo.toml | 3 + crates/egui_demo_app/src/apps/image_viewer.rs | 5 +- crates/egui_demo_app/src/lib.rs | 2 +- crates/egui_demo_app/src/wrap_app.rs | 13 +- .../egui_demo_app/tests/snapshots/clock.png | 3 + .../tests/snapshots/custom3d.png | 3 + .../tests/snapshots/easymarkeditor.png | 3 + .../tests/snapshots/imageviewer.png | 3 + crates/egui_demo_app/tests/test_demo_app.rs | 78 +++++++ .../src/demo/demo_app_windows.rs | 2 +- crates/egui_demo_lib/src/demo/modals.rs | 8 +- .../egui_demo_lib/src/demo/widget_gallery.rs | 2 +- crates/egui_demo_lib/src/rendering_test.rs | 2 +- crates/egui_kittest/Cargo.toml | 6 +- crates/egui_kittest/README.md | 4 +- crates/egui_kittest/src/app_kind.rs | 43 ++-- crates/egui_kittest/src/builder.rs | 59 ++++- crates/egui_kittest/src/lib.rs | 49 +++- crates/egui_kittest/src/renderer.rs | 81 +++++++ crates/egui_kittest/src/snapshot.rs | 101 +++++++-- crates/egui_kittest/src/wgpu.rs | 211 ++++++++++-------- crates/egui_kittest/tests/regression_tests.rs | 2 +- crates/egui_kittest/tests/tests.rs | 2 +- 28 files changed, 593 insertions(+), 180 deletions(-) create mode 100644 crates/egui_demo_app/tests/snapshots/clock.png create mode 100644 crates/egui_demo_app/tests/snapshots/custom3d.png create mode 100644 crates/egui_demo_app/tests/snapshots/easymarkeditor.png create mode 100644 crates/egui_demo_app/tests/snapshots/imageviewer.png create mode 100644 crates/egui_demo_app/tests/test_demo_app.rs create mode 100644 crates/egui_kittest/src/renderer.rs diff --git a/Cargo.lock b/Cargo.lock index 7e11391f1..009aeb731 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1319,6 +1319,7 @@ dependencies = [ "egui", "egui_demo_lib", "egui_extras", + "egui_kittest", "ehttp", "env_logger", "image", @@ -1395,6 +1396,7 @@ version = "0.30.0" dependencies = [ "dify", "document-features", + "eframe", "egui", "egui-wgpu", "image", diff --git a/crates/eframe/src/epi.rs b/crates/eframe/src/epi.rs index 45d833501..7a7e92244 100644 --- a/crates/eframe/src/epi.rs +++ b/crates/eframe/src/epi.rs @@ -109,6 +109,28 @@ impl HasDisplayHandle for CreationContext<'_> { } } +impl CreationContext<'_> { + /// Create a new empty [CreationContext] for testing [App]s in kittest. + #[doc(hidden)] + pub fn _new_kittest(egui_ctx: egui::Context) -> Self { + Self { + egui_ctx, + integration_info: IntegrationInfo::mock(), + storage: None, + #[cfg(feature = "glow")] + gl: None, + #[cfg(feature = "glow")] + get_proc_address: None, + #[cfg(feature = "wgpu")] + wgpu_render_state: None, + #[cfg(not(target_arch = "wasm32"))] + raw_window_handle: Err(HandleError::NotSupported), + #[cfg(not(target_arch = "wasm32"))] + raw_display_handle: Err(HandleError::NotSupported), + } + } +} + // ---------------------------------------------------------------------------- /// Implement this trait to write apps that can be compiled for both web/wasm and desktop/native using [`eframe`](https://github.com/emilk/egui/tree/master/crates/eframe). @@ -617,7 +639,8 @@ pub struct Frame { /// Can be used to manage GPU resources for custom rendering with WGPU using [`egui::PaintCallback`]s. #[cfg(feature = "wgpu")] - pub(crate) wgpu_render_state: Option, + #[doc(hidden)] + pub wgpu_render_state: Option, /// Raw platform window handle #[cfg(not(target_arch = "wasm32"))] @@ -651,6 +674,25 @@ impl HasDisplayHandle for Frame { } impl Frame { + /// Create a new empty [Frame] for testing [App]s in kittest. + #[doc(hidden)] + pub fn _new_kittest() -> Self { + Self { + #[cfg(feature = "glow")] + gl: None, + #[cfg(all(feature = "glow", not(target_arch = "wasm32")))] + glow_register_native_texture: None, + info: IntegrationInfo::mock(), + #[cfg(not(target_arch = "wasm32"))] + raw_display_handle: Err(HandleError::NotSupported), + #[cfg(not(target_arch = "wasm32"))] + raw_window_handle: Err(HandleError::NotSupported), + storage: None, + #[cfg(feature = "wgpu")] + wgpu_render_state: None, + } + } + /// True if you are in a web environment. /// /// Equivalent to `cfg!(target_arch = "wasm32")` @@ -794,6 +836,29 @@ pub struct IntegrationInfo { pub cpu_usage: Option, } +impl IntegrationInfo { + fn mock() -> Self { + Self { + #[cfg(target_arch = "wasm32")] + web_info: WebInfo { + user_agent: "kittest".to_owned(), + location: Location { + url: "http://localhost".to_owned(), + protocol: "http:".to_owned(), + host: "localhost".to_owned(), + hostname: "localhost".to_owned(), + port: "80".to_owned(), + hash: String::new(), + query: String::new(), + query_map: Default::default(), + origin: "http://localhost".to_owned(), + }, + }, + cpu_usage: None, + } + } +} + // ---------------------------------------------------------------------------- /// A place where you can store custom data in a way that persists when you restart the app. diff --git a/crates/eframe/src/web/web_painter_wgpu.rs b/crates/eframe/src/web/web_painter_wgpu.rs index 591d4224d..5e217180c 100644 --- a/crates/eframe/src/web/web_painter_wgpu.rs +++ b/crates/eframe/src/web/web_painter_wgpu.rs @@ -115,7 +115,7 @@ impl WebPainterWgpu { let render_state = RenderState::create( &options.wgpu_options, &instance, - &surface, + Some(&surface), depth_format, 1, options.dithering, diff --git a/crates/egui-wgpu/src/lib.rs b/crates/egui-wgpu/src/lib.rs index a91662f45..b90e52001 100644 --- a/crates/egui-wgpu/src/lib.rs +++ b/crates/egui-wgpu/src/lib.rs @@ -24,7 +24,7 @@ pub use wgpu; mod renderer; pub use renderer::*; -use wgpu::{Adapter, Device, Instance, Queue}; +use wgpu::{Adapter, Device, Instance, Queue, TextureFormat}; /// Helpers for capturing screenshots of the UI. pub mod capture; @@ -91,7 +91,7 @@ impl RenderState { pub async fn create( config: &WgpuConfiguration, instance: &wgpu::Instance, - surface: &wgpu::Surface<'static>, + compatible_surface: Option<&wgpu::Surface<'static>>, depth_format: Option, msaa_samples: u32, dithering: bool, @@ -113,7 +113,7 @@ impl RenderState { instance .request_adapter(&wgpu::RequestAdapterOptions { power_preference, - compatible_surface: Some(surface), + compatible_surface, force_fallback_adapter: false, }) .await @@ -186,11 +186,14 @@ impl RenderState { } => (adapter, device, queue), }; - let capabilities = { + let surface_formats = { profiling::scope!("get_capabilities"); - surface.get_capabilities(&adapter).formats + compatible_surface.map_or_else( + || vec![TextureFormat::Rgba8Unorm], + |s| s.get_capabilities(&adapter).formats, + ) }; - let target_format = crate::preferred_framebuffer_format(&capabilities)?; + let target_format = crate::preferred_framebuffer_format(&surface_formats)?; let renderer = Renderer::new( &device, diff --git a/crates/egui-wgpu/src/winit.rs b/crates/egui-wgpu/src/winit.rs index dea2e7fa3..19be186f2 100644 --- a/crates/egui-wgpu/src/winit.rs +++ b/crates/egui-wgpu/src/winit.rs @@ -212,7 +212,7 @@ impl Painter { let render_state = RenderState::create( &self.configuration, &self.instance, - &surface, + Some(&surface), self.depth_format, self.msaa_samples, self.dithering, diff --git a/crates/egui_demo_app/Cargo.toml b/crates/egui_demo_app/Cargo.toml index c3083d11c..3115c845e 100644 --- a/crates/egui_demo_app/Cargo.toml +++ b/crates/egui_demo_app/Cargo.toml @@ -93,3 +93,6 @@ rfd = { version = "0.15", optional = true } wasm-bindgen = "=0.2.95" wasm-bindgen-futures.workspace = true web-sys.workspace = true + +[dev-dependencies] +egui_kittest = { workspace = true, features = ["eframe", "snapshot", "wgpu"] } \ No newline at end of file diff --git a/crates/egui_demo_app/src/apps/image_viewer.rs b/crates/egui_demo_app/src/apps/image_viewer.rs index 80961915e..326718a23 100644 --- a/crates/egui_demo_app/src/apps/image_viewer.rs +++ b/crates/egui_demo_app/src/apps/image_viewer.rs @@ -54,8 +54,9 @@ impl eframe::App for ImageViewer { fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) { egui::TopBottomPanel::new(TopBottomSide::Top, "url bar").show(ctx, |ui| { ui.horizontal_centered(|ui| { - ui.label("URI:"); - ui.text_edit_singleline(&mut self.uri_edit_text); + let label = ui.label("URI:"); + ui.text_edit_singleline(&mut self.uri_edit_text) + .labelled_by(label.id); if ui.small_button("✔").clicked() { ctx.forget_image(&self.current_uri); self.uri_edit_text = self.uri_edit_text.trim().to_owned(); diff --git a/crates/egui_demo_app/src/lib.rs b/crates/egui_demo_app/src/lib.rs index aa220f2b6..0eb34486d 100644 --- a/crates/egui_demo_app/src/lib.rs +++ b/crates/egui_demo_app/src/lib.rs @@ -6,7 +6,7 @@ mod backend_panel; mod frame_history; mod wrap_app; -pub use wrap_app::WrapApp; +pub use wrap_app::{Anchor, WrapApp}; /// Time of day as seconds since midnight. Used for clock in demo app. pub(crate) fn seconds_since_midnight() -> f64 { diff --git a/crates/egui_demo_app/src/wrap_app.rs b/crates/egui_demo_app/src/wrap_app.rs index cbed988fa..117824e01 100644 --- a/crates/egui_demo_app/src/wrap_app.rs +++ b/crates/egui_demo_app/src/wrap_app.rs @@ -38,6 +38,7 @@ impl eframe::App for DemoApp { #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct FractalClockApp { fractal_clock: crate::apps::FractalClock, + pub mock_time: Option, } impl eframe::App for FractalClockApp { @@ -46,7 +47,7 @@ impl eframe::App for FractalClockApp { .frame(egui::Frame::dark_canvas(&ctx.style())) .show(ctx, |ui| { self.fractal_clock - .ui(ui, Some(crate::seconds_since_midnight())); + .ui(ui, self.mock_time.or(Some(crate::seconds_since_midnight()))); }); } } @@ -77,7 +78,7 @@ impl eframe::App for ColorTestApp { #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -enum Anchor { +pub enum Anchor { Demo, EasyMarkEditor, @@ -161,7 +162,7 @@ pub struct State { http: crate::apps::HttpApp, #[cfg(feature = "image_viewer")] image_viewer: crate::apps::ImageViewer, - clock: FractalClockApp, + pub clock: FractalClockApp, rendering_test: ColorTestApp, selected_anchor: Anchor, @@ -170,7 +171,7 @@ pub struct State { /// Wraps many demo/test apps into one. pub struct WrapApp { - state: State, + pub state: State, #[cfg(any(feature = "glow", feature = "wgpu"))] custom3d: Option, @@ -203,7 +204,9 @@ impl WrapApp { slf } - fn apps_iter_mut(&mut self) -> impl Iterator { + pub fn apps_iter_mut( + &mut self, + ) -> impl Iterator { let mut vec = vec![ ( "✨ Demos", diff --git a/crates/egui_demo_app/tests/snapshots/clock.png b/crates/egui_demo_app/tests/snapshots/clock.png new file mode 100644 index 000000000..51d271ddd --- /dev/null +++ b/crates/egui_demo_app/tests/snapshots/clock.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7c05cc3d48242e46a391af34cb56f72de7933bf2cead009b6cd477c21867a84e +size 327802 diff --git a/crates/egui_demo_app/tests/snapshots/custom3d.png b/crates/egui_demo_app/tests/snapshots/custom3d.png new file mode 100644 index 000000000..1e51e9f6a --- /dev/null +++ b/crates/egui_demo_app/tests/snapshots/custom3d.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61212e30fe1fecf5891ddad6ac795df510bfad76b21a7a8a13aa024fdad6d05e +size 93118 diff --git a/crates/egui_demo_app/tests/snapshots/easymarkeditor.png b/crates/egui_demo_app/tests/snapshots/easymarkeditor.png new file mode 100644 index 000000000..23e1c59b0 --- /dev/null +++ b/crates/egui_demo_app/tests/snapshots/easymarkeditor.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7bcf6e2977bed682d7bdaa0b6a6786e528662dd0791d2e6f83cf1b4852035838 +size 182833 diff --git a/crates/egui_demo_app/tests/snapshots/imageviewer.png b/crates/egui_demo_app/tests/snapshots/imageviewer.png new file mode 100644 index 000000000..1176b2d33 --- /dev/null +++ b/crates/egui_demo_app/tests/snapshots/imageviewer.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e6cc6ff64eb73ddac89ecdacd07c2176f3ab952c0db4593fccf6d11f155ec392 +size 103100 diff --git a/crates/egui_demo_app/tests/test_demo_app.rs b/crates/egui_demo_app/tests/test_demo_app.rs new file mode 100644 index 000000000..b242dad43 --- /dev/null +++ b/crates/egui_demo_app/tests/test_demo_app.rs @@ -0,0 +1,78 @@ +use egui::accesskit::Role; +use egui::Vec2; +use egui_demo_app::{Anchor, WrapApp}; +use egui_kittest::kittest::Queryable; + +#[test] +fn test_demo_app() { + let mut harness = egui_kittest::Harness::builder() + .with_size(Vec2::new(900.0, 600.0)) + .wgpu() + .build_eframe(|cc| WrapApp::new(cc)); + + let app = harness.state_mut(); + + // Mock the fractal clock time so snapshots are consistent. + app.state.clock.mock_time = Some(36383.0); + + let apps = app + .apps_iter_mut() + .map(|(name, anchor, _)| (name, anchor)) + .collect::>(); + + #[cfg(feature = "wgpu")] + assert!( + apps.iter() + .any(|(_, anchor)| matches!(anchor, Anchor::Custom3d)), + "Expected to find the Custom3d app.", + ); + + let mut results = vec![]; + + for (name, anchor) in apps { + harness.get_by_role_and_label(Role::Button, name).click(); + + match anchor { + // The widget gallery demo shows the current date, so we can't use it for snapshot testing + Anchor::Demo => { + continue; + } + // This is already tested extensively elsewhere + Anchor::Rendering => { + continue; + } + // We don't want to rely on a network connection for tests + #[cfg(feature = "http")] + Anchor::Http => { + continue; + } + // Load a local image where we know it exists and loads quickly + #[cfg(feature = "image_viewer")] + Anchor::ImageViewer => { + harness.run(); + + harness + .get_by_role_and_label(Role::TextInput, "URI:") + .focus(); + harness.press_key_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + + harness + .get_by_role_and_label(Role::TextInput, "URI:") + .type_text("file://../eframe/data/icon.png"); + + harness.get_by_role_and_label(Role::Button, "✔").click(); + } + _ => {} + } + + harness.run(); + + if let Err(e) = harness.try_snapshot(&anchor.to_string()) { + results.push(e); + } + } + + if let Some(error) = results.first() { + panic!("{error}"); + } +} 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 7e4891e1f..9cf01e9ea 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -405,7 +405,7 @@ mod tests { options.threshold = 2.1; } - let result = harness.try_wgpu_snapshot_options(&format!("demos/{name}"), &options); + let result = harness.try_snapshot_options(&format!("demos/{name}"), &options); if let Err(err) = result { errors.push(err.to_string()); } diff --git a/crates/egui_demo_lib/src/demo/modals.rs b/crates/egui_demo_lib/src/demo/modals.rs index 989101b4d..8d22c0cc0 100644 --- a/crates/egui_demo_lib/src/demo/modals.rs +++ b/crates/egui_demo_lib/src/demo/modals.rs @@ -235,21 +235,21 @@ mod tests { let mut results = Vec::new(); harness.run(); - results.push(harness.try_wgpu_snapshot("modals_1")); + results.push(harness.try_snapshot("modals_1")); harness.get_by_label("Save").click(); // TODO(lucasmerlin): Remove these extra runs once run checks for repaint requests harness.run(); harness.run(); harness.run(); - results.push(harness.try_wgpu_snapshot("modals_2")); + results.push(harness.try_snapshot("modals_2")); harness.get_by_label("Yes Please").click(); // TODO(lucasmerlin): Remove these extra runs once run checks for repaint requests harness.run(); harness.run(); harness.run(); - results.push(harness.try_wgpu_snapshot("modals_3")); + results.push(harness.try_snapshot("modals_3")); for result in results { result.unwrap(); @@ -282,6 +282,6 @@ mod tests { harness.run(); // This snapshots should show the progress bar modal on top of the save modal. - harness.wgpu_snapshot("modals_backdrop_should_prevent_focusing_lower_area"); + harness.snapshot("modals_backdrop_should_prevent_focusing_lower_area"); } } diff --git a/crates/egui_demo_lib/src/demo/widget_gallery.rs b/crates/egui_demo_lib/src/demo/widget_gallery.rs index d473d4b9d..cfe746899 100644 --- a/crates/egui_demo_lib/src/demo/widget_gallery.rs +++ b/crates/egui_demo_lib/src/demo/widget_gallery.rs @@ -307,6 +307,6 @@ mod tests { harness.fit_contents(); - harness.wgpu_snapshot("widget_gallery"); + harness.snapshot("widget_gallery"); } } diff --git a/crates/egui_demo_lib/src/rendering_test.rs b/crates/egui_demo_lib/src/rendering_test.rs index 011ca4736..9d4c7985f 100644 --- a/crates/egui_demo_lib/src/rendering_test.rs +++ b/crates/egui_demo_lib/src/rendering_test.rs @@ -703,7 +703,7 @@ mod tests { harness.fit_contents(); - let result = harness.try_wgpu_snapshot(&format!("rendering_test/dpi_{dpi:.2}")); + let result = harness.try_snapshot(&format!("rendering_test/dpi_{dpi:.2}")); if let Err(err) = result { errors.push(err); } diff --git a/crates/egui_kittest/Cargo.toml b/crates/egui_kittest/Cargo.toml index d93f03483..4f52d4aef 100644 --- a/crates/egui_kittest/Cargo.toml +++ b/crates/egui_kittest/Cargo.toml @@ -20,15 +20,19 @@ include = ["../LICENSE-APACHE", "../LICENSE-MIT", "**/*.rs", "Cargo.toml"] [features] # Adds a wgpu-based test renderer. -wgpu = ["dep:egui-wgpu", "dep:pollster", "dep:image"] +wgpu = ["dep:egui-wgpu", "dep:pollster", "dep:image", "eframe?/wgpu"] # Adds a dify-based image snapshot utility. snapshot = ["dep:dify", "dep:image", "image/png"] +# Allows testing eframe::App +eframe = ["dep:eframe", "eframe/accesskit"] + [dependencies] kittest.workspace = true egui = { workspace = true, features = ["accesskit"] } +eframe = { workspace = true, optional = true } # wgpu dependencies egui-wgpu = { workspace = true, optional = true } diff --git a/crates/egui_kittest/README.md b/crates/egui_kittest/README.md index c124fac3a..99fe9be65 100644 --- a/crates/egui_kittest/README.md +++ b/crates/egui_kittest/README.md @@ -29,13 +29,13 @@ fn main() { // You can even render the ui and do image snapshot tests #[cfg(all(feature = "wgpu", feature = "snapshot"))] - harness.wgpu_snapshot("readme_example"); + harness.snapshot("readme_example"); } ``` ## Snapshot testing There is a snapshot testing feature. To create snapshot tests, enable the `snapshot` and `wgpu` features. -Once enabled, you can call `Harness::wgpu_snapshot` to render the ui and save the image to the `tests/snapshots` directory. +Once enabled, you can call `Harness::snapshot` to render the ui and save the image to the `tests/snapshots` directory. To update the snapshots, run your tests with `UPDATE_SNAPSHOTS=true`, so e.g. `UPDATE_SNAPSHOTS=true cargo test`. Running with `UPDATE_SNAPSHOTS=true` will still cause the tests to fail, but on the next run, the tests should pass. diff --git a/crates/egui_kittest/src/app_kind.rs b/crates/egui_kittest/src/app_kind.rs index 8a180b3b9..3b5cf9e73 100644 --- a/crates/egui_kittest/src/app_kind.rs +++ b/crates/egui_kittest/src/app_kind.rs @@ -5,37 +5,22 @@ type AppKindUiState<'a, State> = Box; type AppKindContext<'a> = Box; type AppKindUi<'a> = Box; +/// In order to access the [`eframe::App`] trait from the generic `State`, we store a function pointer +/// here that will return the dyn trait from the struct. In the builder we have the correct where +/// clause to be able to create this. +/// Later we can use it anywhere to get the [`eframe::App`] from the `State`. +#[cfg(feature = "eframe")] +type AppKindEframe<'a, State> = (fn(&mut State) -> &mut dyn eframe::App, eframe::Frame); + pub(crate) enum AppKind<'a, State> { Context(AppKindContext<'a>), Ui(AppKindUi<'a>), ContextState(AppKindContextState<'a, State>), UiState(AppKindUiState<'a, State>), + #[cfg(feature = "eframe")] + Eframe(AppKindEframe<'a, State>), } -// TODO(lucasmerlin): These aren't working unfortunately :( -// I think they should work though: https://geo-ant.github.io/blog/2021/rust-traits-and-variadic-functions/ -// pub trait IntoAppKind<'a, UiKind> { -// fn into_harness_kind(self) -> AppKind<'a>; -// } -// -// impl<'a, F> IntoAppKind<'a, &egui::Context> for F -// where -// F: FnMut(&egui::Context) + 'a, -// { -// fn into_harness_kind(self) -> AppKind<'a> { -// AppKind::Context(Box::new(self)) -// } -// } -// -// impl<'a, F> IntoAppKind<'a, &mut egui::Ui> for F -// where -// F: FnMut(&mut egui::Ui) + 'a, -// { -// fn into_harness_kind(self) -> AppKind<'a> { -// AppKind::Ui(Box::new(self)) -// } -// } - impl<'a, State> AppKind<'a, State> { pub fn run( &mut self, @@ -54,6 +39,12 @@ impl<'a, State> AppKind<'a, State> { f(ctx, state); None } + #[cfg(feature = "eframe")] + AppKind::Eframe((get_app, frame)) => { + let app = get_app(state); + app.update(ctx, frame); + None + } kind_ui => Some(kind_ui.run_ui(ctx, state, sizing_pass)), } } @@ -78,7 +69,9 @@ impl<'a, State> AppKind<'a, State> { .show(ui, |ui| match self { AppKind::Ui(f) => f(ui), AppKind::UiState(f) => f(ui, state), - _ => unreachable!(), + _ => unreachable!( + "run_ui should only be called with AppKind::Ui or AppKind UiState" + ), }); }) .response diff --git a/crates/egui_kittest/src/builder.rs b/crates/egui_kittest/src/builder.rs index 65c4dfbf0..d0c219bb1 100644 --- a/crates/egui_kittest/src/builder.rs +++ b/crates/egui_kittest/src/builder.rs @@ -1,5 +1,6 @@ use crate::app_kind::AppKind; -use crate::Harness; +use crate::wgpu::WgpuTestRenderer; +use crate::{Harness, LazyRenderer, TestRenderer}; use egui::{Pos2, Rect, Vec2}; use std::marker::PhantomData; @@ -8,6 +9,7 @@ pub struct HarnessBuilder { pub(crate) screen_rect: Rect, pub(crate) pixels_per_point: f32, pub(crate) state: PhantomData, + pub(crate) renderer: Box, } impl Default for HarnessBuilder { @@ -16,6 +18,7 @@ impl Default for HarnessBuilder { screen_rect: Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)), pixels_per_point: 1.0, state: PhantomData, + renderer: Box::new(LazyRenderer::default()), } } } @@ -37,6 +40,29 @@ impl HarnessBuilder { self } + /// Set the [`TestRenderer`] to use for rendering. + /// + /// By default, a [`LazyRenderer`] is used. + #[inline] + pub fn renderer(mut self, renderer: impl TestRenderer + 'static) -> Self { + self.renderer = Box::new(renderer); + self + } + + /// Enable wgpu rendering with a default setup suitable for testing. + /// + /// This sets up a [`WgpuTestRenderer`] with the default setup. + #[cfg(feature = "wgpu")] + pub fn wgpu(self) -> Self { + self.renderer(WgpuTestRenderer::default()) + } + + /// Enable wgpu rendering with the given setup. + #[cfg(feature = "wgpu")] + pub fn wgpu_setup(self, setup: egui_wgpu::WgpuSetup) -> Self { + self.renderer(WgpuTestRenderer::from_setup(setup)) + } + /// Create a new Harness with the given app closure and a state. /// /// The app closure will immediately be called once to create the initial ui. @@ -66,7 +92,7 @@ impl HarnessBuilder { app: impl FnMut(&egui::Context, &mut State) + 'a, state: State, ) -> Harness<'a, State> { - Harness::from_builder(&self, AppKind::ContextState(Box::new(app)), state) + Harness::from_builder(self, AppKind::ContextState(Box::new(app)), state, None) } /// Create a new Harness with the given ui closure and a state. @@ -95,7 +121,30 @@ impl HarnessBuilder { app: impl FnMut(&mut egui::Ui, &mut State) + 'a, state: State, ) -> Harness<'a, State> { - Harness::from_builder(&self, AppKind::UiState(Box::new(app)), state) + Harness::from_builder(self, AppKind::UiState(Box::new(app)), state, None) + } + + /// Create a new [Harness] from the given eframe creation closure. + /// The app can be accessed via the [`Harness::state`] / [`Harness::state_mut`] methods. + #[cfg(feature = "eframe")] + pub fn build_eframe<'a>( + self, + build: impl FnOnce(&mut eframe::CreationContext<'a>) -> State, + ) -> Harness<'a, State> + where + State: eframe::App, + { + let ctx = egui::Context::default(); + + let mut cc = eframe::CreationContext::_new_kittest(ctx.clone()); + let mut frame = eframe::Frame::_new_kittest(); + + self.renderer.setup_eframe(&mut cc, &mut frame); + + let app = build(&mut cc); + + let kind = AppKind::Eframe((|state| state, frame)); + Harness::from_builder(self, kind, app, Some(ctx)) } } @@ -119,7 +168,7 @@ impl HarnessBuilder { /// }); /// ``` pub fn build<'a>(self, app: impl FnMut(&egui::Context) + 'a) -> Harness<'a> { - Harness::from_builder(&self, AppKind::Context(Box::new(app)), ()) + Harness::from_builder(self, AppKind::Context(Box::new(app)), (), None) } /// Create a new Harness with the given ui closure. @@ -138,6 +187,6 @@ impl HarnessBuilder { /// }); /// ``` pub fn build_ui<'a>(self, app: impl FnMut(&mut egui::Ui) + 'a) -> Harness<'a> { - Harness::from_builder(&self, AppKind::Ui(Box::new(app)), ()) + Harness::from_builder(self, AppKind::Ui(Box::new(app)), (), None) } } diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index 03648aa16..83c6f548a 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -12,18 +12,21 @@ mod snapshot; pub use snapshot::*; use std::fmt::{Debug, Formatter}; mod app_kind; +mod renderer; #[cfg(feature = "wgpu")] mod texture_to_image; #[cfg(feature = "wgpu")] pub mod wgpu; pub use kittest; -use std::mem; use crate::app_kind::AppKind; use crate::event::EventState; + pub use builder::*; -use egui::{Pos2, Rect, TexturesDelta, Vec2, ViewportId}; +pub use renderer::*; + +use egui::{Modifiers, Pos2, Rect, Vec2, ViewportId}; use kittest::{Node, Queryable}; /// The test Harness. This contains everything needed to run the test. @@ -37,11 +40,11 @@ pub struct Harness<'a, State = ()> { input: egui::RawInput, kittest: kittest::State, output: egui::FullOutput, - texture_deltas: Vec, app: AppKind<'a, State>, event_state: EventState, response: Option, state: State, + renderer: Box, } impl<'a, State> Debug for Harness<'a, State> { @@ -52,11 +55,12 @@ impl<'a, State> Debug for Harness<'a, State> { impl<'a, State> Harness<'a, State> { pub(crate) fn from_builder( - builder: &HarnessBuilder, + builder: HarnessBuilder, mut app: AppKind<'a, State>, mut state: State, + ctx: Option, ) -> Self { - let ctx = egui::Context::default(); + let ctx = ctx.unwrap_or_default(); ctx.enable_accesskit(); let mut input = egui::RawInput { screen_rect: Some(builder.screen_rect), @@ -73,6 +77,9 @@ impl<'a, State> Harness<'a, State> { response = app.run(ctx, &mut state, false); }); + let mut renderer = builder.renderer; + renderer.handle_delta(&output.textures_delta); + let mut harness = Self { app, ctx, @@ -84,11 +91,11 @@ impl<'a, State> Harness<'a, State> { .take() .expect("AccessKit was disabled"), ), - texture_deltas: vec![mem::take(&mut output.textures_delta)], output, response, event_state: EventState::default(), state, + renderer, }; // Run the harness until it is stable, ensuring that all Areas are shown and animations are done harness.run(); @@ -153,6 +160,15 @@ impl<'a, State> Harness<'a, State> { Self::builder().build_ui_state(app, state) } + /// Create a new [Harness] from the given eframe creation closure. + #[cfg(feature = "eframe")] + pub fn new_eframe(builder: impl FnOnce(&mut eframe::CreationContext<'a>) -> State) -> Self + where + State: eframe::App, + { + Self::builder().build_eframe(builder) + } + /// Set the size of the window. /// Note: If you only want to set the size once at the beginning, /// prefer using [`HarnessBuilder::with_size`]. @@ -194,8 +210,7 @@ impl<'a, State> Harness<'a, State> { .take() .expect("AccessKit was disabled"), ); - self.texture_deltas - .push(mem::take(&mut output.textures_delta)); + self.renderer.handle_delta(&output.textures_delta); self.output = output; } @@ -253,21 +268,35 @@ impl<'a, State> Harness<'a, State> { /// Press a key. /// This will create a key down event and a key up event. pub fn press_key(&mut self, key: egui::Key) { + self.press_key_modifiers(Modifiers::default(), key); + } + + /// Press a key with modifiers. + /// This will create a key down event and a key up event. + pub fn press_key_modifiers(&mut self, modifiers: Modifiers, key: egui::Key) { self.input.events.push(egui::Event::Key { key, pressed: true, - modifiers: Default::default(), + modifiers, repeat: false, physical_key: None, }); self.input.events.push(egui::Event::Key { key, pressed: false, - modifiers: Default::default(), + modifiers, repeat: false, physical_key: None, }); } + + /// Render the last output to an image. + /// + /// # Errors + /// Returns an error if the rendering fails. + pub fn render(&mut self) -> Result { + self.renderer.render(&self.ctx, &self.output) + } } /// Utilities for stateless harnesses. diff --git a/crates/egui_kittest/src/renderer.rs b/crates/egui_kittest/src/renderer.rs new file mode 100644 index 000000000..cbd789a0c --- /dev/null +++ b/crates/egui_kittest/src/renderer.rs @@ -0,0 +1,81 @@ +use egui::{Context, FullOutput, TexturesDelta}; +use image::RgbaImage; + +pub trait TestRenderer { + /// We use this to pass the glow / wgpu render state to [`eframe::Frame`]. + #[cfg(feature = "eframe")] + fn setup_eframe(&self, _cc: &mut eframe::CreationContext<'_>, _frame: &mut eframe::Frame) {} + + /// Handle a [`TexturesDelta`] by updating the renderer's textures. + fn handle_delta(&mut self, delta: &TexturesDelta); + + /// Render the [`crate::Harness`] and return the resulting image. + /// + /// # Errors + /// Returns an error if the rendering fails. + fn render(&mut self, ctx: &Context, output: &FullOutput) -> Result; +} + +/// A lazy renderer that initializes the renderer on the first render call. +/// +/// By default, this will create a wgpu renderer if the wgpu feature is enabled. +pub enum LazyRenderer { + Uninitialized { + texture_ops: Vec, + builder: Option Box>>, + }, + Initialized { + renderer: Box, + }, +} + +impl Default for LazyRenderer { + fn default() -> Self { + #[cfg(feature = "wgpu")] + return Self::new(crate::wgpu::WgpuTestRenderer::new); + #[cfg(not(feature = "wgpu"))] + return Self::Uninitialized { + texture_ops: Vec::new(), + builder: None, + }; + } +} + +impl LazyRenderer { + pub fn new(create_renderer: impl FnOnce() -> T + 'static) -> Self { + Self::Uninitialized { + texture_ops: Vec::new(), + builder: Some(Box::new(move || Box::new(create_renderer()))), + } + } +} + +impl TestRenderer for LazyRenderer { + fn handle_delta(&mut self, delta: &TexturesDelta) { + match self { + Self::Uninitialized { texture_ops, .. } => texture_ops.push(delta.clone()), + Self::Initialized { renderer } => renderer.handle_delta(delta), + } + } + + fn render(&mut self, ctx: &Context, output: &FullOutput) -> Result { + match self { + Self::Uninitialized { + texture_ops, + builder: build, + } => { + let mut renderer = build.take().ok_or({ + "No default renderer available. \ + Enable the wgpu feature or set one via HarnessBuilder::renderer" + })?(); + for delta in texture_ops.drain(..) { + renderer.handle_delta(&delta); + } + let image = renderer.render(ctx, output)?; + *self = Self::Initialized { renderer }; + Ok(image) + } + Self::Initialized { renderer } => renderer.render(ctx, output), + } + } +} diff --git a/crates/egui_kittest/src/snapshot.rs b/crates/egui_kittest/src/snapshot.rs index f4d480b64..e56a2285f 100644 --- a/crates/egui_kittest/src/snapshot.rs +++ b/crates/egui_kittest/src/snapshot.rs @@ -93,6 +93,12 @@ pub enum SnapshotError { /// The error that occurred err: ImageError, }, + + /// Error rendering the image + RenderError { + /// The error that occurred + err: String, + }, } const HOW_TO_UPDATE_SCREENSHOTS: &str = @@ -142,6 +148,9 @@ impl Display for SnapshotError { let path = std::path::absolute(path).unwrap_or(path.clone()); write!(f, "Error writing snapshot: {err:?}\nAt: {path:?}") } + Self::RenderError { err } => { + write!(f, "Error rendering image: {err:?}") + } } } } @@ -315,7 +324,7 @@ pub fn image_snapshot(current: &image::RgbaImage, name: &str) { #[cfg(feature = "wgpu")] impl Harness<'_, State> { - /// Render a image using a default [`crate::wgpu::TestRenderer`] and compare it to the snapshot + /// Render a image using the setup [`crate::TestRenderer`] and compare it to the snapshot /// with custom options. /// /// If you want to change the default options for your whole project, you could create an @@ -323,7 +332,7 @@ impl Harness<'_, State> { /// new `my_image_snapshot` function on the Harness that calls this function with the desired options. /// You could additionally use the /// [disallowed_methods](https://rust-lang.github.io/rust-clippy/master/#disallowed_methods) - /// lint to disable use of the [`Harness::wgpu_snapshot`] to prevent accidentally using the wrong defaults. + /// lint to disable use of the [`Harness::snapshot`] to prevent accidentally using the wrong defaults. /// /// The snapshot files will be saved under [`SnapshotOptions::output_path`]. /// The snapshot will be saved under `{output_path}/{name}.png`. @@ -331,31 +340,35 @@ impl Harness<'_, State> { /// If new image didn't match the snapshot, a diff image will be saved under `{output_path}/{name}.diff.png`. /// /// # 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_wgpu_snapshot_options( - &self, + /// 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_options( + &mut self, name: &str, options: &SnapshotOptions, ) -> Result<(), SnapshotError> { - let image = crate::wgpu::TestRenderer::new().render(self); + let image = self + .render() + .map_err(|err| SnapshotError::RenderError { err })?; try_image_snapshot_options(&image, name, options) } - /// Render a image using a default [`crate::wgpu::TestRenderer`] and compare it to the snapshot. + /// Render a image using the setup [`crate::TestRenderer`] and compare it to the snapshot. /// The snapshot will be saved under `tests/snapshots/{name}.png`. /// The new image from the last test run will be saved under `tests/snapshots/{name}.new.png`. /// If new image didn't match the snapshot, a diff image will be saved under `tests/snapshots/{name}.diff.png`. /// /// # 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_wgpu_snapshot(&self, name: &str) -> Result<(), SnapshotError> { - let image = crate::wgpu::TestRenderer::new().render(self); + /// 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) -> Result<(), SnapshotError> { + let image = self + .render() + .map_err(|err| SnapshotError::RenderError { err })?; try_image_snapshot(&image, name) } - /// Render a image using a default [`crate::wgpu::TestRenderer`] and compare it to the snapshot + /// Render a image using the setup [`crate::TestRenderer`] and compare it to the snapshot /// with custom options. /// /// If you want to change the default options for your whole project, you could create an @@ -363,7 +376,7 @@ impl Harness<'_, State> { /// new `my_image_snapshot` function on the Harness that calls this function with the desired options. /// You could additionally use the /// [disallowed_methods](https://rust-lang.github.io/rust-clippy/master/#disallowed_methods) - /// lint to disable use of the [`Harness::wgpu_snapshot`] to prevent accidentally using the wrong defaults. + /// lint to disable use of the [`Harness::snapshot`] to prevent accidentally using the wrong defaults. /// /// The snapshot files will be saved under [`SnapshotOptions::output_path`]. /// The snapshot will be saved under `{output_path}/{name}.png`. @@ -371,11 +384,11 @@ impl Harness<'_, State> { /// If new image didn't match the snapshot, a diff image will be saved under `{output_path}/{name}.diff.png`. /// /// # Panics - /// Panics if the image does not match the snapshot or if there was an error reading or writing the - /// snapshot. + /// 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 wgpu_snapshot_options(&self, name: &str, options: &SnapshotOptions) { - match self.try_wgpu_snapshot_options(name, options) { + pub fn snapshot_options(&mut self, name: &str, options: &SnapshotOptions) { + match self.try_snapshot_options(name, options) { Ok(_) => {} Err(err) => { panic!("{}", err); @@ -383,17 +396,17 @@ impl Harness<'_, State> { } } - /// Render a image using a default [`crate::wgpu::TestRenderer`] and compare it to the snapshot. + /// Render a image using the setup [`crate::TestRenderer`] and compare it to the snapshot. /// The snapshot will be saved under `tests/snapshots/{name}.png`. /// The new image from the last test run will be saved under `tests/snapshots/{name}.new.png`. /// If new image didn't match the snapshot, a diff image will be saved under `tests/snapshots/{name}.diff.png`. /// /// # Panics - /// Panics if the image does not match the snapshot or if there was an error reading or writing the - /// snapshot. + /// 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 wgpu_snapshot(&self, name: &str) { - match self.try_wgpu_snapshot(name) { + pub fn snapshot(&mut self, name: &str) { + match self.try_snapshot(name) { Ok(_) => {} Err(err) => { panic!("{}", err); @@ -401,3 +414,45 @@ impl Harness<'_, State> { } } } + +// Deprecated wgpu_snapshot functions +// TODO(lucasmerlin): Remove in 0.32 +#[allow(clippy::missing_errors_doc)] +#[cfg(feature = "wgpu")] +impl Harness<'_, State> { + #[deprecated( + since = "0.31.0", + note = "Use `try_snapshot_options` instead. This function will be removed in 0.32" + )] + pub fn try_wgpu_snapshot_options( + &mut self, + name: &str, + options: &SnapshotOptions, + ) -> Result<(), SnapshotError> { + self.try_snapshot_options(name, options) + } + + #[deprecated( + 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) -> Result<(), SnapshotError> { + self.try_snapshot(name) + } + + #[deprecated( + 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) { + self.snapshot_options(name, options); + } + + #[deprecated( + since = "0.31.0", + note = "Use `snapshot` instead. This function will be removed in 0.32" + )] + pub fn wgpu_snapshot(&mut self, name: &str) { + self.snapshot(name); + } +} diff --git a/crates/egui_kittest/src/wgpu.rs b/crates/egui_kittest/src/wgpu.rs index 4c3001fa7..3f229763f 100644 --- a/crates/egui_kittest/src/wgpu.rs +++ b/crates/egui_kittest/src/wgpu.rs @@ -1,122 +1,152 @@ use crate::texture_to_image::texture_to_image; -use crate::Harness; -use egui_wgpu::wgpu::{Backends, InstanceDescriptor, StoreOp, TextureFormat}; -use egui_wgpu::{wgpu, ScreenDescriptor}; +use eframe::epaint::TextureId; +use egui::TexturesDelta; +use egui_wgpu::wgpu::{Backends, StoreOp, TextureFormat}; +use egui_wgpu::{wgpu, RenderState, ScreenDescriptor, WgpuSetup}; use image::RgbaImage; use std::iter::once; +use std::sync::Arc; use wgpu::Maintain; -/// Utility to render snapshots from a [`Harness`] using [`egui_wgpu`]. -pub struct TestRenderer { - device: wgpu::Device, - queue: wgpu::Queue, - dithering: bool, +// TODO(#5506): Replace this with the setup from https://github.com/emilk/egui/pull/5506 +pub fn default_wgpu_setup() -> egui_wgpu::WgpuSetup { + egui_wgpu::WgpuSetup::CreateNew { + supported_backends: Backends::all(), + device_descriptor: Arc::new(|_| wgpu::DeviceDescriptor::default()), + power_preference: wgpu::PowerPreference::default(), + } } -impl Default for TestRenderer { +pub fn create_render_state(setup: WgpuSetup) -> egui_wgpu::RenderState { + let instance = match &setup { + WgpuSetup::Existing { instance, .. } => instance.clone(), + WgpuSetup::CreateNew { .. } => Default::default(), + }; + + pollster::block_on(egui_wgpu::RenderState::create( + &egui_wgpu::WgpuConfiguration { + wgpu_setup: setup, + ..Default::default() + }, + &instance, + None, + None, + 1, + false, + )) + .expect("Failed to create render state") +} + +/// Utility to render snapshots from a [`crate::Harness`] using [`egui_wgpu`]. +pub struct WgpuTestRenderer { + render_state: RenderState, +} + +impl Default for WgpuTestRenderer { fn default() -> Self { Self::new() } } -impl TestRenderer { - /// Create a new [`TestRenderer`] using a default [`wgpu::Instance`]. +impl WgpuTestRenderer { + /// Create a new [`WgpuTestRenderer`] with the default setup. pub fn new() -> Self { - let instance = wgpu::Instance::new(InstanceDescriptor::default()); - - let adapters = instance.enumerate_adapters(Backends::all()); - let adapter = adapters.first().expect("No adapter found"); - - let (device, queue) = pollster::block_on(adapter.request_device( - &wgpu::DeviceDescriptor { - label: Some("Egui Device"), - memory_hints: Default::default(), - required_limits: Default::default(), - required_features: Default::default(), - }, - None, - )) - .expect("Failed to create device"); - - Self::create(device, queue) - } - - /// Create a new [`TestRenderer`] using the provided [`wgpu::Device`] and [`wgpu::Queue`]. - pub fn create(device: wgpu::Device, queue: wgpu::Queue) -> Self { Self { - device, - queue, - dithering: false, + render_state: create_render_state(default_wgpu_setup()), } } - /// Enable or disable dithering. + /// Create a new [`WgpuTestRenderer`] with the given setup. + pub fn from_setup(setup: WgpuSetup) -> Self { + Self { + render_state: create_render_state(setup), + } + } + + /// Create a new [`WgpuTestRenderer`] from an existing [`RenderState`]. /// - /// Disabled by default. - #[inline] - pub fn with_dithering(mut self, dithering: bool) -> Self { - self.dithering = dithering; - self + /// # Panics + /// Panics if the [`RenderState`] has been used before. + pub fn from_render_state(render_state: RenderState) -> Self { + assert!( + render_state + .renderer + .read() + .texture(&TextureId::Managed(0)) + .is_none(), + "The RenderState passed in has been used before, pass in a fresh RenderState instead." + ); + Self { render_state } + } +} + +impl crate::TestRenderer for WgpuTestRenderer { + #[cfg(feature = "eframe")] + fn setup_eframe(&self, cc: &mut eframe::CreationContext<'_>, frame: &mut eframe::Frame) { + cc.wgpu_render_state = Some(self.render_state.clone()); + frame.wgpu_render_state = Some(self.render_state.clone()); } - /// Render the [`Harness`] and return the resulting image. - pub fn render(&self, harness: &Harness<'_, State>) -> RgbaImage { - // We need to create a new renderer each time we render, since the renderer stores - // textures related to the Harnesses' egui Context. - // Calling the renderer from different Harnesses would cause problems if we store the renderer. - let mut renderer = egui_wgpu::Renderer::new( - &self.device, - TextureFormat::Rgba8Unorm, - None, - 1, - self.dithering, - ); - - for delta in &harness.texture_deltas { - for (id, image_delta) in &delta.set { - renderer.update_texture(&self.device, &self.queue, *id, image_delta); - } + fn handle_delta(&mut self, delta: &TexturesDelta) { + let mut renderer = self.render_state.renderer.write(); + for (id, image) in &delta.set { + renderer.update_texture( + &self.render_state.device, + &self.render_state.queue, + *id, + image, + ); } + } - let mut encoder = self - .device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("Egui Command Encoder"), - }); + /// Render the [`crate::Harness`] and return the resulting image. + fn render( + &mut self, + ctx: &egui::Context, + output: &egui::FullOutput, + ) -> Result { + let mut renderer = self.render_state.renderer.write(); - let size = harness.ctx.screen_rect().size() * harness.ctx.pixels_per_point(); + let mut encoder = + self.render_state + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Egui Command Encoder"), + }); + + let size = ctx.screen_rect().size() * ctx.pixels_per_point(); let screen = ScreenDescriptor { - pixels_per_point: harness.ctx.pixels_per_point(), + pixels_per_point: ctx.pixels_per_point(), size_in_pixels: [size.x.round() as u32, size.y.round() as u32], }; - let tessellated = harness.ctx.tessellate( - harness.output().shapes.clone(), - harness.ctx.pixels_per_point(), - ); + let tessellated = ctx.tessellate(output.shapes.clone(), ctx.pixels_per_point()); let user_buffers = renderer.update_buffers( - &self.device, - &self.queue, + &self.render_state.device, + &self.render_state.queue, &mut encoder, &tessellated, &screen, ); - let texture = self.device.create_texture(&wgpu::TextureDescriptor { - label: Some("Egui Texture"), - size: wgpu::Extent3d { - width: screen.size_in_pixels[0], - height: screen.size_in_pixels[1], - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, - view_formats: &[], - }); + let texture = self + .render_state + .device + .create_texture(&wgpu::TextureDescriptor { + label: Some("Egui Texture"), + size: wgpu::Extent3d { + width: screen.size_in_pixels[0], + height: screen.size_in_pixels[1], + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -141,11 +171,16 @@ impl TestRenderer { renderer.render(&mut pass, &tessellated, &screen); } - self.queue + self.render_state + .queue .submit(user_buffers.into_iter().chain(once(encoder.finish()))); - self.device.poll(Maintain::Wait); + self.render_state.device.poll(Maintain::Wait); - texture_to_image(&self.device, &self.queue, &texture) + Ok(texture_to_image( + &self.render_state.device, + &self.render_state.queue, + &texture, + )) } } diff --git a/crates/egui_kittest/tests/regression_tests.rs b/crates/egui_kittest/tests/regression_tests.rs index 690ca86f6..32b412e6e 100644 --- a/crates/egui_kittest/tests/regression_tests.rs +++ b/crates/egui_kittest/tests/regression_tests.rs @@ -41,5 +41,5 @@ fn image_failed() { harness.fit_contents(); #[cfg(all(feature = "wgpu", feature = "snapshot"))] - harness.wgpu_snapshot("image_snapshots"); + harness.snapshot("image_snapshots"); } diff --git a/crates/egui_kittest/tests/tests.rs b/crates/egui_kittest/tests/tests.rs index 6799b9a35..bf7d0bdb2 100644 --- a/crates/egui_kittest/tests/tests.rs +++ b/crates/egui_kittest/tests/tests.rs @@ -11,5 +11,5 @@ fn test_shrink() { harness.fit_contents(); #[cfg(all(feature = "snapshot", feature = "wgpu"))] - harness.wgpu_snapshot("test_shrink"); + harness.snapshot("test_shrink"); } From 4784136fee8a46db3761fc53c616e57db3cb615d Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Thu, 2 Jan 2025 23:50:40 +0100 Subject: [PATCH 9/9] Better rounding of rectangles with thin outlines (#5571) Better positioning of rectangle outline when the stroke width is less than one pixel --- crates/egui_kittest/src/snapshot.rs | 2 +- crates/epaint/src/tessellator.rs | 54 +++++++++-------------------- 2 files changed, 18 insertions(+), 38 deletions(-) diff --git a/crates/egui_kittest/src/snapshot.rs b/crates/egui_kittest/src/snapshot.rs index e56a2285f..956329127 100644 --- a/crates/egui_kittest/src/snapshot.rs +++ b/crates/egui_kittest/src/snapshot.rs @@ -102,7 +102,7 @@ pub enum SnapshotError { } const HOW_TO_UPDATE_SCREENSHOTS: &str = - "Run `UPDATE_SNAPSHOTS=1 cargo test` to update the snapshots."; + "Run `UPDATE_SNAPSHOTS=1 cargo test --all-features` to update the snapshots."; impl Display for SnapshotError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { diff --git a/crates/epaint/src/tessellator.rs b/crates/epaint/src/tessellator.rs index 57d30b11b..b3e018a91 100644 --- a/crates/epaint/src/tessellator.rs +++ b/crates/epaint/src/tessellator.rs @@ -1291,29 +1291,6 @@ impl Tessellator { self.clip_rect = clip_rect; } - #[inline(always)] - pub fn round_to_pixel(&self, point: f32) -> f32 { - (point * self.pixels_per_point).round() / self.pixels_per_point - } - - #[inline(always)] - pub fn round_to_pixel_center(&self, point: f32) -> f32 { - ((point * self.pixels_per_point - 0.5).round() + 0.5) / self.pixels_per_point - } - - #[inline(always)] - pub fn round_pos_to_pixel(&self, pos: Pos2) -> Pos2 { - pos2(self.round_to_pixel(pos.x), self.round_to_pixel(pos.y)) - } - - #[inline(always)] - pub fn round_pos_to_pixel_center(&self, pos: Pos2) -> Pos2 { - pos2( - self.round_to_pixel_center(pos.x), - self.round_to_pixel_center(pos.y), - ) - } - /// Tessellate a clipped shape into a list of primitives. pub fn tessellate_clipped_shape( &mut self, @@ -1716,8 +1693,16 @@ impl Tessellator { // Since the stroke extends outside of the rectangle, // we can round the rectangle sides to the physical pixel edges, // and the filled rect will appear crisp, as will the inside of the stroke. - let Stroke { .. } = stroke; // Make sure we remember to update this if we change `stroke` to `PathStroke` - rect = rect.round_to_pixels(self.pixels_per_point); + let Stroke { width, .. } = stroke; // Make sure we remember to update this if we change `stroke` to `PathStroke` + if width <= self.feathering && !stroke.is_empty() { + // If the stroke is thin, make sure its center is in the center of the pixel: + rect = rect + .expand(width / 2.0) + .round_to_pixel_center(self.pixels_per_point) + .shrink(width / 2.0); + } else { + rect = rect.round_to_pixels(self.pixels_per_point); + } } // It is common to (sometimes accidentally) create an infinitely sized rectangle. @@ -1727,7 +1712,7 @@ impl Tessellator { let old_feathering = self.feathering; - if old_feathering < blur_width { + if self.feathering < blur_width { // We accomplish the blur by using a larger-than-normal feathering. // Feathering is usually used to make the edges of a shape softer for anti-aliasing. @@ -1836,10 +1821,7 @@ impl Tessellator { // The contents of the galley are already snapped to pixel coordinates, // but we need to make sure the galley ends up on the start of a physical pixel: let galley_pos = if self.options.round_text_to_pixels { - pos2( - self.round_to_pixel(galley_pos.x), - self.round_to_pixel(galley_pos.y), - ) + galley_pos.round_to_pixels(self.pixels_per_point) } else { *galley_pos }; @@ -1917,13 +1899,11 @@ impl Tessellator { ); if *underline != Stroke::NONE { - self.scratchpad_path.clear(); - self.scratchpad_path.add_line_segment([ - self.round_pos_to_pixel_center(row_rect.left_bottom()), - self.round_pos_to_pixel_center(row_rect.right_bottom()), - ]); - self.scratchpad_path - .stroke_open(0.0, &PathStroke::from(*underline), out); + self.tessellate_line_segment( + [row_rect.left_bottom(), row_rect.right_bottom()], + *underline, + out, + ); } } }