Add `AtomLayout`, abstracing layouting within widgets (#5830)

Today each widget does its own custom layout, which has some drawbacks:
- not very flexible
- you can add an `Image` to `Button` but it will always be shown on the
left side
  - you can't add a `Image` to a e.g. a `SelectableLabel`
- a lot of duplicated code

This PR introduces `Atoms` and `AtomLayout` which abstracts over "widget
content" and layout within widgets, so it'd be possible to add images /
text / custom rendering (for e.g. the checkbox) to any widget.

A simple custom button implementation is now as easy as this:
```rs
pub struct ALButton<'a> {
    al: AtomicLayout<'a>,
}

impl<'a> ALButton<'a> {
    pub fn new(content: impl IntoAtomics) -> Self {
        Self { al: content.into_atomics() }
    }
}

impl<'a> Widget for ALButton<'a> {
    fn ui(mut self, ui: &mut Ui) -> Response {
        let response = ui.ctx().read_response(ui.next_auto_id());

        let visuals = response.map_or(&ui.style().visuals.widgets.inactive, |response| {
            ui.style().interact(&response)
        });

        self.al.frame = self
            .al
            .frame
            .inner_margin(ui.style().spacing.button_padding)
            .fill(visuals.bg_fill)
            .stroke(visuals.bg_stroke)
            .corner_radius(visuals.corner_radius);

        self.al.show(ui)
    }
}

```

The initial implementation only does very basic layout, just enough to
be able to implement most current egui widgets, so:
- only horizontal layout
- everything is centered
- a single item may grow/shrink based on the available space
- everything can be contained in a Frame


There is a trait `IntoAtoms` that conveniently allows you to construct
`Atoms` from a tuple
```
   ui.button((Image::new("image.png"), "Click me!"))
```
to get a button with image and text.


This PR reimplements three egui widgets based on the new AtomLayout:
 - Button
   - matches the old button pixel-by-pixel
- Button with image is now [properly
aligned](https://github.com/emilk/egui/pull/5830/files#diff-962ce2c68ab50724b01c6b64c683c4067edd9b79fcdcb39a6071021e33ebe772)
in justified layouts
   - selected button style now matches SelecatbleLabel look
- For some reason the DragValue text seems shifted by a pixel almost
everywhere, but I think it's more centered now, yay?
 - Checkbox
- basically pixel-perfect but apparently the check mesh is very slightly
different so I had to update the snapshot
   - somehow needs a bit more space in some snapshot tests?
 - RadioButton
   - pixel-perfect
   - somehow needs a bit more space in some snapshot tests?

I plan on updating TextEdit based on AtomLayout in a separate PR (so
you could use it to add a icon within the textedit frame).
This commit is contained in:
Lucas Meurer 2025-06-13 09:39:52 +02:00 committed by GitHub
parent f0abce9bb8
commit 6eb7bb6e08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1528 additions and 409 deletions

View File

@ -1263,6 +1263,7 @@ dependencies = [
"profiling",
"ron",
"serde",
"smallvec",
"unicode-segmentation",
]

View File

@ -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"

View File

@ -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

View File

@ -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<Atom>`.
/// ```
/// # 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<Vec2>,
/// 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<Vec2>) -> 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<TextWrapMode>,
) -> 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<T> for Atom<'a>
where
T: Into<AtomKind<'a>>,
{
fn from(value: T) -> Self {
Atom {
kind: value.into(),
..Default::default()
}
}
}

View File

@ -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<Atom<'a>> + 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
}
}

View File

@ -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<WidgetText>) -> Self {
AtomKind::Text(text.into())
}
pub fn image(image: impl Into<Image<'a>>) -> 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<TextWrapMode>,
) -> (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<ImageSource<'a>> for AtomKind<'a> {
fn from(value: ImageSource<'a>) -> Self {
AtomKind::Image(value.into())
}
}
impl<'a> From<Image<'a>> for AtomKind<'a> {
fn from(value: Image<'a>) -> Self {
AtomKind::Image(value)
}
}
impl<T> From<T> for AtomKind<'_>
where
T: Into<WidgetText>,
{
fn from(value: T) -> Self {
AtomKind::Text(value.into())
}
}

View File

@ -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<Id>,
pub atoms: Atoms<'a>,
gap: Option<f32>,
pub(crate) frame: Frame,
pub(crate) sense: Sense,
fallback_text_color: Option<Color32>,
min_size: Vec2,
wrap_mode: Option<TextWrapMode>,
align2: Option<Align2>,
}
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<Item = &SizedAtomKind<'atom>> {
self.sized_atoms.iter().map(|atom| &atom.kind)
}
pub fn iter_kinds_mut(&mut self) -> impl Iterator<Item = &mut SizedAtomKind<'atom>> {
self.sized_atoms.iter_mut().map(|atom| &mut atom.kind)
}
pub fn iter_images(&self) -> impl Iterator<Item = &Image<'atom>> {
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<Item = &mut Image<'atom>> {
self.iter_kinds_mut().filter_map(|kind| {
if let SizedAtomKind::Image(image, _) = kind {
Some(image)
} else {
None
}
})
}
pub fn iter_texts(&self) -> impl Iterator<Item = &Arc<Galley>> + 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<Item = &mut Arc<Galley>> + use<'atom, '_> {
self.iter_kinds_mut().filter_map(|kind| {
if let SizedAtomKind::Text(text) = kind {
Some(text)
} else {
None
}
})
}
pub fn map_kind<F>(&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<F>(&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<Item = (Id, Rect)> + '_ {
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<Rect> {
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
}
}

View File

@ -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<Atom<'a>>) {
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<Atom<'a>>) {
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<Cow<'_, str>> {
let mut string: Option<Cow<'_, str>> = 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<Item = &'a AtomKind<'a>> {
self.0.iter().map(|atom| &atom.kind)
}
pub fn iter_kinds_mut(&'a mut self) -> impl Iterator<Item = &'a mut AtomKind<'a>> {
self.0.iter_mut().map(|atom| &mut atom.kind)
}
pub fn iter_images(&'a self) -> impl Iterator<Item = &'a Image<'a>> {
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<Item = &'a mut Image<'a>> {
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<Item = &'a WidgetText> {
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<Item = &'a mut WidgetText> {
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<F>(&'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<F>(&'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<F>(&'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<Atom<'a>>,
{
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
}
}

View File

@ -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::*;

View File

@ -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
}
}

View File

@ -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<Galley>),
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,
}
}
}

View File

@ -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<WidgetText>) -> 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<WidgetText>) -> 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`].

View File

@ -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::{

View File

@ -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<WidgetText>) -> 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<WidgetText>) -> 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<WidgetText>) -> 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<Value: PartialEq>(
pub fn radio_value<'a, Value: PartialEq>(
&mut self,
current_value: &mut Value,
alternative: Value,
text: impl Into<WidgetText>,
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<R>(
pub fn menu_button<'a, R>(
&mut self,
title: impl Into<WidgetText>,
atoms: impl IntoAtoms<'a>,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<Option<R>> {
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)
}

View File

@ -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<Galley>),
}
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())

View File

@ -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<Image<'a>>,
text: Option<WidgetText>,
right_text: WidgetText,
wrap_mode: Option<TextWrapMode>,
/// None means default for interact
layout: AtomLayout<'a>,
fill: Option<Color32>,
stroke: Option<Stroke>,
sense: Sense,
small: bool,
frame: Option<bool>,
min_size: Vec2,
corner_radius: Option<CornerRadius>,
selected: bool,
image_tint_follows_text_color: bool,
limit_image_size: bool,
}
impl<'a> Button<'a> {
pub fn new(text: impl Into<WidgetText>) -> 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<Image<'a>>) -> 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<Image<'a>>, text: impl Into<WidgetText>) -> Self {
Self::opt_image_and_text(Some(image.into()), Some(text.into()))
}
pub fn opt_image_and_text(image: Option<Image<'a>>, text: Option<WidgetText>) -> 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<Image<'a>>) -> 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<Image<'a>>, text: impl Into<WidgetText>) -> 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<Image<'a>>, text: Option<WidgetText>) -> 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<Color32>) -> 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<WidgetText>) -> Self {
self.right_text = shortcut_text.into().weak();
pub fn shortcut_text(mut self, shortcut_text: impl Into<Atom<'a>>) -> 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<WidgetText>) -> Self {
self.right_text = right_text.into();
pub fn right_text(mut self, right_text: impl Into<Atom<'a>>) -> 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
}
}

View File

@ -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<WidgetText>) -> 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
}
}

View File

@ -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<WidgetText>) -> 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
}
}

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f7572ec2dad9038c24beb9949e4c05155cd0f5479153de6647c38911ec5c67a0
size 100779
oid sha256:e2fae780123389ca0affa762a5c031b84abcdd31c7a830d485c907c8c370b006
size 100780

View File

@ -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();

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c69c211061663cd17756eb0ad5a7720ed883047dbcedb39c493c544cfc644ed3
oid sha256:0c975c8b646425878b704f32198286010730746caf5d463ca8cbcfe539922816
size 99087

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:18fe761145335a60b1eeb1f7f2072224df86f0e2006caa09d1f3cc4bd263d90c
size 46560
oid sha256:c47a19d1f56fcc4c30c7e88aada2a50e038d66c1b591b4646b86c11bffb3c66f
size 46563

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0fcfee082fe1dcbb7515ca6e3d5457e71fecf91a3efc4f76906a32fdb588adb4
size 35096
oid sha256:cdff6256488f3a40c65a3d73c0635377bf661c57927bce4c853b2a5f3b33274e
size 35121

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b236fe02f6cd52041359cf4b1a00e9812b95560353ce5df4fa6cb20fdbb45307
oid sha256:4a347875ef98ebbd606774e03baffdb317cb0246882db116fee1aa7685efbb88
size 179653

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1579351658875af48ad9aafeb08d928d83f1bda42bf092fdcceecd0aa6730e26
size 115313
oid sha256:f0e3eeca8abb4fba632cef4621d478fb66af1a0f13e099dda9a79420cc2b6301
size 115320

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9446da28768cae0b489e0f6243410a8b3acf0ca2a0b70690d65d2a6221bc25b9
size 30517
oid sha256:9d27ed8292a2612b337f663bff73cd009a82f806c61f0863bf70a53fd4c281ff
size 75074

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:af75f773e9e4ad2615893babce5b99e7fd127c76dd0976ac8dc95307f38a59dc
size 152854
oid sha256:ee129f0542f21e12f5aa3c2f9746e7cadd73441a04d580f57c12c1cdd40d8b07
size 153136

View File

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

View File

@ -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"));
}

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:34f0c49cef96c7c3d08dbe835efd9366a4ced6ad2c6aa7facb0de08fd1a44648
size 14011

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:39a13fdac498d6f851a28ea3ca19d523235d5e0ab8e765ea980cf8fb2f64ba35
size 387619

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4a09e926d25e2b6f63dc6df00ab5e5b76745aae1f288231f1a602421b2bbb53b
size 384721

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:14a1dc826aeced98cab1413f915dcbbe904b5b1eadfc4d811232bc8ccbe7f550
size 299556

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:975c279d6da2a2cb000df72bf5d9f3bdd200bb20adc00e29e8fd9ed4d2c6f6b1
size 340923
oid sha256:01309596ac9eb90b2dfc00074cfd39d26e3f6d1f83299f227cb4bbea9ccd3b66
size 339917

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aaf9b032037d0708894e568cc8e256b32be9cfb586eaffdc6167143b85562b37
size 415016
oid sha256:1d842f88b6a94f19aa59bdae9dbbf42f4662aaead1b8f73ac0194f183112e1b8
size 415066

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:90cfa6e9be28ef538491ad94615e162ecc107df6a320084ec30840a75660ac35
size 8759

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:effb4a69a7a6af12614be59a0afb0be2d2ebad402da3d7ee99fa25ae350bf4a0
size 8761

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cf5032b2a08f993ae023934715222fe8d35a3a2e5cc09026d9e7ea3c296a9dc7
size 11609

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:84d0c37a198fb56d8608a201dbe7ad19e7de7802bd5110316b36228e14b5f330
size 12140

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c6a7555290f6121d6e48657e3ae810976b540ee9328909aca2d6c078b3d76ab4
size 8735

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:09c5904877c8895d3ad41b7082019ef87db40c6a91ad47401bb9b8ac79a62bdc
size 12914
oid sha256:f9151f1c9d8a769ac2143a684cabf5d9ed1e453141fff555da245092003f1df1
size 13563

View File

@ -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)
}

View File

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