diff --git a/Cargo.lock b/Cargo.lock index 9d7c11a1..9a441cb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1263,6 +1263,7 @@ dependencies = [ "profiling", "ron", "serde", + "smallvec", "unicode-segmentation", ] diff --git a/Cargo.toml b/Cargo.toml index d1efd4ae..bf5b27d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,6 +98,7 @@ raw-window-handle = "0.6.0" ron = "0.10.1" serde = { version = "1", features = ["derive"] } similar-asserts = "1.4.2" +smallvec = "1" thiserror = "1.0.37" type-map = "0.5.0" unicode-segmentation = "1.12.0" diff --git a/crates/egui/Cargo.toml b/crates/egui/Cargo.toml index e7641ef3..238a8d8e 100644 --- a/crates/egui/Cargo.toml +++ b/crates/egui/Cargo.toml @@ -87,6 +87,7 @@ ahash.workspace = true bitflags.workspace = true nohash-hasher.workspace = true profiling.workspace = true +smallvec.workspace = true unicode-segmentation.workspace = true #! ### Optional dependencies diff --git a/crates/egui/src/atomics/atom.rs b/crates/egui/src/atomics/atom.rs new file mode 100644 index 00000000..4f4b5b75 --- /dev/null +++ b/crates/egui/src/atomics/atom.rs @@ -0,0 +1,109 @@ +use crate::{AtomKind, Id, SizedAtom, Ui}; +use emath::{NumExt as _, Vec2}; +use epaint::text::TextWrapMode; + +/// A low-level ui building block. +/// +/// Implements [`From`] for [`String`], [`str`], [`crate::Image`] and much more for convenience. +/// You can directly call the `atom_*` methods on anything that implements `Into`. +/// ``` +/// # use egui::{Image, emath::Vec2}; +/// use egui::AtomExt as _; +/// let string_atom = "Hello".atom_grow(true); +/// let image_atom = Image::new("some_image_url").atom_size(Vec2::splat(20.0)); +/// ``` +#[derive(Clone, Debug)] +pub struct Atom<'a> { + /// See [`crate::AtomExt::atom_size`] + pub size: Option, + + /// See [`crate::AtomExt::atom_max_size`] + pub max_size: Vec2, + + /// See [`crate::AtomExt::atom_grow`] + pub grow: bool, + + /// See [`crate::AtomExt::atom_shrink`] + pub shrink: bool, + + /// The atom type + pub kind: AtomKind<'a>, +} + +impl Default for Atom<'_> { + fn default() -> Self { + Atom { + size: None, + max_size: Vec2::INFINITY, + grow: false, + shrink: false, + kind: AtomKind::Empty, + } + } +} + +impl<'a> Atom<'a> { + /// Create an empty [`Atom`] marked as `grow`. + /// + /// This will expand in size, allowing all preceding atoms to be left-aligned, + /// and all following atoms to be right-aligned + pub fn grow() -> Self { + Atom { + grow: true, + ..Default::default() + } + } + + /// Create a [`AtomKind::Custom`] with a specific size. + pub fn custom(id: Id, size: impl Into) -> Self { + Atom { + size: Some(size.into()), + kind: AtomKind::Custom(id), + ..Default::default() + } + } + + /// Turn this into a [`SizedAtom`]. + pub fn into_sized( + self, + ui: &Ui, + mut available_size: Vec2, + mut wrap_mode: Option, + ) -> SizedAtom<'a> { + if !self.shrink && self.max_size.x.is_infinite() { + wrap_mode = Some(TextWrapMode::Extend); + } + available_size = available_size.at_most(self.max_size); + if let Some(size) = self.size { + available_size = available_size.at_most(size); + } + if self.max_size.x.is_finite() { + wrap_mode = Some(TextWrapMode::Truncate); + } + + let (preferred, kind) = self.kind.into_sized(ui, available_size, wrap_mode); + + let size = self + .size + .map_or_else(|| kind.size(), |s| s.at_most(self.max_size)); + + SizedAtom { + size, + preferred_size: preferred, + grow: self.grow, + kind, + } + } +} + +impl<'a, T> From for Atom<'a> +where + T: Into>, +{ + fn from(value: T) -> Self { + Atom { + kind: value.into(), + ..Default::default() + } + } +} diff --git a/crates/egui/src/atomics/atom_ext.rs b/crates/egui/src/atomics/atom_ext.rs new file mode 100644 index 00000000..0c34544d --- /dev/null +++ b/crates/egui/src/atomics/atom_ext.rs @@ -0,0 +1,107 @@ +use crate::{Atom, FontSelection, Ui}; +use emath::Vec2; + +/// A trait for conveniently building [`Atom`]s. +/// +/// The functions are prefixed with `atom_` to avoid conflicts with e.g. [`crate::RichText::size`]. +pub trait AtomExt<'a> { + /// Set the atom to a fixed size. + /// + /// If [`Atom::grow`] is `true`, this will be the minimum width. + /// If [`Atom::shrink`] is `true`, this will be the maximum width. + /// If both are true, the width will have no effect. + /// + /// [`Self::atom_max_size`] will limit size. + /// + /// See [`crate::AtomKind`] docs to see how the size affects the different types. + fn atom_size(self, size: Vec2) -> Atom<'a>; + + /// Grow this atom to the available space. + /// + /// This will affect the size of the [`Atom`] in the main direction. Since + /// [`crate::AtomLayout`] today only supports horizontal layout, it will affect the width. + /// + /// You can also combine this with [`Self::atom_shrink`] to make it always take exactly the + /// remaining space. + fn atom_grow(self, grow: bool) -> Atom<'a>; + + /// Shrink this atom if there isn't enough space. + /// + /// This will affect the size of the [`Atom`] in the main direction. Since + /// [`crate::AtomLayout`] today only supports horizontal layout, it will affect the width. + /// + /// NOTE: Only a single [`Atom`] may shrink for each widget. + /// + /// If no atom was set to shrink and `wrap_mode != TextWrapMode::Extend`, the first + /// `AtomKind::Text` is set to shrink. + fn atom_shrink(self, shrink: bool) -> Atom<'a>; + + /// Set the maximum size of this atom. + /// + /// Will not affect the space taken by `grow` (All atoms marked as grow will always grow + /// equally to fill the available space). + fn atom_max_size(self, max_size: Vec2) -> Atom<'a>; + + /// Set the maximum width of this atom. + /// + /// Will not affect the space taken by `grow` (All atoms marked as grow will always grow + /// equally to fill the available space). + fn atom_max_width(self, max_width: f32) -> Atom<'a>; + + /// Set the maximum height of this atom. + fn atom_max_height(self, max_height: f32) -> Atom<'a>; + + /// Set the max height of this atom to match the font size. + /// + /// This is useful for e.g. limiting the height of icons in buttons. + fn atom_max_height_font_size(self, ui: &Ui) -> Atom<'a> + where + Self: Sized, + { + let font_selection = FontSelection::default(); + let font_id = font_selection.resolve(ui.style()); + let height = ui.fonts(|f| f.row_height(&font_id)); + self.atom_max_height(height) + } +} + +impl<'a, T> AtomExt<'a> for T +where + T: Into> + Sized, +{ + fn atom_size(self, size: Vec2) -> Atom<'a> { + let mut atom = self.into(); + atom.size = Some(size); + atom + } + + fn atom_grow(self, grow: bool) -> Atom<'a> { + let mut atom = self.into(); + atom.grow = grow; + atom + } + + fn atom_shrink(self, shrink: bool) -> Atom<'a> { + let mut atom = self.into(); + atom.shrink = shrink; + atom + } + + fn atom_max_size(self, max_size: Vec2) -> Atom<'a> { + let mut atom = self.into(); + atom.max_size = max_size; + atom + } + + fn atom_max_width(self, max_width: f32) -> Atom<'a> { + let mut atom = self.into(); + atom.max_size.x = max_width; + atom + } + + fn atom_max_height(self, max_height: f32) -> Atom<'a> { + let mut atom = self.into(); + atom.max_size.y = max_height; + atom + } +} diff --git a/crates/egui/src/atomics/atom_kind.rs b/crates/egui/src/atomics/atom_kind.rs new file mode 100644 index 00000000..2672e646 --- /dev/null +++ b/crates/egui/src/atomics/atom_kind.rs @@ -0,0 +1,120 @@ +use crate::{Id, Image, ImageSource, SizedAtomKind, TextStyle, Ui, WidgetText}; +use emath::Vec2; +use epaint::text::TextWrapMode; + +/// The different kinds of [`crate::Atom`]s. +#[derive(Clone, Default, Debug)] +pub enum AtomKind<'a> { + /// Empty, that can be used with [`crate::AtomExt::atom_grow`] to reserve space. + #[default] + Empty, + + /// Text atom. + /// + /// Truncation within [`crate::AtomLayout`] works like this: + /// - + /// - if `wrap_mode` is not Extend + /// - if no atom is `shrink` + /// - the first text atom is selected and will be marked as `shrink` + /// - the atom marked as `shrink` will shrink / wrap based on the selected wrap mode + /// - any other text atoms will have `wrap_mode` extend + /// - if `wrap_mode` is extend, Text will extend as expected. + /// + /// Unless [`crate::AtomExt::atom_max_width`] is set, `wrap_mode` should only be set via [`crate::Style`] or + /// [`crate::AtomLayout::wrap_mode`], as setting a wrap mode on a [`WidgetText`] atom + /// that is not `shrink` will have unexpected results. + /// + /// The size is determined by converting the [`WidgetText`] into a galley and using the galleys + /// size. You can use [`crate::AtomExt::atom_size`] to override this, and [`crate::AtomExt::atom_max_width`] + /// to limit the width (Causing the text to wrap or truncate, depending on the `wrap_mode`. + /// [`crate::AtomExt::atom_max_height`] has no effect on text. + Text(WidgetText), + + /// Image atom. + /// + /// By default the size is determined via [`Image::calc_size`]. + /// You can use [`crate::AtomExt::atom_max_size`] or [`crate::AtomExt::atom_size`] to customize the size. + /// There is also a helper [`crate::AtomExt::atom_max_height_font_size`] to set the max height to the + /// default font height, which is convenient for icons. + Image(Image<'a>), + + /// For custom rendering. + /// + /// You can get the [`crate::Rect`] with the [`Id`] from [`crate::AtomLayoutResponse`] and use a + /// [`crate::Painter`] or [`Ui::put`] to add/draw some custom content. + /// + /// Example: + /// ``` + /// # use egui::{AtomExt, AtomKind, Atom, Button, Id, __run_test_ui}; + /// # use emath::Vec2; + /// # __run_test_ui(|ui| { + /// let id = Id::new("my_button"); + /// let response = Button::new(("Hi!", Atom::custom(id, Vec2::splat(18.0)))).atom_ui(ui); + /// + /// let rect = response.rect(id); + /// if let Some(rect) = rect { + /// ui.put(rect, Button::new("⏵")); + /// } + /// # }); + /// ``` + Custom(Id), +} + +impl<'a> AtomKind<'a> { + pub fn text(text: impl Into) -> Self { + AtomKind::Text(text.into()) + } + + pub fn image(image: impl Into>) -> Self { + AtomKind::Image(image.into()) + } + + /// Turn this [`AtomKind`] into a [`SizedAtomKind`]. + /// + /// This converts [`WidgetText`] into [`crate::Galley`] and tries to load and size [`Image`]. + /// The first returned argument is the preferred size. + pub fn into_sized( + self, + ui: &Ui, + available_size: Vec2, + wrap_mode: Option, + ) -> (Vec2, SizedAtomKind<'a>) { + match self { + AtomKind::Text(text) => { + let galley = text.into_galley(ui, wrap_mode, available_size.x, TextStyle::Button); + ( + galley.size(), // TODO(#5762): calculate the preferred size + SizedAtomKind::Text(galley), + ) + } + AtomKind::Image(image) => { + let size = image.load_and_calc_size(ui, available_size); + let size = size.unwrap_or(Vec2::ZERO); + (size, SizedAtomKind::Image(image, size)) + } + AtomKind::Custom(id) => (Vec2::ZERO, SizedAtomKind::Custom(id)), + AtomKind::Empty => (Vec2::ZERO, SizedAtomKind::Empty), + } + } +} + +impl<'a> From> for AtomKind<'a> { + fn from(value: ImageSource<'a>) -> Self { + AtomKind::Image(value.into()) + } +} + +impl<'a> From> for AtomKind<'a> { + fn from(value: Image<'a>) -> Self { + AtomKind::Image(value) + } +} + +impl From for AtomKind<'_> +where + T: Into, +{ + fn from(value: T) -> Self { + AtomKind::Text(value.into()) + } +} diff --git a/crates/egui/src/atomics/atom_layout.rs b/crates/egui/src/atomics/atom_layout.rs new file mode 100644 index 00000000..a25a4b7c --- /dev/null +++ b/crates/egui/src/atomics/atom_layout.rs @@ -0,0 +1,493 @@ +use crate::atomics::ATOMS_SMALL_VEC_SIZE; +use crate::{ + AtomKind, Atoms, Frame, Id, Image, IntoAtoms, Response, Sense, SizedAtom, SizedAtomKind, Ui, + Widget, +}; +use emath::{Align2, GuiRounding as _, NumExt as _, Rect, Vec2}; +use epaint::text::TextWrapMode; +use epaint::{Color32, Galley}; +use smallvec::SmallVec; +use std::ops::{Deref, DerefMut}; +use std::sync::Arc; + +/// Intra-widget layout utility. +/// +/// Used to lay out and paint [`crate::Atom`]s. +/// This is used internally by widgets like [`crate::Button`] and [`crate::Checkbox`]. +/// You can use it to make your own widgets. +/// +/// Painting the atoms can be split in two phases: +/// - [`AtomLayout::allocate`] +/// - calculates sizes +/// - converts texts to [`Galley`]s +/// - allocates a [`Response`] +/// - returns a [`AllocatedAtomLayout`] +/// - [`AllocatedAtomLayout::paint`] +/// - paints the [`Frame`] +/// - calculates individual [`crate::Atom`] positions +/// - paints each single atom +/// +/// You can use this to first allocate a response and then modify, e.g., the [`Frame`] on the +/// [`AllocatedAtomLayout`] for interaction styling. +pub struct AtomLayout<'a> { + id: Option, + pub atoms: Atoms<'a>, + gap: Option, + pub(crate) frame: Frame, + pub(crate) sense: Sense, + fallback_text_color: Option, + min_size: Vec2, + wrap_mode: Option, + align2: Option, +} + +impl Default for AtomLayout<'_> { + fn default() -> Self { + Self::new(()) + } +} + +impl<'a> AtomLayout<'a> { + pub fn new(atoms: impl IntoAtoms<'a>) -> Self { + Self { + id: None, + atoms: atoms.into_atoms(), + gap: None, + frame: Frame::default(), + sense: Sense::hover(), + fallback_text_color: None, + min_size: Vec2::ZERO, + wrap_mode: None, + align2: None, + } + } + + /// Set the gap between atoms. + /// + /// Default: `Spacing::icon_spacing` + #[inline] + pub fn gap(mut self, gap: f32) -> Self { + self.gap = Some(gap); + self + } + + /// Set the [`Frame`]. + #[inline] + pub fn frame(mut self, frame: Frame) -> Self { + self.frame = frame; + self + } + + /// Set the [`Sense`] used when allocating the [`Response`]. + #[inline] + pub fn sense(mut self, sense: Sense) -> Self { + self.sense = sense; + self + } + + /// Set the fallback (default) text color. + /// + /// Default: [`crate::Visuals::text_color`] + #[inline] + pub fn fallback_text_color(mut self, color: Color32) -> Self { + self.fallback_text_color = Some(color); + self + } + + /// Set the minimum size of the Widget. + /// + /// This will find and expand atoms with `grow: true`. + /// If there are no growable atoms then everything will be left-aligned. + #[inline] + pub fn min_size(mut self, size: Vec2) -> Self { + self.min_size = size; + self + } + + /// Set the [`Id`] used to allocate a [`Response`]. + #[inline] + pub fn id(mut self, id: Id) -> Self { + self.id = Some(id); + self + } + + /// Set the [`TextWrapMode`] for the [`crate::Atom`] marked as `shrink`. + /// + /// Only a single [`crate::Atom`] may shrink. If this (or `ui.wrap_mode()`) is not + /// [`TextWrapMode::Extend`] and no item is set to shrink, the first (left-most) + /// [`AtomKind::Text`] will be set to shrink. + #[inline] + pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self { + self.wrap_mode = Some(wrap_mode); + self + } + + /// Set the [`Align2`]. + /// + /// This will align the [`crate::Atom`]s within the [`Rect`] returned by [`Ui::allocate_space`]. + /// + /// The default is chosen based on the [`Ui`]s [`crate::Layout`]. See + /// [this snapshot](https://github.com/emilk/egui/blob/master/tests/egui_tests/tests/snapshots/layout/button.png) + /// for info on how the [`crate::Layout`] affects the alignment. + #[inline] + pub fn align2(mut self, align2: Align2) -> Self { + self.align2 = Some(align2); + self + } + + /// [`AtomLayout::allocate`] and [`AllocatedAtomLayout::paint`] in one go. + pub fn show(self, ui: &mut Ui) -> AtomLayoutResponse { + self.allocate(ui).paint(ui) + } + + /// Calculate sizes, create [`Galley`]s and allocate a [`Response`]. + /// + /// Use the returned [`AllocatedAtomLayout`] for painting. + pub fn allocate(self, ui: &mut Ui) -> AllocatedAtomLayout<'a> { + let Self { + id, + mut atoms, + gap, + frame, + sense, + fallback_text_color, + min_size, + wrap_mode, + align2, + } = self; + + let wrap_mode = wrap_mode.unwrap_or(ui.wrap_mode()); + + // If the TextWrapMode is not Extend, ensure there is some item marked as `shrink`. + // If none is found, mark the first text item as `shrink`. + if wrap_mode != TextWrapMode::Extend { + let any_shrink = atoms.iter().any(|a| a.shrink); + if !any_shrink { + let first_text = atoms + .iter_mut() + .find(|a| matches!(a.kind, AtomKind::Text(..))); + if let Some(atom) = first_text { + atom.shrink = true; // Will make the text truncate or shrink depending on wrap_mode + } + } + } + + let id = id.unwrap_or_else(|| ui.next_auto_id()); + + let fallback_text_color = + fallback_text_color.unwrap_or_else(|| ui.style().visuals.text_color()); + let gap = gap.unwrap_or(ui.spacing().icon_spacing); + + // The size available for the content + let available_inner_size = ui.available_size() - frame.total_margin().sum(); + + let mut desired_width = 0.0; + + // Preferred width / height is the ideal size of the widget, e.g. the size where the + // text is not wrapped. Used to set Response::intrinsic_size. + let mut preferred_width = 0.0; + let mut preferred_height = 0.0; + + let mut height: f32 = 0.0; + + let mut sized_items = SmallVec::new(); + + let mut grow_count = 0; + + let mut shrink_item = None; + + let align2 = align2.unwrap_or_else(|| { + Align2([ui.layout().horizontal_align(), ui.layout().vertical_align()]) + }); + + if atoms.len() > 1 { + let gap_space = gap * (atoms.len() as f32 - 1.0); + desired_width += gap_space; + preferred_width += gap_space; + } + + for (idx, item) in atoms.into_iter().enumerate() { + if item.grow { + grow_count += 1; + } + if item.shrink { + debug_assert!( + shrink_item.is_none(), + "Only one atomic may be marked as shrink. {item:?}" + ); + if shrink_item.is_none() { + shrink_item = Some((idx, item)); + continue; + } + } + let sized = item.into_sized(ui, available_inner_size, Some(wrap_mode)); + let size = sized.size; + + desired_width += size.x; + preferred_width += sized.preferred_size.x; + + height = height.at_least(size.y); + preferred_height = preferred_height.at_least(sized.preferred_size.y); + + sized_items.push(sized); + } + + if let Some((index, item)) = shrink_item { + // The `shrink` item gets the remaining space + let available_size_for_shrink_item = Vec2::new( + available_inner_size.x - desired_width, + available_inner_size.y, + ); + + let sized = item.into_sized(ui, available_size_for_shrink_item, Some(wrap_mode)); + let size = sized.size; + + desired_width += size.x; + preferred_width += sized.preferred_size.x; + + height = height.at_least(size.y); + preferred_height = preferred_height.at_least(sized.preferred_size.y); + + sized_items.insert(index, sized); + } + + let margin = frame.total_margin(); + let desired_size = Vec2::new(desired_width, height); + let frame_size = (desired_size + margin.sum()).at_least(min_size); + + let (_, rect) = ui.allocate_space(frame_size); + let mut response = ui.interact(rect, id, sense); + + response.intrinsic_size = + Some((Vec2::new(preferred_width, preferred_height) + margin.sum()).at_least(min_size)); + + AllocatedAtomLayout { + sized_atoms: sized_items, + frame, + fallback_text_color, + response, + grow_count, + desired_size, + align2, + gap, + } + } +} + +/// Instructions for painting an [`AtomLayout`]. +#[derive(Clone, Debug)] +pub struct AllocatedAtomLayout<'a> { + pub sized_atoms: SmallVec<[SizedAtom<'a>; ATOMS_SMALL_VEC_SIZE]>, + pub frame: Frame, + pub fallback_text_color: Color32, + pub response: Response, + grow_count: usize, + // The size of the inner content, before any growing. + desired_size: Vec2, + align2: Align2, + gap: f32, +} + +impl<'atom> AllocatedAtomLayout<'atom> { + pub fn iter_kinds(&self) -> impl Iterator> { + self.sized_atoms.iter().map(|atom| &atom.kind) + } + + pub fn iter_kinds_mut(&mut self) -> impl Iterator> { + self.sized_atoms.iter_mut().map(|atom| &mut atom.kind) + } + + pub fn iter_images(&self) -> impl Iterator> { + self.iter_kinds().filter_map(|kind| { + if let SizedAtomKind::Image(image, _) = kind { + Some(image) + } else { + None + } + }) + } + + pub fn iter_images_mut(&mut self) -> impl Iterator> { + self.iter_kinds_mut().filter_map(|kind| { + if let SizedAtomKind::Image(image, _) = kind { + Some(image) + } else { + None + } + }) + } + + pub fn iter_texts(&self) -> impl Iterator> + use<'atom, '_> { + self.iter_kinds().filter_map(|kind| { + if let SizedAtomKind::Text(text) = kind { + Some(text) + } else { + None + } + }) + } + + pub fn iter_texts_mut(&mut self) -> impl Iterator> + use<'atom, '_> { + self.iter_kinds_mut().filter_map(|kind| { + if let SizedAtomKind::Text(text) = kind { + Some(text) + } else { + None + } + }) + } + + pub fn map_kind(&mut self, mut f: F) + where + F: FnMut(SizedAtomKind<'atom>) -> SizedAtomKind<'atom>, + { + for kind in self.iter_kinds_mut() { + *kind = f(std::mem::take(kind)); + } + } + + pub fn map_images(&mut self, mut f: F) + where + F: FnMut(Image<'atom>) -> Image<'atom>, + { + self.map_kind(|kind| { + if let SizedAtomKind::Image(image, size) = kind { + SizedAtomKind::Image(f(image), size) + } else { + kind + } + }); + } + + /// Paint the [`Frame`] and individual [`crate::Atom`]s. + pub fn paint(self, ui: &Ui) -> AtomLayoutResponse { + let Self { + sized_atoms, + frame, + fallback_text_color, + response, + grow_count, + desired_size, + align2, + gap, + } = self; + + let inner_rect = response.rect - self.frame.total_margin(); + + ui.painter().add(frame.paint(inner_rect)); + + let width_to_fill = inner_rect.width(); + let extra_space = f32::max(width_to_fill - desired_size.x, 0.0); + let grow_width = f32::max(extra_space / grow_count as f32, 0.0).floor_ui(); + + let aligned_rect = if grow_count > 0 { + align2.align_size_within_rect(Vec2::new(width_to_fill, desired_size.y), inner_rect) + } else { + align2.align_size_within_rect(desired_size, inner_rect) + }; + + let mut cursor = aligned_rect.left(); + + let mut response = AtomLayoutResponse::empty(response); + + for sized in sized_atoms { + let size = sized.size; + // TODO(lucasmerlin): This is not ideal, since this might lead to accumulated rounding errors + // https://github.com/emilk/egui/pull/5830#discussion_r2079627864 + let growth = if sized.is_grow() { grow_width } else { 0.0 }; + + let frame = aligned_rect + .with_min_x(cursor) + .with_max_x(cursor + size.x + growth); + cursor = frame.right() + gap; + + let align = Align2::CENTER_CENTER; + let rect = align.align_size_within_rect(size, frame); + + match sized.kind { + SizedAtomKind::Text(galley) => { + ui.painter().galley(rect.min, galley, fallback_text_color); + } + SizedAtomKind::Image(image, _) => { + image.paint_at(ui, rect); + } + SizedAtomKind::Custom(id) => { + debug_assert!( + !response.custom_rects.iter().any(|(i, _)| *i == id), + "Duplicate custom id" + ); + response.custom_rects.push((id, rect)); + } + SizedAtomKind::Empty => {} + } + } + + response + } +} + +/// Response from a [`AtomLayout::show`] or [`AllocatedAtomLayout::paint`]. +/// +/// Use [`AtomLayoutResponse::rect`] to get the response rects from [`AtomKind::Custom`]. +#[derive(Clone, Debug)] +pub struct AtomLayoutResponse { + pub response: Response, + // There should rarely be more than one custom rect. + custom_rects: SmallVec<[(Id, Rect); 1]>, +} + +impl AtomLayoutResponse { + pub fn empty(response: Response) -> Self { + Self { + response, + custom_rects: Default::default(), + } + } + + pub fn custom_rects(&self) -> impl Iterator + '_ { + self.custom_rects.iter().copied() + } + + /// Use this together with [`AtomKind::Custom`] to add custom painting / child widgets. + /// + /// NOTE: Don't `unwrap` rects, they might be empty when the widget is not visible. + pub fn rect(&self, id: Id) -> Option { + self.custom_rects + .iter() + .find_map(|(i, r)| if *i == id { Some(*r) } else { None }) + } +} + +impl Widget for AtomLayout<'_> { + fn ui(self, ui: &mut Ui) -> Response { + self.show(ui).response + } +} + +impl<'a> Deref for AtomLayout<'a> { + type Target = Atoms<'a>; + + fn deref(&self) -> &Self::Target { + &self.atoms + } +} + +impl DerefMut for AtomLayout<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.atoms + } +} + +impl<'a> Deref for AllocatedAtomLayout<'a> { + type Target = [SizedAtom<'a>]; + + fn deref(&self) -> &Self::Target { + &self.sized_atoms + } +} + +impl DerefMut for AllocatedAtomLayout<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.sized_atoms + } +} diff --git a/crates/egui/src/atomics/atoms.rs b/crates/egui/src/atomics/atoms.rs new file mode 100644 index 00000000..3752ace7 --- /dev/null +++ b/crates/egui/src/atomics/atoms.rs @@ -0,0 +1,220 @@ +use crate::{Atom, AtomKind, Image, WidgetText}; +use smallvec::SmallVec; +use std::borrow::Cow; +use std::ops::{Deref, DerefMut}; + +// Rarely there should be more than 2 atoms in one Widget. +// I guess it could happen in a menu button with Image and right text... +pub(crate) const ATOMS_SMALL_VEC_SIZE: usize = 2; + +/// A list of [`Atom`]s. +#[derive(Clone, Debug, Default)] +pub struct Atoms<'a>(SmallVec<[Atom<'a>; ATOMS_SMALL_VEC_SIZE]>); + +impl<'a> Atoms<'a> { + pub fn new(atoms: impl IntoAtoms<'a>) -> Self { + atoms.into_atoms() + } + + /// Insert a new [`Atom`] at the end of the list (right side). + pub fn push_right(&mut self, atom: impl Into>) { + self.0.push(atom.into()); + } + + /// Insert a new [`Atom`] at the beginning of the list (left side). + pub fn push_left(&mut self, atom: impl Into>) { + self.0.insert(0, atom.into()); + } + + /// Concatenate and return the text contents. + // TODO(lucasmerlin): It might not always make sense to return the concatenated text, e.g. + // in a submenu button there is a right text '⏵' which is now passed to the screen reader. + pub fn text(&self) -> Option> { + let mut string: Option> = None; + for atom in &self.0 { + if let AtomKind::Text(text) = &atom.kind { + if let Some(string) = &mut string { + let string = string.to_mut(); + string.push(' '); + string.push_str(text.text()); + } else { + string = Some(Cow::Borrowed(text.text())); + } + } + } + string + } + + pub fn iter_kinds(&'a self) -> impl Iterator> { + self.0.iter().map(|atom| &atom.kind) + } + + pub fn iter_kinds_mut(&'a mut self) -> impl Iterator> { + self.0.iter_mut().map(|atom| &mut atom.kind) + } + + pub fn iter_images(&'a self) -> impl Iterator> { + self.iter_kinds().filter_map(|kind| { + if let AtomKind::Image(image) = kind { + Some(image) + } else { + None + } + }) + } + + pub fn iter_images_mut(&'a mut self) -> impl Iterator> { + self.iter_kinds_mut().filter_map(|kind| { + if let AtomKind::Image(image) = kind { + Some(image) + } else { + None + } + }) + } + + pub fn iter_texts(&'a self) -> impl Iterator { + self.iter_kinds().filter_map(|kind| { + if let AtomKind::Text(text) = kind { + Some(text) + } else { + None + } + }) + } + + pub fn iter_texts_mut(&'a mut self) -> impl Iterator { + self.iter_kinds_mut().filter_map(|kind| { + if let AtomKind::Text(text) = kind { + Some(text) + } else { + None + } + }) + } + + pub fn map_atoms(&mut self, mut f: impl FnMut(Atom<'a>) -> Atom<'a>) { + self.iter_mut() + .for_each(|atom| *atom = f(std::mem::take(atom))); + } + + pub fn map_kind(&'a mut self, mut f: F) + where + F: FnMut(AtomKind<'a>) -> AtomKind<'a>, + { + for kind in self.iter_kinds_mut() { + *kind = f(std::mem::take(kind)); + } + } + + pub fn map_images(&'a mut self, mut f: F) + where + F: FnMut(Image<'a>) -> Image<'a>, + { + self.map_kind(|kind| { + if let AtomKind::Image(image) = kind { + AtomKind::Image(f(image)) + } else { + kind + } + }); + } + + pub fn map_texts(&'a mut self, mut f: F) + where + F: FnMut(WidgetText) -> WidgetText, + { + self.map_kind(|kind| { + if let AtomKind::Text(text) = kind { + AtomKind::Text(f(text)) + } else { + kind + } + }); + } +} + +impl<'a> IntoIterator for Atoms<'a> { + type Item = Atom<'a>; + type IntoIter = smallvec::IntoIter<[Atom<'a>; ATOMS_SMALL_VEC_SIZE]>; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +/// Helper trait to convert a tuple of atoms into [`Atoms`]. +/// +/// ``` +/// use egui::{Atoms, Image, IntoAtoms, RichText}; +/// let atoms: Atoms = ( +/// "Some text", +/// RichText::new("Some RichText"), +/// Image::new("some_image_url"), +/// ).into_atoms(); +/// ``` +impl<'a, T> IntoAtoms<'a> for T +where + T: Into>, +{ + fn collect(self, atoms: &mut Atoms<'a>) { + atoms.push_right(self); + } +} + +/// Trait for turning a tuple of [`Atom`]s into [`Atoms`]. +pub trait IntoAtoms<'a> { + fn collect(self, atoms: &mut Atoms<'a>); + + fn into_atoms(self) -> Atoms<'a> + where + Self: Sized, + { + let mut atoms = Atoms::default(); + self.collect(&mut atoms); + atoms + } +} + +impl<'a> IntoAtoms<'a> for Atoms<'a> { + fn collect(self, atoms: &mut Self) { + atoms.0.extend(self.0); + } +} + +macro_rules! all_the_atoms { + ($($T:ident),*) => { + impl<'a, $($T),*> IntoAtoms<'a> for ($($T),*) + where + $($T: IntoAtoms<'a>),* + { + fn collect(self, _atoms: &mut Atoms<'a>) { + #[allow(clippy::allow_attributes)] + #[allow(non_snake_case)] + let ($($T),*) = self; + $($T.collect(_atoms);)* + } + } + }; +} + +all_the_atoms!(); +all_the_atoms!(T0, T1); +all_the_atoms!(T0, T1, T2); +all_the_atoms!(T0, T1, T2, T3); +all_the_atoms!(T0, T1, T2, T3, T4); +all_the_atoms!(T0, T1, T2, T3, T4, T5); + +impl<'a> Deref for Atoms<'a> { + type Target = [Atom<'a>]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for Atoms<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} diff --git a/crates/egui/src/atomics/mod.rs b/crates/egui/src/atomics/mod.rs new file mode 100644 index 00000000..7c8922c9 --- /dev/null +++ b/crates/egui/src/atomics/mod.rs @@ -0,0 +1,15 @@ +mod atom; +mod atom_ext; +mod atom_kind; +mod atom_layout; +mod atoms; +mod sized_atom; +mod sized_atom_kind; + +pub use atom::*; +pub use atom_ext::*; +pub use atom_kind::*; +pub use atom_layout::*; +pub use atoms::*; +pub use sized_atom::*; +pub use sized_atom_kind::*; diff --git a/crates/egui/src/atomics/sized_atom.rs b/crates/egui/src/atomics/sized_atom.rs new file mode 100644 index 00000000..50fa443a --- /dev/null +++ b/crates/egui/src/atomics/sized_atom.rs @@ -0,0 +1,26 @@ +use crate::SizedAtomKind; +use emath::Vec2; + +/// A [`crate::Atom`] which has been sized. +#[derive(Clone, Debug)] +pub struct SizedAtom<'a> { + pub(crate) grow: bool, + + /// The size of the atom. + /// + /// Used for placing this atom in [`crate::AtomLayout`], the cursor will advance by + /// size.x + gap. + pub size: Vec2, + + /// Preferred size of the atom. This is used to calculate `Response::intrinsic_size`. + pub preferred_size: Vec2, + + pub kind: SizedAtomKind<'a>, +} + +impl SizedAtom<'_> { + /// Was this [`crate::Atom`] marked as `grow`? + pub fn is_grow(&self) -> bool { + self.grow + } +} diff --git a/crates/egui/src/atomics/sized_atom_kind.rs b/crates/egui/src/atomics/sized_atom_kind.rs new file mode 100644 index 00000000..ff8da163 --- /dev/null +++ b/crates/egui/src/atomics/sized_atom_kind.rs @@ -0,0 +1,25 @@ +use crate::{Id, Image}; +use emath::Vec2; +use epaint::Galley; +use std::sync::Arc; + +/// A sized [`crate::AtomKind`]. +#[derive(Clone, Default, Debug)] +pub enum SizedAtomKind<'a> { + #[default] + Empty, + Text(Arc), + Image(Image<'a>, Vec2), + Custom(Id), +} + +impl SizedAtomKind<'_> { + /// Get the calculated size. + pub fn size(&self) -> Vec2 { + match self { + SizedAtomKind::Text(galley) => galley.size(), + SizedAtomKind::Image(_, size) => *size, + SizedAtomKind::Empty | SizedAtomKind::Custom(_) => Vec2::ZERO, + } + } +} diff --git a/crates/egui/src/containers/menu.rs b/crates/egui/src/containers/menu.rs index 228bbd86..4fe06477 100644 --- a/crates/egui/src/containers/menu.rs +++ b/crates/egui/src/containers/menu.rs @@ -1,7 +1,7 @@ use crate::style::StyleModifier; use crate::{ - Button, Color32, Context, Frame, Id, InnerResponse, Layout, Popup, PopupCloseBehavior, - Response, Style, Ui, UiBuilder, UiKind, UiStack, UiStackInfo, Widget as _, WidgetText, + Button, Color32, Context, Frame, Id, InnerResponse, IntoAtoms, Layout, Popup, + PopupCloseBehavior, Response, Style, Ui, UiBuilder, UiKind, UiStack, UiStackInfo, Widget as _, }; use emath::{vec2, Align, RectAlign, Vec2}; use epaint::Stroke; @@ -243,8 +243,8 @@ pub struct MenuButton<'a> { } impl<'a> MenuButton<'a> { - pub fn new(text: impl Into) -> Self { - Self::from_button(Button::new(text)) + pub fn new(atoms: impl IntoAtoms<'a>) -> Self { + Self::from_button(Button::new(atoms.into_atoms())) } /// Set the config for the menu. @@ -293,8 +293,8 @@ impl<'a> SubMenuButton<'a> { /// The default right arrow symbol: `"⏵"` pub const RIGHT_ARROW: &'static str = "⏵"; - pub fn new(text: impl Into) -> Self { - Self::from_button(Button::new(text).right_text("⏵")) + pub fn new(atoms: impl IntoAtoms<'a>) -> Self { + Self::from_button(Button::new(atoms.into_atoms()).right_text("⏵")) } /// Create a new submenu button from a [`Button`]. diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 6d42a230..4ff7db23 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -444,6 +444,7 @@ mod widget_rect; pub mod widget_text; pub mod widgets; +mod atomics; #[cfg(feature = "callstack")] #[cfg(debug_assertions)] mod callstack; @@ -482,6 +483,7 @@ pub mod text { } pub use self::{ + atomics::*, containers::*, context::{Context, RepaintCause, RequestRepaintInfo}, data::{ diff --git a/crates/egui/src/ui.rs b/crates/egui/src/ui.rs index 4174ca96..bc2ed860 100644 --- a/crates/egui/src/ui.rs +++ b/crates/egui/src/ui.rs @@ -25,10 +25,10 @@ use crate::{ color_picker, Button, Checkbox, DragValue, Hyperlink, Image, ImageSource, Label, Link, RadioButton, SelectableLabel, Separator, Spinner, TextEdit, Widget, }, - Align, Color32, Context, CursorIcon, DragAndDrop, Id, InnerResponse, InputState, LayerId, - Memory, Order, Painter, PlatformOutput, Pos2, Rangef, Rect, Response, Rgba, RichText, Sense, - Style, TextStyle, TextWrapMode, UiBuilder, UiKind, UiStack, UiStackInfo, Vec2, WidgetRect, - WidgetText, + Align, Color32, Context, CursorIcon, DragAndDrop, Id, InnerResponse, InputState, IntoAtoms, + LayerId, Memory, Order, Painter, PlatformOutput, Pos2, Rangef, Rect, Response, Rgba, RichText, + Sense, Style, TextStyle, TextWrapMode, UiBuilder, UiKind, UiStack, UiStackInfo, Vec2, + WidgetRect, WidgetText, }; // ---------------------------------------------------------------------------- @@ -2055,8 +2055,8 @@ impl Ui { /// ``` #[must_use = "You should check if the user clicked this with `if ui.button(…).clicked() { … } "] #[inline] - pub fn button(&mut self, text: impl Into) -> Response { - Button::new(text).ui(self) + pub fn button<'a>(&mut self, atoms: impl IntoAtoms<'a>) -> Response { + Button::new(atoms).ui(self) } /// A button as small as normal body text. @@ -2073,8 +2073,8 @@ impl Ui { /// /// See also [`Self::toggle_value`]. #[inline] - pub fn checkbox(&mut self, checked: &mut bool, text: impl Into) -> Response { - Checkbox::new(checked, text).ui(self) + pub fn checkbox<'a>(&mut self, checked: &'a mut bool, atoms: impl IntoAtoms<'a>) -> Response { + Checkbox::new(checked, atoms).ui(self) } /// Acts like a checkbox, but looks like a [`SelectableLabel`]. @@ -2095,8 +2095,8 @@ impl Ui { /// Often you want to use [`Self::radio_value`] instead. #[must_use = "You should check if the user clicked this with `if ui.radio(…).clicked() { … } "] #[inline] - pub fn radio(&mut self, selected: bool, text: impl Into) -> Response { - RadioButton::new(selected, text).ui(self) + pub fn radio<'a>(&mut self, selected: bool, atoms: impl IntoAtoms<'a>) -> Response { + RadioButton::new(selected, atoms).ui(self) } /// Show a [`RadioButton`]. It is selected if `*current_value == selected_value`. @@ -2118,13 +2118,13 @@ impl Ui { /// } /// # }); /// ``` - pub fn radio_value( + pub fn radio_value<'a, Value: PartialEq>( &mut self, current_value: &mut Value, alternative: Value, - text: impl Into, + atoms: impl IntoAtoms<'a>, ) -> Response { - let mut response = self.radio(*current_value == alternative, text); + let mut response = self.radio(*current_value == alternative, atoms); if response.clicked() && *current_value != alternative { *current_value = alternative; response.mark_changed(); @@ -3041,15 +3041,15 @@ impl Ui { /// ``` /// /// See also: [`Self::close`] and [`Response::context_menu`]. - pub fn menu_button( + pub fn menu_button<'a, R>( &mut self, - title: impl Into, + atoms: impl IntoAtoms<'a>, add_contents: impl FnOnce(&mut Ui) -> R, ) -> InnerResponse> { let (response, inner) = if menu::is_in_menu(self) { - menu::SubMenuButton::new(title).ui(self, add_contents) + menu::SubMenuButton::new(atoms).ui(self, add_contents) } else { - menu::MenuButton::new(title).ui(self, add_contents) + menu::MenuButton::new(atoms).ui(self, add_contents) }; InnerResponse::new(inner.map(|i| i.inner), response) } diff --git a/crates/egui/src/widget_text.rs b/crates/egui/src/widget_text.rs index d9f98859..d0edb6a2 100644 --- a/crates/egui/src/widget_text.rs +++ b/crates/egui/src/widget_text.rs @@ -1,7 +1,7 @@ -use std::{borrow::Cow, sync::Arc}; - use emath::GuiRounding as _; use epaint::text::TextFormat; +use std::fmt::Formatter; +use std::{borrow::Cow, sync::Arc}; use crate::{ text::{LayoutJob, TextWrapping}, @@ -521,6 +521,18 @@ pub enum WidgetText { Galley(Arc), } +impl std::fmt::Debug for WidgetText { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let text = self.text(); + match self { + Self::Text(_) => write!(f, "Text({text:?})"), + Self::RichText(_) => write!(f, "RichText({text:?})"), + Self::LayoutJob(_) => write!(f, "LayoutJob({text:?})"), + Self::Galley(_) => write!(f, "Galley({text:?})"), + } + } +} + impl Default for WidgetText { fn default() -> Self { Self::Text(String::new()) diff --git a/crates/egui/src/widgets/button.rs b/crates/egui/src/widgets/button.rs index 9ae573ae..aa75eabd 100644 --- a/crates/egui/src/widgets/button.rs +++ b/crates/egui/src/widgets/button.rs @@ -1,6 +1,7 @@ use crate::{ - widgets, Align, Color32, CornerRadius, FontSelection, Image, NumExt as _, Rect, Response, - Sense, Stroke, TextStyle, TextWrapMode, Ui, Vec2, Widget, WidgetInfo, WidgetText, WidgetType, + Atom, AtomExt as _, AtomKind, AtomLayout, AtomLayoutResponse, Color32, CornerRadius, Frame, + Image, IntoAtoms, NumExt as _, Response, Sense, Stroke, TextWrapMode, Ui, Vec2, Widget, + WidgetInfo, WidgetText, WidgetType, }; /// Clickable button with text. @@ -23,56 +24,66 @@ use crate::{ /// ``` #[must_use = "You should put this widget in a ui with `ui.add(widget);`"] pub struct Button<'a> { - image: Option>, - text: Option, - right_text: WidgetText, - wrap_mode: Option, - - /// None means default for interact + layout: AtomLayout<'a>, fill: Option, stroke: Option, - sense: Sense, small: bool, frame: Option, min_size: Vec2, corner_radius: Option, selected: bool, image_tint_follows_text_color: bool, + limit_image_size: bool, } impl<'a> Button<'a> { - pub fn new(text: impl Into) -> Self { - Self::opt_image_and_text(None, Some(text.into())) - } - - /// Creates a button with an image. The size of the image as displayed is defined by the provided size. - pub fn image(image: impl Into>) -> Self { - Self::opt_image_and_text(Some(image.into()), None) - } - - /// Creates a button with an image to the left of the text. The size of the image as displayed is defined by the provided size. - pub fn image_and_text(image: impl Into>, text: impl Into) -> Self { - Self::opt_image_and_text(Some(image.into()), Some(text.into())) - } - - pub fn opt_image_and_text(image: Option>, text: Option) -> Self { + pub fn new(atoms: impl IntoAtoms<'a>) -> Self { Self { - text, - image, - right_text: Default::default(), - wrap_mode: None, + layout: AtomLayout::new(atoms.into_atoms()).sense(Sense::click()), fill: None, stroke: None, - sense: Sense::click(), small: false, frame: None, min_size: Vec2::ZERO, corner_radius: None, selected: false, image_tint_follows_text_color: false, + limit_image_size: false, } } + /// Creates a button with an image. The size of the image as displayed is defined by the provided size. + /// + /// Note: In contrast to [`Button::new`], this limits the image size to the default font height + /// (using [`crate::AtomExt::atom_max_height_font_size`]). + pub fn image(image: impl Into>) -> Self { + Self::opt_image_and_text(Some(image.into()), None) + } + + /// Creates a button with an image to the left of the text. + /// + /// Note: In contrast to [`Button::new`], this limits the image size to the default font height + /// (using [`crate::AtomExt::atom_max_height_font_size`]). + pub fn image_and_text(image: impl Into>, text: impl Into) -> Self { + Self::opt_image_and_text(Some(image.into()), Some(text.into())) + } + + /// Create a button with an optional image and optional text. + /// + /// Note: In contrast to [`Button::new`], this limits the image size to the default font height + /// (using [`crate::AtomExt::atom_max_height_font_size`]). + pub fn opt_image_and_text(image: Option>, text: Option) -> Self { + let mut button = Self::new(()); + if let Some(image) = image { + button.layout.push_right(image); + } + if let Some(text) = text { + button.layout.push_right(text); + } + button.limit_image_size = true; + button + } + /// Set the wrap mode for the text. /// /// By default, [`crate::Ui::wrap_mode`] will be used, which can be overridden with [`crate::Style::wrap_mode`]. @@ -80,23 +91,20 @@ impl<'a> Button<'a> { /// Note that any `\n` in the text will always produce a new line. #[inline] pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self { - self.wrap_mode = Some(wrap_mode); + self.layout = self.layout.wrap_mode(wrap_mode); self } /// Set [`Self::wrap_mode`] to [`TextWrapMode::Wrap`]. #[inline] - pub fn wrap(mut self) -> Self { - self.wrap_mode = Some(TextWrapMode::Wrap); - - self + pub fn wrap(self) -> Self { + self.wrap_mode(TextWrapMode::Wrap) } /// Set [`Self::wrap_mode`] to [`TextWrapMode::Truncate`]. #[inline] - pub fn truncate(mut self) -> Self { - self.wrap_mode = Some(TextWrapMode::Truncate); - self + pub fn truncate(self) -> Self { + self.wrap_mode(TextWrapMode::Truncate) } /// Override background fill color. Note that this will override any on-hover effects. @@ -104,7 +112,6 @@ impl<'a> Button<'a> { #[inline] pub fn fill(mut self, fill: impl Into) -> Self { self.fill = Some(fill.into()); - self.frame = Some(true); self } @@ -120,9 +127,6 @@ impl<'a> Button<'a> { /// Make this a small button, suitable for embedding into text. #[inline] pub fn small(mut self) -> Self { - if let Some(text) = self.text { - self.text = Some(text.text_style(TextStyle::Body)); - } self.small = true; self } @@ -138,7 +142,7 @@ impl<'a> Button<'a> { /// Change this to a drag-button with `Sense::drag()`. #[inline] pub fn sense(mut self, sense: Sense) -> Self { - self.sense = sense; + self.layout = self.layout.sense(sense); self } @@ -182,15 +186,22 @@ impl<'a> Button<'a> { /// /// See also [`Self::right_text`]. #[inline] - pub fn shortcut_text(mut self, shortcut_text: impl Into) -> Self { - self.right_text = shortcut_text.into().weak(); + pub fn shortcut_text(mut self, shortcut_text: impl Into>) -> Self { + let mut atom = shortcut_text.into(); + atom.kind = match atom.kind { + AtomKind::Text(text) => AtomKind::Text(text.weak()), + other => other, + }; + self.layout.push_right(Atom::grow()); + self.layout.push_right(atom); self } /// Show some text on the right side of the button. #[inline] - pub fn right_text(mut self, right_text: impl Into) -> Self { - self.right_text = right_text.into(); + pub fn right_text(mut self, right_text: impl Into>) -> Self { + self.layout.push_right(Atom::grow()); + self.layout.push_right(right_text.into()); self } @@ -200,39 +211,41 @@ impl<'a> Button<'a> { self.selected = selected; self } -} -impl Widget for Button<'_> { - fn ui(self, ui: &mut Ui) -> Response { + /// Show the button and return a [`AtomLayoutResponse`] for painting custom contents. + pub fn atom_ui(self, ui: &mut Ui) -> AtomLayoutResponse { let Button { - text, - image, - right_text, - wrap_mode, + mut layout, fill, stroke, - sense, small, frame, - min_size, + mut min_size, corner_radius, selected, image_tint_follows_text_color, + limit_image_size, } = self; - let frame = frame.unwrap_or_else(|| ui.visuals().button_frame); + if !small { + min_size.y = min_size.y.at_least(ui.spacing().interact_size.y); + } - let default_font_height = || { - let font_selection = FontSelection::default(); - let font_id = font_selection.resolve(ui.style()); - ui.fonts(|f| f.row_height(&font_id)) - }; + if limit_image_size { + layout.map_atoms(|atom| { + if matches!(&atom.kind, AtomKind::Image(_)) { + atom.atom_max_height_font_size(ui) + } else { + atom + } + }); + } - let text_font_height = ui - .fonts(|fonts| text.as_ref().map(|wt| wt.font_height(fonts, ui.style()))) - .unwrap_or_else(default_font_height); + let text = layout.text().map(String::from); - let mut button_padding = if frame { + let has_frame = frame.unwrap_or_else(|| ui.visuals().button_frame); + + let mut button_padding = if has_frame { ui.spacing().button_padding } else { Vec2::ZERO @@ -241,192 +254,53 @@ impl Widget for Button<'_> { button_padding.y = 0.0; } - let (space_available_for_image, right_text_font_height) = if let Some(text) = &text { - let font_height = ui.fonts(|fonts| text.font_height(fonts, ui.style())); - ( - Vec2::splat(font_height), // Reasonable? - font_height, - ) - } else { - ( - (ui.available_size() - 2.0 * button_padding).at_least(Vec2::ZERO), - default_font_height(), - ) - }; + let mut prepared = layout + .frame(Frame::new().inner_margin(button_padding)) + .min_size(min_size) + .allocate(ui); - let image_size = if let Some(image) = &image { - image - .load_and_calc_size(ui, space_available_for_image) - .unwrap_or(space_available_for_image) - } else { - Vec2::ZERO - }; + let response = if ui.is_rect_visible(prepared.response.rect) { + let visuals = ui.style().interact_selectable(&prepared.response, selected); - let gap_before_right_text = ui.spacing().item_spacing.x; - - let mut text_wrap_width = ui.available_width() - 2.0 * button_padding.x; - if image.is_some() { - text_wrap_width -= image_size.x + ui.spacing().icon_spacing; - } - - // Note: we don't wrap the right text - let right_galley = (!right_text.is_empty()).then(|| { - right_text.into_galley( - ui, - Some(TextWrapMode::Extend), - f32::INFINITY, - TextStyle::Button, - ) - }); - - if let Some(right_galley) = &right_galley { - // Leave space for the right text: - text_wrap_width -= gap_before_right_text + right_galley.size().x; - } - - let galley = - text.map(|text| text.into_galley(ui, wrap_mode, text_wrap_width, TextStyle::Button)); - - let mut desired_size = Vec2::ZERO; - if image.is_some() { - desired_size.x += image_size.x; - desired_size.y = desired_size.y.max(image_size.y); - } - if image.is_some() && galley.is_some() { - desired_size.x += ui.spacing().icon_spacing; - } - if let Some(galley) = &galley { - desired_size.x += galley.size().x; - desired_size.y = desired_size.y.max(galley.size().y).max(text_font_height); - } - if let Some(right_galley) = &right_galley { - desired_size.x += gap_before_right_text + right_galley.size().x; - desired_size.y = desired_size - .y - .max(right_galley.size().y) - .max(right_text_font_height); - } - desired_size += 2.0 * button_padding; - if !small { - desired_size.y = desired_size.y.at_least(ui.spacing().interact_size.y); - } - desired_size = desired_size.at_least(min_size); - - let (rect, mut response) = ui.allocate_at_least(desired_size, sense); - response.widget_info(|| { - let mut widget_info = WidgetInfo::new(WidgetType::Button); - widget_info.enabled = ui.is_enabled(); - - if let Some(galley) = &galley { - widget_info.label = Some(galley.text().to_owned()); - } else if let Some(image) = &image { - widget_info.label = image.alt_text.clone(); + if image_tint_follows_text_color { + prepared.map_images(|image| image.tint(visuals.text_color())); } - widget_info - }); - if ui.is_rect_visible(rect) { - let visuals = ui.style().interact(&response); + prepared.fallback_text_color = visuals.text_color(); - let (frame_expansion, frame_cr, frame_fill, frame_stroke) = if selected { - let selection = ui.visuals().selection; - ( - Vec2::ZERO, - CornerRadius::ZERO, - selection.bg_fill, - selection.stroke, - ) - } else if frame { - let expansion = Vec2::splat(visuals.expansion); - ( - expansion, - visuals.corner_radius, - visuals.weak_bg_fill, - visuals.bg_stroke, - ) - } else { - Default::default() + if has_frame { + let stroke = stroke.unwrap_or(visuals.bg_stroke); + let fill = fill.unwrap_or(visuals.weak_bg_fill); + prepared.frame = prepared + .frame + .inner_margin( + button_padding + Vec2::splat(visuals.expansion) - Vec2::splat(stroke.width), + ) + .outer_margin(-Vec2::splat(visuals.expansion)) + .fill(fill) + .stroke(stroke) + .corner_radius(corner_radius.unwrap_or(visuals.corner_radius)); }; - let frame_cr = corner_radius.unwrap_or(frame_cr); - let frame_fill = fill.unwrap_or(frame_fill); - let frame_stroke = stroke.unwrap_or(frame_stroke); - ui.painter().rect( - rect.expand2(frame_expansion), - frame_cr, - frame_fill, - frame_stroke, - epaint::StrokeKind::Inside, - ); - let mut cursor_x = rect.min.x + button_padding.x; + prepared.paint(ui) + } else { + AtomLayoutResponse::empty(prepared.response) + }; - if let Some(image) = &image { - let mut image_pos = ui - .layout() - .align_size_within_rect(image_size, rect.shrink2(button_padding)) - .min; - if galley.is_some() || right_galley.is_some() { - image_pos.x = cursor_x; - } - let image_rect = Rect::from_min_size(image_pos, image_size); - cursor_x += image_size.x; - let tlr = image.load_for_size(ui.ctx(), image_size); - let mut image_options = image.image_options().clone(); - if image_tint_follows_text_color { - image_options.tint = image_options.tint * visuals.text_color(); - } - widgets::image::paint_texture_load_result( - ui, - &tlr, - image_rect, - image.show_loading_spinner, - &image_options, - None, - ); - response = widgets::image::texture_load_result_response( - &image.source(ui.ctx()), - &tlr, - response, - ); + response.response.widget_info(|| { + if let Some(text) = &text { + WidgetInfo::labeled(WidgetType::Button, ui.is_enabled(), text) + } else { + WidgetInfo::new(WidgetType::Button) } - - if image.is_some() && galley.is_some() { - cursor_x += ui.spacing().icon_spacing; - } - - if let Some(galley) = galley { - let mut text_pos = ui - .layout() - .align_size_within_rect(galley.size(), rect.shrink2(button_padding)) - .min; - if image.is_some() || right_galley.is_some() { - text_pos.x = cursor_x; - } - ui.painter().galley(text_pos, galley, visuals.text_color()); - } - - if let Some(right_galley) = right_galley { - // Always align to the right - let layout = if ui.layout().is_horizontal() { - ui.layout().with_main_align(Align::Max) - } else { - ui.layout().with_cross_align(Align::Max) - }; - let right_text_pos = layout - .align_size_within_rect(right_galley.size(), rect.shrink2(button_padding)) - .min; - - ui.painter() - .galley(right_text_pos, right_galley, visuals.text_color()); - } - } - - if let Some(cursor) = ui.visuals().interact_cursor { - if response.hovered() { - ui.ctx().set_cursor_icon(cursor); - } - } + }); response } } + +impl Widget for Button<'_> { + fn ui(self, ui: &mut Ui) -> Response { + self.atom_ui(ui).response + } +} diff --git a/crates/egui/src/widgets/checkbox.rs b/crates/egui/src/widgets/checkbox.rs index 97bd97b8..f7498de5 100644 --- a/crates/egui/src/widgets/checkbox.rs +++ b/crates/egui/src/widgets/checkbox.rs @@ -1,6 +1,6 @@ use crate::{ - epaint, pos2, vec2, NumExt as _, Response, Sense, Shape, TextStyle, Ui, Vec2, Widget, - WidgetInfo, WidgetText, WidgetType, + epaint, pos2, Atom, AtomLayout, Atoms, Id, IntoAtoms, NumExt as _, Response, Sense, Shape, Ui, + Vec2, Widget, WidgetInfo, WidgetType, }; // TODO(emilk): allow checkbox without a text label @@ -19,21 +19,21 @@ use crate::{ #[must_use = "You should put this widget in a ui with `ui.add(widget);`"] pub struct Checkbox<'a> { checked: &'a mut bool, - text: WidgetText, + atoms: Atoms<'a>, indeterminate: bool, } impl<'a> Checkbox<'a> { - pub fn new(checked: &'a mut bool, text: impl Into) -> Self { + pub fn new(checked: &'a mut bool, atoms: impl IntoAtoms<'a>) -> Self { Checkbox { checked, - text: text.into(), + atoms: atoms.into_atoms(), indeterminate: false, } } pub fn without_text(checked: &'a mut bool) -> Self { - Self::new(checked, WidgetText::default()) + Self::new(checked, ()) } /// Display an indeterminate state (neither checked nor unchecked) @@ -51,92 +51,88 @@ impl Widget for Checkbox<'_> { fn ui(self, ui: &mut Ui) -> Response { let Checkbox { checked, - text, + mut atoms, indeterminate, } = self; let spacing = &ui.spacing(); let icon_width = spacing.icon_width; - let icon_spacing = spacing.icon_spacing; - let (galley, mut desired_size) = if text.is_empty() { - (None, vec2(icon_width, 0.0)) - } else { - let total_extra = vec2(icon_width + icon_spacing, 0.0); + let mut min_size = Vec2::splat(spacing.interact_size.y); + min_size.y = min_size.y.at_least(icon_width); - let wrap_width = ui.available_width() - total_extra.x; - let galley = text.into_galley(ui, None, wrap_width, TextStyle::Button); + // In order to center the checkbox based on min_size we set the icon height to at least min_size.y + let mut icon_size = Vec2::splat(icon_width); + icon_size.y = icon_size.y.at_least(min_size.y); + let rect_id = Id::new("egui::checkbox"); + atoms.push_left(Atom::custom(rect_id, icon_size)); - let mut desired_size = total_extra + galley.size(); - desired_size = desired_size.at_least(spacing.interact_size); + let text = atoms.text().map(String::from); - (Some(galley), desired_size) - }; + let mut prepared = AtomLayout::new(atoms) + .sense(Sense::click()) + .min_size(min_size) + .allocate(ui); - desired_size = desired_size.at_least(Vec2::splat(spacing.interact_size.y)); - desired_size.y = desired_size.y.max(icon_width); - let (rect, mut response) = ui.allocate_exact_size(desired_size, Sense::click()); - - if response.clicked() { + if prepared.response.clicked() { *checked = !*checked; - response.mark_changed(); + prepared.response.mark_changed(); } - response.widget_info(|| { + prepared.response.widget_info(|| { if indeterminate { WidgetInfo::labeled( WidgetType::Checkbox, ui.is_enabled(), - galley.as_ref().map_or("", |x| x.text()), + text.as_deref().unwrap_or(""), ) } else { WidgetInfo::selected( WidgetType::Checkbox, ui.is_enabled(), *checked, - galley.as_ref().map_or("", |x| x.text()), + text.as_deref().unwrap_or(""), ) } }); - if ui.is_rect_visible(rect) { + if ui.is_rect_visible(prepared.response.rect) { // let visuals = ui.style().interact_selectable(&response, *checked); // too colorful - let visuals = ui.style().interact(&response); - let (small_icon_rect, big_icon_rect) = ui.spacing().icon_rectangles(rect); - ui.painter().add(epaint::RectShape::new( - big_icon_rect.expand(visuals.expansion), - visuals.corner_radius, - visuals.bg_fill, - visuals.bg_stroke, - epaint::StrokeKind::Inside, - )); + let visuals = *ui.style().interact(&prepared.response); + prepared.fallback_text_color = visuals.text_color(); + let response = prepared.paint(ui); - if indeterminate { - // Horizontal line: - ui.painter().add(Shape::hline( - small_icon_rect.x_range(), - small_icon_rect.center().y, - visuals.fg_stroke, - )); - } else if *checked { - // Check mark: - ui.painter().add(Shape::line( - vec![ - pos2(small_icon_rect.left(), small_icon_rect.center().y), - pos2(small_icon_rect.center().x, small_icon_rect.bottom()), - pos2(small_icon_rect.right(), small_icon_rect.top()), - ], - visuals.fg_stroke, + if let Some(rect) = response.rect(rect_id) { + let (small_icon_rect, big_icon_rect) = ui.spacing().icon_rectangles(rect); + ui.painter().add(epaint::RectShape::new( + big_icon_rect.expand(visuals.expansion), + visuals.corner_radius, + visuals.bg_fill, + visuals.bg_stroke, + epaint::StrokeKind::Inside, )); + + if indeterminate { + // Horizontal line: + ui.painter().add(Shape::hline( + small_icon_rect.x_range(), + small_icon_rect.center().y, + visuals.fg_stroke, + )); + } else if *checked { + // Check mark: + ui.painter().add(Shape::line( + vec![ + pos2(small_icon_rect.left(), small_icon_rect.center().y), + pos2(small_icon_rect.center().x, small_icon_rect.bottom()), + pos2(small_icon_rect.right(), small_icon_rect.top()), + ], + visuals.fg_stroke, + )); + } } - if let Some(galley) = galley { - let text_pos = pos2( - rect.min.x + icon_width + icon_spacing, - rect.center().y - 0.5 * galley.size().y, - ); - ui.painter().galley(text_pos, galley, visuals.text_color()); - } + response.response + } else { + prepared.response } - - response } } diff --git a/crates/egui/src/widgets/radio_button.rs b/crates/egui/src/widgets/radio_button.rs index 7c178840..53dda399 100644 --- a/crates/egui/src/widgets/radio_button.rs +++ b/crates/egui/src/widgets/radio_button.rs @@ -1,6 +1,6 @@ use crate::{ - epaint, pos2, vec2, NumExt as _, Response, Sense, TextStyle, Ui, Vec2, Widget, WidgetInfo, - WidgetText, WidgetType, + epaint, Atom, AtomLayout, Atoms, Id, IntoAtoms, NumExt as _, Response, Sense, Ui, Vec2, Widget, + WidgetInfo, WidgetType, }; /// One out of several alternatives, either selected or not. @@ -23,89 +23,84 @@ use crate::{ /// # }); /// ``` #[must_use = "You should put this widget in a ui with `ui.add(widget);`"] -pub struct RadioButton { +pub struct RadioButton<'a> { checked: bool, - text: WidgetText, + atoms: Atoms<'a>, } -impl RadioButton { - pub fn new(checked: bool, text: impl Into) -> Self { +impl<'a> RadioButton<'a> { + pub fn new(checked: bool, atoms: impl IntoAtoms<'a>) -> Self { Self { checked, - text: text.into(), + atoms: atoms.into_atoms(), } } } -impl Widget for RadioButton { +impl Widget for RadioButton<'_> { fn ui(self, ui: &mut Ui) -> Response { - let Self { checked, text } = self; + let Self { checked, mut atoms } = self; let spacing = &ui.spacing(); let icon_width = spacing.icon_width; - let icon_spacing = spacing.icon_spacing; - let (galley, mut desired_size) = if text.is_empty() { - (None, vec2(icon_width, 0.0)) - } else { - let total_extra = vec2(icon_width + icon_spacing, 0.0); + let mut min_size = Vec2::splat(spacing.interact_size.y); + min_size.y = min_size.y.at_least(icon_width); - let wrap_width = ui.available_width() - total_extra.x; - let text = text.into_galley(ui, None, wrap_width, TextStyle::Button); + // In order to center the checkbox based on min_size we set the icon height to at least min_size.y + let mut icon_size = Vec2::splat(icon_width); + icon_size.y = icon_size.y.at_least(min_size.y); + let rect_id = Id::new("egui::radio_button"); + atoms.push_left(Atom::custom(rect_id, icon_size)); - let mut desired_size = total_extra + text.size(); - desired_size = desired_size.at_least(spacing.interact_size); + let text = atoms.text().map(String::from); - (Some(text), desired_size) - }; + let mut prepared = AtomLayout::new(atoms) + .sense(Sense::click()) + .min_size(min_size) + .allocate(ui); - desired_size = desired_size.at_least(Vec2::splat(spacing.interact_size.y)); - desired_size.y = desired_size.y.max(icon_width); - let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click()); - - response.widget_info(|| { + prepared.response.widget_info(|| { WidgetInfo::selected( WidgetType::RadioButton, ui.is_enabled(), checked, - galley.as_ref().map_or("", |x| x.text()), + text.as_deref().unwrap_or(""), ) }); - if ui.is_rect_visible(rect) { + if ui.is_rect_visible(prepared.response.rect) { // let visuals = ui.style().interact_selectable(&response, checked); // too colorful - let visuals = ui.style().interact(&response); + let visuals = *ui.style().interact(&prepared.response); - let (small_icon_rect, big_icon_rect) = ui.spacing().icon_rectangles(rect); + prepared.fallback_text_color = visuals.text_color(); + let response = prepared.paint(ui); - let painter = ui.painter(); + if let Some(rect) = response.rect(rect_id) { + let (small_icon_rect, big_icon_rect) = ui.spacing().icon_rectangles(rect); - painter.add(epaint::CircleShape { - center: big_icon_rect.center(), - radius: big_icon_rect.width() / 2.0 + visuals.expansion, - fill: visuals.bg_fill, - stroke: visuals.bg_stroke, - }); + let painter = ui.painter(); - if checked { painter.add(epaint::CircleShape { - center: small_icon_rect.center(), - radius: small_icon_rect.width() / 3.0, - fill: visuals.fg_stroke.color, // Intentional to use stroke and not fill - // fill: ui.visuals().selection.stroke.color, // too much color - stroke: Default::default(), + center: big_icon_rect.center(), + radius: big_icon_rect.width() / 2.0 + visuals.expansion, + fill: visuals.bg_fill, + stroke: visuals.bg_stroke, }); - } - if let Some(galley) = galley { - let text_pos = pos2( - rect.min.x + icon_width + icon_spacing, - rect.center().y - 0.5 * galley.size().y, - ); - ui.painter().galley(text_pos, galley, visuals.text_color()); + if checked { + painter.add(epaint::CircleShape { + center: small_icon_rect.center(), + radius: small_icon_rect.width() / 3.0, + fill: visuals.fg_stroke.color, // Intentional to use stroke and not fill + // fill: ui.visuals().selection.stroke.color, // too much color + stroke: Default::default(), + }); + } } + response.response + } else { + prepared.response } - - response } } diff --git a/crates/egui_demo_app/tests/snapshots/imageviewer.png b/crates/egui_demo_app/tests/snapshots/imageviewer.png index 57c88b50..d5bde1f9 100644 --- a/crates/egui_demo_app/tests/snapshots/imageviewer.png +++ b/crates/egui_demo_app/tests/snapshots/imageviewer.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f7572ec2dad9038c24beb9949e4c05155cd0f5479153de6647c38911ec5c67a0 -size 100779 +oid sha256:e2fae780123389ca0affa762a5c031b84abcdd31c7a830d485c907c8c370b006 +size 100780 diff --git a/crates/egui_demo_lib/src/demo/widget_gallery.rs b/crates/egui_demo_lib/src/demo/widget_gallery.rs index c7b6df28..31f5d279 100644 --- a/crates/egui_demo_lib/src/demo/widget_gallery.rs +++ b/crates/egui_demo_lib/src/demo/widget_gallery.rs @@ -322,7 +322,10 @@ mod tests { let mut harness = Harness::builder() .with_pixels_per_point(2.0) .with_size(Vec2::new(380.0, 550.0)) - .build_ui(|ui| demo.ui(ui)); + .build_ui(|ui| { + egui_extras::install_image_loaders(ui.ctx()); + demo.ui(ui); + }); harness.fit_contents(); diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Grid Test.png b/crates/egui_demo_lib/tests/snapshots/demos/Grid Test.png index 526dc7ac..394bea64 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Grid Test.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Grid Test.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c69c211061663cd17756eb0ad5a7720ed883047dbcedb39c493c544cfc644ed3 +oid sha256:0c975c8b646425878b704f32198286010730746caf5d463ca8cbcfe539922816 size 99087 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Layout Test.png b/crates/egui_demo_lib/tests/snapshots/demos/Layout Test.png index e2899160..7800f5f5 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Layout Test.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Layout Test.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:18fe761145335a60b1eeb1f7f2072224df86f0e2006caa09d1f3cc4bd263d90c -size 46560 +oid sha256:c47a19d1f56fcc4c30c7e88aada2a50e038d66c1b591b4646b86c11bffb3c66f +size 46563 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Scene.png b/crates/egui_demo_lib/tests/snapshots/demos/Scene.png index 212a7ccd..ea8f9c85 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Scene.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Scene.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0fcfee082fe1dcbb7515ca6e3d5457e71fecf91a3efc4f76906a32fdb588adb4 -size 35096 +oid sha256:cdff6256488f3a40c65a3d73c0635377bf661c57927bce4c853b2a5f3b33274e +size 35121 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Scrolling.png b/crates/egui_demo_lib/tests/snapshots/demos/Scrolling.png index 17e76840..49b223e7 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Scrolling.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Scrolling.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b236fe02f6cd52041359cf4b1a00e9812b95560353ce5df4fa6cb20fdbb45307 +oid sha256:4a347875ef98ebbd606774e03baffdb317cb0246882db116fee1aa7685efbb88 size 179653 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Sliders.png b/crates/egui_demo_lib/tests/snapshots/demos/Sliders.png index 09f8eaa6..92e94b78 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Sliders.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Sliders.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1579351658875af48ad9aafeb08d928d83f1bda42bf092fdcceecd0aa6730e26 -size 115313 +oid sha256:f0e3eeca8abb4fba632cef4621d478fb66af1a0f13e099dda9a79420cc2b6301 +size 115320 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Table.png b/crates/egui_demo_lib/tests/snapshots/demos/Table.png index c11788f4..8a269fd4 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Table.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Table.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9446da28768cae0b489e0f6243410a8b3acf0ca2a0b70690d65d2a6221bc25b9 -size 30517 +oid sha256:9d27ed8292a2612b337f663bff73cd009a82f806c61f0863bf70a53fd4c281ff +size 75074 diff --git a/crates/egui_demo_lib/tests/snapshots/widget_gallery.png b/crates/egui_demo_lib/tests/snapshots/widget_gallery.png index b05ebda1..bcb09fe2 100644 --- a/crates/egui_demo_lib/tests/snapshots/widget_gallery.png +++ b/crates/egui_demo_lib/tests/snapshots/widget_gallery.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:af75f773e9e4ad2615893babce5b99e7fd127c76dd0976ac8dc95307f38a59dc -size 152854 +oid sha256:ee129f0542f21e12f5aa3c2f9746e7cadd73441a04d580f57c12c1cdd40d8b07 +size 153136 diff --git a/crates/egui_extras/src/syntax_highlighting.rs b/crates/egui_extras/src/syntax_highlighting.rs index 6bb51184..8d688ae8 100644 --- a/crates/egui_extras/src/syntax_highlighting.rs +++ b/crates/egui_extras/src/syntax_highlighting.rs @@ -511,7 +511,7 @@ struct Highlighter {} #[cfg(not(feature = "syntect"))] impl Highlighter { - #[expect(clippy::unused_self, clippy::unnecessary_wraps)] + #[expect(clippy::unused_self)] fn highlight_impl( &self, theme: &CodeTheme, diff --git a/crates/egui_kittest/tests/menu.rs b/crates/egui_kittest/tests/menu.rs index a01e39dc..fc19e804 100644 --- a/crates/egui_kittest/tests/menu.rs +++ b/crates/egui_kittest/tests/menu.rs @@ -99,7 +99,7 @@ fn menu_close_on_click_outside() { harness.run(); harness - .get_by_label("Submenu C (CloseOnClickOutside)") + .get_by_label_contains("Submenu C (CloseOnClickOutside)") .hover(); harness.run(); @@ -133,7 +133,7 @@ fn menu_close_on_click() { harness.get_by_label("Menu A").simulate_click(); harness.run(); - harness.get_by_label("Submenu B with icon").hover(); + harness.get_by_label_contains("Submenu B with icon").hover(); harness.run(); // Clicking the button should close the menu (even if ui.close() is not called by the button) @@ -154,7 +154,9 @@ fn clicking_submenu_button_should_never_close_menu() { harness.run(); // Clicking the submenu button should not close the menu - harness.get_by_label("Submenu B with icon").simulate_click(); + harness + .get_by_label_contains("Submenu B with icon") + .simulate_click(); harness.run(); harness.get_by_label("Button in Submenu B").simulate_click(); @@ -177,12 +179,12 @@ fn menu_snapshots() { results.add(harness.try_snapshot("menu/opened")); harness - .get_by_label("Submenu C (CloseOnClickOutside)") + .get_by_label_contains("Submenu C (CloseOnClickOutside)") .hover(); harness.run(); results.add(harness.try_snapshot("menu/submenu")); - harness.get_by_label("Submenu D").hover(); + harness.get_by_label_contains("Submenu D").hover(); harness.run(); results.add(harness.try_snapshot("menu/subsubmenu")); } diff --git a/tests/egui_tests/tests/snapshots/grow_all.png b/tests/egui_tests/tests/snapshots/grow_all.png new file mode 100644 index 00000000..7ef69ea1 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/grow_all.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34f0c49cef96c7c3d08dbe835efd9366a4ced6ad2c6aa7facb0de08fd1a44648 +size 14011 diff --git a/tests/egui_tests/tests/snapshots/layout/atoms_image.png b/tests/egui_tests/tests/snapshots/layout/atoms_image.png new file mode 100644 index 00000000..3d9efa8a --- /dev/null +++ b/tests/egui_tests/tests/snapshots/layout/atoms_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39a13fdac498d6f851a28ea3ca19d523235d5e0ab8e765ea980cf8fb2f64ba35 +size 387619 diff --git a/tests/egui_tests/tests/snapshots/layout/atoms_minimal.png b/tests/egui_tests/tests/snapshots/layout/atoms_minimal.png new file mode 100644 index 00000000..1eb0a834 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/layout/atoms_minimal.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a09e926d25e2b6f63dc6df00ab5e5b76745aae1f288231f1a602421b2bbb53b +size 384721 diff --git a/tests/egui_tests/tests/snapshots/layout/atoms_multi_grow.png b/tests/egui_tests/tests/snapshots/layout/atoms_multi_grow.png new file mode 100644 index 00000000..87211765 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/layout/atoms_multi_grow.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:14a1dc826aeced98cab1413f915dcbbe904b5b1eadfc4d811232bc8ccbe7f550 +size 299556 diff --git a/tests/egui_tests/tests/snapshots/layout/button_image.png b/tests/egui_tests/tests/snapshots/layout/button_image.png index 737f0670..fb6ff3b3 100644 --- a/tests/egui_tests/tests/snapshots/layout/button_image.png +++ b/tests/egui_tests/tests/snapshots/layout/button_image.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:975c279d6da2a2cb000df72bf5d9f3bdd200bb20adc00e29e8fd9ed4d2c6f6b1 -size 340923 +oid sha256:01309596ac9eb90b2dfc00074cfd39d26e3f6d1f83299f227cb4bbea9ccd3b66 +size 339917 diff --git a/tests/egui_tests/tests/snapshots/layout/checkbox_checked.png b/tests/egui_tests/tests/snapshots/layout/checkbox_checked.png index 66ae8115..9c6fb4c0 100644 --- a/tests/egui_tests/tests/snapshots/layout/checkbox_checked.png +++ b/tests/egui_tests/tests/snapshots/layout/checkbox_checked.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aaf9b032037d0708894e568cc8e256b32be9cfb586eaffdc6167143b85562b37 -size 415016 +oid sha256:1d842f88b6a94f19aa59bdae9dbbf42f4662aaead1b8f73ac0194f183112e1b8 +size 415066 diff --git a/tests/egui_tests/tests/snapshots/max_width.png b/tests/egui_tests/tests/snapshots/max_width.png new file mode 100644 index 00000000..bab2f387 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/max_width.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:90cfa6e9be28ef538491ad94615e162ecc107df6a320084ec30840a75660ac35 +size 8759 diff --git a/tests/egui_tests/tests/snapshots/max_width_and_grow.png b/tests/egui_tests/tests/snapshots/max_width_and_grow.png new file mode 100644 index 00000000..077bccbd --- /dev/null +++ b/tests/egui_tests/tests/snapshots/max_width_and_grow.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:effb4a69a7a6af12614be59a0afb0be2d2ebad402da3d7ee99fa25ae350bf4a0 +size 8761 diff --git a/tests/egui_tests/tests/snapshots/shrink_first_text.png b/tests/egui_tests/tests/snapshots/shrink_first_text.png new file mode 100644 index 00000000..c9196ea2 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/shrink_first_text.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf5032b2a08f993ae023934715222fe8d35a3a2e5cc09026d9e7ea3c296a9dc7 +size 11609 diff --git a/tests/egui_tests/tests/snapshots/shrink_last_text.png b/tests/egui_tests/tests/snapshots/shrink_last_text.png new file mode 100644 index 00000000..038b70a2 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/shrink_last_text.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84d0c37a198fb56d8608a201dbe7ad19e7de7802bd5110316b36228e14b5f330 +size 12140 diff --git a/tests/egui_tests/tests/snapshots/size_max_size.png b/tests/egui_tests/tests/snapshots/size_max_size.png new file mode 100644 index 00000000..3ea8feab --- /dev/null +++ b/tests/egui_tests/tests/snapshots/size_max_size.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c6a7555290f6121d6e48657e3ae810976b540ee9328909aca2d6c078b3d76ab4 +size 8735 diff --git a/tests/egui_tests/tests/snapshots/visuals/button_image_shortcut_selected.png b/tests/egui_tests/tests/snapshots/visuals/button_image_shortcut_selected.png index 3ff34c6b..114baa35 100644 --- a/tests/egui_tests/tests/snapshots/visuals/button_image_shortcut_selected.png +++ b/tests/egui_tests/tests/snapshots/visuals/button_image_shortcut_selected.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:09c5904877c8895d3ad41b7082019ef87db40c6a91ad47401bb9b8ac79a62bdc -size 12914 +oid sha256:f9151f1c9d8a769ac2143a684cabf5d9ed1e453141fff555da245092003f1df1 +size 13563 diff --git a/tests/egui_tests/tests/test_atoms.rs b/tests/egui_tests/tests/test_atoms.rs new file mode 100644 index 00000000..abc9f2d0 --- /dev/null +++ b/tests/egui_tests/tests/test_atoms.rs @@ -0,0 +1,71 @@ +use egui::{Align, AtomExt as _, Button, Layout, TextWrapMode, Ui, Vec2}; +use egui_kittest::{HarnessBuilder, SnapshotResult, SnapshotResults}; + +#[test] +fn test_atoms() { + let mut results = SnapshotResults::new(); + + results.add(single_test("max_width", |ui| { + ui.add(Button::new(( + "max width not grow".atom_max_width(30.0), + "other text", + ))); + })); + results.add(single_test("max_width_and_grow", |ui| { + ui.add(Button::new(( + "max width and grow".atom_max_width(30.0).atom_grow(true), + "other text", + ))); + })); + results.add(single_test("shrink_first_text", |ui| { + ui.style_mut().wrap_mode = Some(TextWrapMode::Truncate); + ui.add(Button::new(("this should shrink", "this shouldn't"))); + })); + results.add(single_test("shrink_last_text", |ui| { + ui.style_mut().wrap_mode = Some(TextWrapMode::Truncate); + ui.add(Button::new(( + "this shouldn't shrink", + "this should".atom_shrink(true), + ))); + })); + results.add(single_test("grow_all", |ui| { + ui.style_mut().wrap_mode = Some(TextWrapMode::Truncate); + ui.add(Button::new(( + "I grow".atom_grow(true), + "I also grow".atom_grow(true), + "I grow as well".atom_grow(true), + ))); + })); + results.add(single_test("size_max_size", |ui| { + ui.style_mut().wrap_mode = Some(TextWrapMode::Truncate); + ui.add(Button::new(( + "size and max size" + .atom_size(Vec2::new(80.0, 80.0)) + .atom_max_size(Vec2::new(20.0, 20.0)), + "other text".atom_grow(true), + ))); + })); +} + +fn single_test(name: &str, mut f: impl FnMut(&mut Ui)) -> SnapshotResult { + let mut harness = HarnessBuilder::default() + .with_size(Vec2::new(400.0, 200.0)) + .build_ui(move |ui| { + ui.label("Normal"); + let normal_width = ui.horizontal(&mut f).response.rect.width(); + + ui.label("Justified"); + ui.with_layout( + Layout::left_to_right(Align::Min).with_main_justify(true), + &mut f, + ); + + ui.label("Shrunk"); + ui.scope(|ui| { + ui.set_max_width(normal_width / 2.0); + f(ui); + }); + }); + + harness.try_snapshot(name) +} diff --git a/tests/egui_tests/tests/test_widgets.rs b/tests/egui_tests/tests/test_widgets.rs index 96015cbd..110eff81 100644 --- a/tests/egui_tests/tests/test_widgets.rs +++ b/tests/egui_tests/tests/test_widgets.rs @@ -1,8 +1,8 @@ use egui::load::SizedTexture; use egui::{ - include_image, Align, Button, Color32, ColorImage, Direction, DragValue, Event, Grid, Layout, - PointerButton, Pos2, Response, Slider, Stroke, StrokeKind, TextWrapMode, TextureHandle, - TextureOptions, Ui, UiBuilder, Vec2, Widget as _, + include_image, Align, AtomExt as _, AtomLayout, Button, Color32, ColorImage, Direction, + DragValue, Event, Grid, IntoAtoms as _, Layout, PointerButton, Pos2, Response, Slider, Stroke, + StrokeKind, TextWrapMode, TextureHandle, TextureOptions, Ui, UiBuilder, Vec2, Widget as _, }; use egui_kittest::kittest::{by, Node, Queryable as _}; use egui_kittest::{Harness, SnapshotResult, SnapshotResults}; @@ -92,6 +92,25 @@ fn widget_tests() { }, &mut results, ); + + let source = include_image!("../../../crates/eframe/data/icon.png"); + let interesting_atoms = vec![ + ("minimal", ("Hello World!").into_atoms()), + ( + "image", + (source.clone().atom_max_height(12.0), "With Image").into_atoms(), + ), + ( + "multi_grow", + ("g".atom_grow(true), "2", "g".atom_grow(true), "4").into_atoms(), + ), + ]; + + for atoms in interesting_atoms { + results.add(test_widget_layout(&format!("atoms_{}", atoms.0), |ui| { + AtomLayout::new(atoms.1.clone()).ui(ui) + })); + } } fn test_widget(name: &str, mut w: impl FnMut(&mut Ui) -> Response, results: &mut SnapshotResults) {