1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 14:49:06 -04:00

Implement wrapping atom layout

This commit is contained in:
lucasmerlin
2026-06-04 15:31:45 +02:00
parent cd9e351221
commit 3a85b165aa
20 changed files with 931 additions and 212 deletions

View File

@@ -109,14 +109,17 @@ impl<'a> Atom<'a> {
/// at the cell the parent computes for it. See [`AtomKind::Layout`].
pub fn layout(layout: AtomLayout<'a>) -> Self {
Atom {
kind: AtomKind::Layout(Box::new(layout)),
kind: AtomKind::Layout(std::rc::Rc::new(layout)),
..Default::default()
}
}
/// Turn this into a [`SizedAtom`].
pub fn into_sized(
self,
/// Size this into a [`SizedAtom`].
///
/// Takes `&self` so an atom can be sized repeatedly (e.g. re-measured at a grown size) without
/// being consumed; the returned [`SizedAtom`] is owned and does not borrow `self`.
pub fn as_sized(
&self,
ui: &Ui,
mut available_size: Vec2,
mut wrap_mode: Option<TextWrapMode>,
@@ -139,7 +142,7 @@ impl<'a> Atom<'a> {
let IntoSizedResult {
intrinsic_size,
sized,
} = self.kind.into_sized(
} = self.kind.as_sized(
ui,
IntoSizedArgs {
available_size,

View File

@@ -2,6 +2,7 @@ use crate::{AtomLayout, FontSelection, Image, ImageSource, SizedAtomKind, Ui, Wi
use emath::Vec2;
use epaint::text::TextWrapMode;
use std::fmt::Debug;
use std::rc::Rc;
/// Args passed when sizing an [`super::Atom`]
pub struct IntoSizedArgs {
@@ -16,13 +17,8 @@ pub struct IntoSizedResult<'a> {
pub sized: SizedAtomKind<'a>,
}
/// See [`AtomKind::Closure`]
// We need 'static in the result (or need to introduce another lifetime on the enum).
// Otherwise, a single 'static Atom would force the closure to be 'static.
pub type AtomClosure<'a> = Box<dyn FnOnce(&Ui, IntoSizedArgs) -> IntoSizedResult<'static> + 'a>;
/// The different kinds of [`crate::Atom`]s.
#[derive(Default)]
#[derive(Clone, Default)]
pub enum AtomKind<'a> {
/// Empty, that can be used with [`crate::AtomExt::atom_grow`] to reserve space.
#[default]
@@ -57,36 +53,14 @@ pub enum AtomKind<'a> {
/// default font height, which is convenient for icons.
Image(Image<'a>),
/// A custom closure that produces a sized atom.
///
/// The vec2 passed in is the available size to this atom. The returned vec2 should be the
/// preferred / intrinsic size.
///
/// Note: This api is experimental, expect breaking changes here.
/// When cloning, this will be cloned as [`AtomKind::Empty`].
Closure(AtomClosure<'a>),
/// A nested [`AtomLayout`], letting you embed an atom-based widget as a single atom
/// inside another [`AtomLayout`].
///
/// The nested layout is measured (sized) when the parent is sized, and painted (and
/// interacted with) at the cell rect the parent computes for it.
Layout(Box<AtomLayout<'a>>),
}
impl Clone for AtomKind<'_> {
fn clone(&self) -> Self {
match self {
AtomKind::Empty => AtomKind::Empty,
AtomKind::Text(text) => AtomKind::Text(text.clone()),
AtomKind::Image(image) => AtomKind::Image(image.clone()),
AtomKind::Closure(_) => {
log::warn!("Cannot clone atom closures");
AtomKind::Empty
}
AtomKind::Layout(layout) => AtomKind::Layout(layout.clone()),
}
}
/// interacted with) at the cell rect the parent computes for it. The `Arc` lets the parent
/// keep the (unsized) layout around cheaply so a grown atom can be re-measured at its painted
/// size without deep-cloning it. See [`SizedAtomKind::Layout`].
Layout(Rc<AtomLayout<'a>>),
}
impl Debug for AtomKind<'_> {
@@ -95,7 +69,6 @@ impl Debug for AtomKind<'_> {
AtomKind::Empty => write!(f, "AtomKind::Empty"),
AtomKind::Text(text) => write!(f, "AtomKind::Text({text:?})"),
AtomKind::Image(image) => write!(f, "AtomKind::Image({image:?})"),
AtomKind::Closure(_) => write!(f, "AtomKind::Closure(<closure>)"),
AtomKind::Layout(_) => write!(f, "AtomKind::Layout(<layout>)"),
}
}
@@ -112,17 +85,17 @@ impl<'a> AtomKind<'a> {
AtomKind::Image(image.into())
}
/// See [`Self::Closure`]
pub fn closure(func: impl FnOnce(&Ui, IntoSizedArgs) -> IntoSizedResult<'static> + 'a) -> Self {
AtomKind::Closure(Box::new(func))
}
/// Turn this [`AtomKind`] into a [`SizedAtomKind`].
/// Size 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,
///
/// Takes `&self` so an atom can be sized repeatedly (e.g. re-measured at a grown size when a
/// nested layout reflows) without consuming it. The returned [`SizedAtomKind`] is owned (texts
/// produce a [`crate::Galley`], images and nested layouts are shared via cheap clones), so it
/// does not borrow `self`.
pub fn as_sized(
&self,
ui: &Ui,
IntoSizedArgs {
available_size,
@@ -132,7 +105,9 @@ impl<'a> AtomKind<'a> {
) -> IntoSizedResult<'a> {
match self {
AtomKind::Text(text) => {
let galley = text.into_galley(ui, Some(wrap_mode), available_size.x, fallback_font);
let galley =
text.clone()
.into_galley(ui, Some(wrap_mode), available_size.x, fallback_font);
IntoSizedResult {
intrinsic_size: galley.intrinsic_size(),
sized: SizedAtomKind::Text(galley),
@@ -143,26 +118,27 @@ impl<'a> AtomKind<'a> {
let size = size.unwrap_or(Vec2::ZERO);
IntoSizedResult {
intrinsic_size: size,
sized: SizedAtomKind::Image { image, size },
sized: SizedAtomKind::Image {
image: image.clone(),
size,
},
}
}
AtomKind::Empty => IntoSizedResult {
intrinsic_size: Vec2::ZERO,
sized: SizedAtomKind::Empty { size: None },
},
AtomKind::Closure(func) => func(
ui,
IntoSizedArgs {
available_size,
wrap_mode,
fallback_font,
},
),
AtomKind::Layout(layout) => {
// Measure at the natural size for the parent's sizing, but keep a shared handle to
// the original layout so a grown atom can be re-measured at its painted size in
// `paint_at` (cheap `Arc` clone, no deep copy).
let sized = layout.measure(ui, available_size);
IntoSizedResult {
intrinsic_size: sized.intrinsic_size,
sized: SizedAtomKind::Layout(Box::new(sized)),
sized: SizedAtomKind::Layout {
source: Rc::clone(layout),
sized: Box::new(sized),
},
}
}
}
@@ -192,6 +168,6 @@ where
impl<'a> From<AtomLayout<'a>> for AtomKind<'a> {
fn from(layout: AtomLayout<'a>) -> Self {
AtomKind::Layout(Box::new(layout))
AtomKind::Layout(Rc::new(layout))
}
}

View File

@@ -37,6 +37,78 @@ fn main_cross_rect(direction: Direction, aligned_rect: Rect, min_main: f32, max_
}
}
/// Build a [`Rect`] that extends `main_len` along the main axis from `main_min`, and `cross_len`
/// along the cross axis from `cross_min`, for `direction`.
#[inline]
fn rect_from_main_cross(
direction: Direction,
main_min: f32,
main_len: f32,
cross_min: f32,
cross_len: f32,
) -> Rect {
let min = main_cross_vec(direction, main_min, cross_min).to_pos2();
Rect::from_min_size(min, main_cross_vec(direction, main_len, cross_len))
}
/// Group already-sized atoms into lines for flex-like wrapping.
///
/// Walks `atoms` in order, accumulating along the main axis; when adding the next atom would
/// exceed `max_main` (and the current line is non-empty) a new line is started. Atoms are never
/// split. `gap` is added between atoms on a line. Always returns at least one line (possibly
/// empty, if there are no atoms).
fn pack_lines(
atoms: &[SizedAtom<'_>],
main_axis: usize,
cross_axis: usize,
max_main: f32,
gap: f32,
) -> Vec<Line> {
let mut lines = Vec::new();
let mut start = 0;
let mut main_extent = 0.0;
let mut cross_extent: f32 = 0.0;
let mut grow_count = 0;
for (i, atom) in atoms.iter().enumerate() {
let atom_main = atom.size[main_axis];
let atom_cross = atom.size[cross_axis];
let is_first_on_line = i == start;
let main_with_atom = if is_first_on_line {
atom_main
} else {
main_extent + gap + atom_main
};
if !is_first_on_line && main_with_atom > max_main {
// Doesn't fit: flush the current line and start a new one with this atom.
lines.push(Line {
range: start..i,
main_extent,
cross_extent,
grow_count,
});
start = i;
main_extent = atom_main;
cross_extent = atom_cross;
grow_count = usize::from(atom.grow);
} else {
main_extent = main_with_atom;
cross_extent = cross_extent.max(atom_cross);
grow_count += usize::from(atom.grow);
}
}
lines.push(Line {
range: start..atoms.len(),
main_extent,
cross_extent,
grow_count,
});
lines
}
/// Intra-widget layout utility.
///
/// Used to lay out and paint [`crate::Atom`]s.
@@ -70,6 +142,8 @@ pub struct AtomLayout<'a> {
wrap_mode: Option<TextWrapMode>,
align2: Option<Align2>,
direction: Direction,
wrap: bool,
cross_justify: bool,
}
impl Default for AtomLayout<'_> {
@@ -93,6 +167,8 @@ impl<'a> AtomLayout<'a> {
wrap_mode: None,
align2: None,
direction: Direction::LeftToRight,
wrap: false,
cross_justify: false,
}
}
@@ -217,6 +293,37 @@ impl<'a> AtomLayout<'a> {
self
}
/// Wrap [`crate::Atom`]s onto multiple lines when they exceed the available main extent
/// (flex-like wrapping).
///
/// Atoms are treated as atomic units for line-breaking: an atom either fits on the current
/// line or moves to the next one (it is not split). Each line is packed and grown
/// independently along the main axis; lines are stacked along the cross axis.
///
/// Wrapping is mutually exclusive with the implicit single-atom text shrink: when `wrap` is
/// set, no atom is automatically marked as `shrink`. The same `gap` is used between atoms on
/// a line and between lines.
#[inline]
pub fn wrap(mut self, wrap: bool) -> Self {
self.wrap = wrap;
self
}
/// Stretch the content along the cross axis to fill the [`Rect`] this layout is painted into
/// (flexbox `align-items: stretch`).
///
/// By default the content takes its natural cross size and is positioned within the available
/// cross space by [`Self::align2`]. This matters when the layout is painted into a `Rect`
/// larger than its measured size along the cross axis — most commonly when it is a nested,
/// `grow`ing [`crate::Atom`] in a parent layout: with `cross_justify` its own content (a full
/// width mock image, a row of tags, …) expands to fill the grown size instead of hugging the
/// start. Extra cross space is shared evenly between (wrapped) lines.
#[inline]
pub fn cross_justify(mut self, cross_justify: bool) -> Self {
self.cross_justify = cross_justify;
self
}
/// [`AtomLayout::allocate`] and [`AllocatedAtomLayout::paint`] in one go.
pub fn show(self, ui: &mut Ui) -> AtomLayoutResponse {
self.allocate(ui).paint(ui)
@@ -233,45 +340,40 @@ impl<'a> AtomLayout<'a> {
/// `available_size` is the space available to the whole widget (frame included); it is
/// clamped by `max_size`/`min_size`, exactly like [`Self::allocate`] does with
/// [`Ui::available_size`].
pub fn measure(self, ui: &Ui, available_size: Vec2) -> SizedAtomLayout<'a> {
let Self {
id,
mut atoms,
gap,
frame,
sense,
fallback_text_color,
min_size,
mut max_size,
wrap_mode,
align2,
fallback_font,
direction,
} = self;
pub fn measure(&self, ui: &Ui, available_size: Vec2) -> SizedAtomLayout<'a> {
let atoms = &self.atoms;
let frame = self.frame;
let sense = self.sense;
let min_size = self.min_size;
let mut max_size = self.max_size;
let direction = self.direction;
let wrap = self.wrap;
let cross_justify = self.cross_justify;
let fallback_font = fallback_font.unwrap_or_default();
let fallback_font = self.fallback_font.clone().unwrap_or_default();
let wrap_mode = wrap_mode.unwrap_or_else(|| ui.wrap_mode());
let wrap_mode = self.wrap_mode.unwrap_or_else(|| 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.any_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
}
}
}
// If none is found, the first text item acts as the `shrink` item. We size from `&self`
// and can't mutate the atom, so we record its index and treat it as `shrink` below.
// When `wrap` (flex wrapping) is enabled the shrink mechanism is disabled (atoms wrap
// onto new lines instead of one atom shrinking to fit a single line).
let auto_shrink_index = if wrap_mode != TextWrapMode::Extend && !wrap && !atoms.any_shrink()
{
atoms
.iter()
.position(|a| matches!(a.kind, AtomKind::Text(..)))
} else {
None
};
let id = id.unwrap_or_else(|| ui.next_auto_id());
let id = self.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_else(|| ui.spacing().icon_spacing);
let fallback_text_color = self
.fallback_text_color
.unwrap_or_else(|| ui.style().visuals.text_color());
let gap = self.gap.unwrap_or_else(|| ui.spacing().icon_spacing);
// max_size has no effect in justified layouts. If we'd limit the available size here,
// the content would be sized differently than the frame which would look weird.
@@ -310,7 +412,7 @@ impl<'a> AtomLayout<'a> {
let mut shrink_item = None;
let align2 = align2.unwrap_or_else(|| {
let align2 = self.align2.unwrap_or_else(|| {
Align2([ui.layout().horizontal_align(), ui.layout().vertical_align()])
});
@@ -320,11 +422,14 @@ impl<'a> AtomLayout<'a> {
intrinsic_main += gap_space;
}
for (idx, item) in atoms.into_iter().enumerate() {
for (idx, item) in atoms.iter().enumerate() {
if item.grow {
grow_count += 1;
}
if item.shrink {
// When wrapping, `shrink` atoms are laid out like any other atom (no single-atom
// shrink-to-fit), so don't divert them into the shrink path. `auto_shrink_index`
// promotes the first text atom to `shrink` when none was set explicitly.
if (item.shrink || Some(idx) == auto_shrink_index) && !wrap {
debug_assert!(
shrink_item.is_none(),
"Only one atomic may be marked as shrink. {item:?}"
@@ -334,7 +439,7 @@ impl<'a> AtomLayout<'a> {
continue;
}
}
let sized = item.into_sized(
let sized = item.as_sized(
ui,
available_inner_size,
Some(wrap_mode),
@@ -359,12 +464,26 @@ impl<'a> AtomLayout<'a> {
available_inner_size[cross_axis],
);
let sized = item.into_sized(
ui,
available_size_for_shrink_item,
Some(wrap_mode),
fallback_font,
);
// `Atom::as_sized` reads `self.shrink` (a non-shrink atom with no max width is forced
// to `Extend`). The auto-selected first-text atom isn't flagged `shrink`, so size a
// copy with `shrink` set to keep the previous truncate/wrap behavior.
let sized = if item.shrink {
item.as_sized(
ui,
available_size_for_shrink_item,
Some(wrap_mode),
fallback_font,
)
} else {
let mut item = item.clone();
item.shrink = true;
item.as_sized(
ui,
available_size_for_shrink_item,
Some(wrap_mode),
fallback_font,
)
};
let size = sized.size;
inner_main += size[main_axis];
@@ -376,8 +495,78 @@ impl<'a> AtomLayout<'a> {
sized_items.insert(index, sized);
}
// Group the (flat) sized atoms into lines. Without wrapping that's a single line
// spanning everything, which reproduces the previous single-line behavior exactly.
let mut lines = if wrap {
pack_lines(
&sized_items,
main_axis,
cross_axis,
available_inner_size[main_axis],
gap,
)
} else {
vec![Line {
range: 0..sized_items.len(),
main_extent: inner_main,
cross_extent: cross_size,
grow_count,
}]
};
// Inner main = widest line. `grow` doesn't change it (it only fills slack within a line).
let inner_main = lines.iter().map(|l| l.main_extent).fold(0.0_f32, f32::max);
let margin = frame.total_margin();
let inner_size = main_cross_vec(direction, inner_main, cross_size);
// Flexbox §9.3→§9.4 ordering: resolve `grow` and *then* re-measure each grown nested
// layout at its grown main extent, so its reflowed cross size feeds the line's cross
// extent. Otherwise the cross size (line height) is computed from each atom's *natural*
// (pre-grow) main size, committed into `outer_size`, and only paint resolves `grow` — so a
// nested layout that re-wraps narrower content when grown (a card whose tags collapse onto
// one line) leaves the line taller than its reflowed contents (a gap above the footer).
//
// This mirrors the re-measure `paint_at` already does for grown nested layouts, but does
// it here so the reflowed height propagates into the parent's own size. It only fires when
// the layout fills past its content along the main axis (`fill_main > line.main_extent` —
// e.g. `min_size` forces it to the available width, the gallery case). When nothing fills,
// `grow` has no slack to distribute, so non-fill layouts are completely unaffected.
let fill_main = (min_size[main_axis] - margin.sum()[main_axis]).max(inner_main);
for line in &mut lines {
if line.grow_count == 0 {
continue;
}
let grow_main = ((fill_main - line.main_extent) / line.grow_count as f32).floor_ui();
if grow_main <= 0.0 {
continue;
}
let mut line_cross: f32 = 0.0;
for sized in &mut sized_items[line.range.clone()] {
if sized.grow
&& let SizedAtomKind::Layout {
source,
sized: inner,
} = &mut sized.kind
{
let grown = main_cross_vec(
direction,
sized.size[main_axis] + grow_main,
available_inner_size[cross_axis],
);
let remeasured = source.measure(ui, grown);
sized.size[cross_axis] = remeasured.outer_size[cross_axis];
**inner = remeasured;
}
line_cross = line_cross.max(sized.size[cross_axis]);
}
line.cross_extent = line_cross;
}
// Inner cross = stacked line cross extents + inter-line gaps (post-reflow).
let inner_cross = lines.iter().map(|l| l.cross_extent).sum::<f32>()
+ gap * lines.len().saturating_sub(1) as f32;
let inner_size = main_cross_vec(direction, inner_main, inner_cross);
let outer_size = (inner_size + margin.sum()).at_least(min_size);
let intrinsic_size = (main_cross_vec(direction, intrinsic_main, intrinsic_cross)
+ margin.sum())
@@ -391,11 +580,12 @@ impl<'a> AtomLayout<'a> {
sense,
outer_size,
intrinsic_size,
grow_count,
lines,
inner_size,
align2,
gap,
direction,
cross_justify,
}
}
@@ -413,6 +603,26 @@ impl<'a> AtomLayout<'a> {
}
}
/// One (possibly wrapped) line of atoms within a [`SizedAtomLayout`].
///
/// `range` indexes into [`SizedAtomLayout::sized_atoms`], which is kept as a single flat `Vec`
/// (so the `iter_*`/`map_*` helpers keep working); the lines just describe how to group it.
/// For a non-wrapping layout there is exactly one line spanning all atoms.
#[derive(Clone, Debug)]
struct Line {
/// Range into [`SizedAtomLayout::sized_atoms`].
range: std::ops::Range<usize>,
/// Sum of atom main extents on this line plus the inter-atom gaps.
main_extent: f32,
/// The largest atom cross extent on this line.
cross_extent: f32,
/// How many atoms on this line are marked `grow`.
grow_count: usize,
}
/// A measured [`AtomLayout`], ready to be painted at a [`Rect`].
///
/// Produced by [`AtomLayout::measure`]. Unlike [`AllocatedAtomLayout`], it has not yet
@@ -447,8 +657,8 @@ pub struct SizedAtomLayout<'a> {
/// [`Response::set_intrinsic_size`].
pub(crate) intrinsic_size: Vec2,
/// How many atoms were marked as `grow`?
grow_count: usize,
/// The atoms grouped into (possibly wrapped) lines. Always at least one line.
lines: Vec<Line>,
/// How will all the atoms be aligned within the allocated rect?
align2: Align2,
@@ -458,6 +668,10 @@ pub struct SizedAtomLayout<'a> {
/// The axis the atoms are laid out along. The main axis carries `grow`/`shrink`/`gap`.
direction: Direction,
/// Stretch the content along the cross axis to fill the painted [`Rect`]. See
/// [`AtomLayout::cross_justify`].
cross_justify: bool,
}
/// Instructions for painting an [`AtomLayout`].
@@ -553,14 +767,15 @@ impl<'atom> SizedAtomLayout<'atom> {
/// becomes the base of the returned [`AtomLayoutResponse`].
pub fn paint_at(self, ui: &Ui, rect: Rect, response: Response) -> AtomLayoutResponse {
let Self {
mut sized_atoms,
sized_atoms,
frame,
fallback_text_color,
grow_count,
lines,
inner_size,
align2,
gap,
direction,
cross_justify,
..
} = self;
@@ -570,68 +785,131 @@ impl<'atom> SizedAtomLayout<'atom> {
let (main_axis, cross_axis) = main_cross_axis(direction);
// We position atoms along the main axis (the `direction`) and span the cross axis.
let main_to_fill = inner_rect.size()[main_axis];
let inner_main = inner_size[main_axis];
let extra_space = f32::max(main_to_fill - inner_main, 0.0);
let grow_main = f32::max(extra_space / grow_count as f32, 0.0).floor_ui();
// When something grows, the block fills the available main extent; otherwise it's the
// content's inner size. `align2` then positions the block within `inner_rect`.
let block_main = if grow_count > 0 {
main_to_fill
// We position atoms along the main axis (the `direction`) and stack lines along the cross
// axis. For a single (non-wrapped) line this reduces to the original single-line layout.
let main_range = if direction.is_horizontal() {
inner_rect.x_range()
} else {
inner_main
inner_rect.y_range()
};
let block_size = main_cross_vec(direction, block_main, inner_size[cross_axis]);
let aligned_rect = align2.align_size_within_rect(block_size, inner_rect);
let cross_range = if direction.is_horizontal() {
inner_rect.y_range()
} else {
inner_rect.x_range()
};
let main_to_fill = inner_rect.size()[main_axis];
// For reversed directions the first atom sits at the far end, so we lay them out in
// reverse and otherwise share the same forward cursor logic.
if matches!(direction, Direction::RightToLeft | Direction::BottomUp) {
sized_atoms.reverse();
}
// With `cross_justify` the content stretches to fill the cross extent of `inner_rect`:
// any extra cross space is shared evenly between lines and the stack starts at the edge.
// Otherwise the stack takes its natural cross size and `align2` positions it.
let extra_cross = f32::max(cross_range.span() - inner_size[cross_axis], 0.0);
let line_grow_cross = if cross_justify && !lines.is_empty() {
(extra_cross / lines.len() as f32).floor_ui()
} else {
0.0
};
let mut cross_cursor = if cross_justify {
cross_range.min
} else {
align2.0[cross_axis]
.align_size_within_range(inner_size[cross_axis], cross_range)
.min
};
// The cursor walks the main axis from the start (left/top) of the aligned block.
let mut cursor = aligned_rect.min.to_vec2()[main_axis];
// Split the flat `sized_atoms` into per-line groups. The line ranges are contiguous and
// ordered, so we can just take them off the front in order.
let mut atoms_iter = sized_atoms.into_iter();
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_main } else { 0.0 };
for line in lines {
let mut line_atoms: Vec<SizedAtom<'_>> =
(&mut atoms_iter).take(line.range.len()).collect();
let atom_main = size[main_axis] + growth;
// Per-line growth: extra main space is split between this line's `grow` atoms.
let extra_space = f32::max(main_to_fill - line.main_extent, 0.0);
let grow_main = if line.grow_count > 0 {
f32::max(extra_space / line.grow_count as f32, 0.0).floor_ui()
} else {
0.0
};
// The cell spans the cross axis fully and `atom_main` along the main axis.
let cell = main_cross_rect(direction, aligned_rect, cursor, cursor + atom_main);
cursor += atom_main + gap;
let item_rect = sized.align.align_size_within_rect(size, cell);
// When something on this line grows, the line fills the available main extent;
// otherwise it's the line's own extent. `align2` then positions it along the main axis.
let block_main = if line.grow_count > 0 {
main_to_fill
} else {
line.main_extent
};
let line_main = align2.0[main_axis].align_size_within_range(block_main, main_range);
if let Some(id) = sized.id {
debug_assert!(
!response.custom_rects.iter().any(|(i, _)| *i == id),
"Duplicate custom id"
);
response.custom_rects.push((id, item_rect));
// The rect this line occupies: `block_main` along the main axis, and its cross extent
// (this line's height) along the cross axis, grown to fill if `cross_justify` is set.
let line_cross = line.cross_extent + line_grow_cross;
let line_rect = rect_from_main_cross(
direction,
line_main.min,
line_main.span(),
cross_cursor,
line_cross,
);
cross_cursor += line_cross + gap;
// For reversed directions the first atom sits at the far end, so we lay them out in
// reverse and otherwise share the same forward cursor logic.
if matches!(direction, Direction::RightToLeft | Direction::BottomUp) {
line_atoms.reverse();
}
match sized.kind {
SizedAtomKind::Text(galley) => {
ui.painter()
.galley(item_rect.min, galley, fallback_text_color);
// The cursor walks the main axis from the start of the aligned line.
let mut cursor = line_rect.min.to_vec2()[main_axis];
for sized in line_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_main } else { 0.0 };
let atom_main = size[main_axis] + growth;
// The cell spans this line's cross extent fully and `atom_main` along the main axis.
let cell = main_cross_rect(direction, line_rect, cursor, cursor + atom_main);
cursor += atom_main + gap;
let item_rect = sized.align.align_size_within_rect(size, cell);
if let Some(id) = sized.id {
debug_assert!(
!response.custom_rects.iter().any(|(i, _)| *i == id),
"Duplicate custom id"
);
response.custom_rects.push((id, item_rect));
}
SizedAtomKind::Image { image, size: _ } => {
image.paint_at(ui, item_rect);
}
SizedAtomKind::Empty { .. } => {}
SizedAtomKind::Layout(layout) => {
// TODO(lucasmerlin): Add some kind of justify flag, right now nested atoms are always
// shown fully stretched.
let layout_response = ui.interact(cell, layout.id, layout.sense);
layout.paint_at(ui, cell, layout_response);
match sized.kind {
SizedAtomKind::Text(galley) => {
ui.painter()
.galley(item_rect.min, galley, fallback_text_color);
}
SizedAtomKind::Image { image, size: _ } => {
image.paint_at(ui, item_rect);
}
SizedAtomKind::Empty { .. } => {}
SizedAtomKind::Layout { source, sized } => {
let layout_response = ui.interact(cell, sized.id, sized.sense);
// The atom was measured at its natural size, but `grow`/`shrink` may have
// changed the cell it's painted into. If so, re-measure the layout at the
// actual cell so its own contents re-wrap / reflow to fit (a nested layout
// can't otherwise know how much it grew). When the size is unchanged we
// reuse the already-measured layout.
let resized = (cell.size() - sized.outer_size).abs().max_elem() > 0.5;
if resized {
source
.measure(ui, cell.size())
.paint_at(ui, cell, layout_response);
} else {
sized.paint_at(ui, cell, layout_response);
}
}
}
}
}

View File

@@ -1,15 +1,40 @@
use crate::{Image, SizedAtomLayout};
use crate::{AtomLayout, Image, SizedAtomLayout};
use emath::Vec2;
use epaint::Galley;
use std::rc::Rc;
use std::sync::Arc;
/// A sized [`crate::AtomKind`].
#[derive(Clone, Debug)]
#[derive(Clone)]
pub enum SizedAtomKind<'a> {
Empty { size: Option<Vec2> },
Empty {
size: Option<Vec2>,
},
Text(Arc<Galley>),
Image { image: Image<'a>, size: Vec2 },
Layout(Box<SizedAtomLayout<'a>>),
Image {
image: Image<'a>,
size: Vec2,
},
Layout {
/// A shared handle to the original (unmeasured) layout, kept so a grown atom can be
/// re-measured — and so re-wrap its contents — at the size its parent actually paints it
/// at, without deep-cloning. See [`SizedAtomLayout::paint_at`].
source: Rc<AtomLayout<'a>>,
/// The layout measured at its natural size, used for the parent's own sizing.
sized: Box<SizedAtomLayout<'a>>,
},
}
impl std::fmt::Debug for SizedAtomKind<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty { size } => f.debug_struct("Empty").field("size", size).finish(),
Self::Text(galley) => f.debug_tuple("Text").field(galley).finish(),
Self::Image { size, .. } => f.debug_struct("Image").field("size", size).finish(),
Self::Layout { sized, .. } => f.debug_tuple("Layout").field(sized).finish(),
}
}
}
impl Default for SizedAtomKind<'_> {
@@ -25,7 +50,7 @@ impl SizedAtomKind<'_> {
SizedAtomKind::Text(galley) => galley.size(),
SizedAtomKind::Image { image: _, size } => *size,
SizedAtomKind::Empty { size } => size.unwrap_or_default(),
SizedAtomKind::Layout(layout) => layout.outer_size,
SizedAtomKind::Layout { sized, .. } => sized.outer_size,
}
}
}

View File

@@ -4,10 +4,10 @@ use emath::{Rect, TSTransform};
use epaint::text::{Galley, LayoutJob, TextWrapMode, cursor::CCursor};
use crate::{
Align, Align2, AsIdSalt, AtomExt as _, AtomKind, AtomLayout, Atoms, Color32, Context,
CursorIcon, Event, EventFilter, FontSelection, Frame, Id, IdSalt, ImeEvent, IntoAtoms,
IntoSizedResult, Key, KeyboardShortcut, Margin, Modifiers, NumExt as _, Response, Sense,
SizedAtomKind, TextBuffer, TextStyle, Ui, Vec2, Widget, WidgetInfo, WidgetWithState, epaint,
Align, Align2, AsIdSalt, Atom, AtomExt as _, AtomKind, AtomLayout, Atoms, Color32, Context,
CursorIcon, Event, EventFilter, FontSelection, Frame, Id, IdSalt, ImeEvent, IntoAtoms, Key,
KeyboardShortcut, Margin, Modifiers, NumExt as _, Response, Sense, TextBuffer, TextStyle, Ui,
Vec2, Widget, WidgetInfo, WidgetWithState, epaint,
os::OperatingSystem,
output::OutputEvent,
response,
@@ -564,11 +564,48 @@ impl TextEdit<'_> {
}
};
// We need to calculate the galley within the atom closure, so we can calculate it based on
// the available width (in case of wrapping multiline text edits). But we show it later,
// so we can clip it to the available size. Thus, extract it from the atom closure here.
let mut get_galley = None;
let custom_frame = frame.is_some();
let frame = frame.unwrap_or_else(|| Frame::new().inner_margin(margin));
let inner_rect_id = Id::new("text_edit_rect");
// The editable text is the layout's `shrink` atom, so the layout would size it to
// `available_inner_width - prefix - suffix - gaps`. We derive that width here so we can lay
// out the galley (and handle events) at the correct wrap width *before* building the
// layout — deriving it up front is the only thing the old sizing-time closure was for.
let editable_width = {
let gap = ui.spacing().icon_spacing;
// In a horizontally-justified layout the AtomLayout drops its `max_width` and fills the
// available width; otherwise it's `allocate_width`. Mirror that so the width we lay the
// galley out at matches the cell the layout will actually give the editable atom.
let effective_width = if ui.layout().horizontal_justify() {
ui.available_size().x.at_least(allocate_width)
} else {
allocate_width
};
let available_inner_width = effective_width - frame.total_margin().sum().x;
let n_atoms = prefix.len() + suffix.len() + 1;
let fixed_main: f32 = prefix
.iter()
.chain(suffix.iter())
.map(|atom| {
atom.as_sized(
ui,
Vec2::new(available_inner_width, f32::INFINITY),
Some(TextWrapMode::Extend),
FontSelection::default(),
)
.size
.x
})
.sum();
(available_inner_width - fixed_main - gap * n_atoms.saturating_sub(1) as f32)
.at_least(0.0)
};
// The galley is laid out (and painted) by us, so we can clip/offset it and draw the cursor;
// the editable atom just reserves its size. Assigned in both branches of the block below.
let get_galley;
let mut response = {
let any_shrink = hint_text.any_shrink();
// Ideally we could just do `let mut atoms = prefix` here, but that won't compile
@@ -623,42 +660,32 @@ impl TextEdit<'_> {
get_galley = Some(galley);
} else {
// We need to shrink when clip_text, so that we don't exceed the available size
// and thus clip. We also need to shrink in multi line text edits, so text can
// wrap appropriately.
// We shrink when clip_text (so we don't exceed the available width and clip) and
// in multiline (so the text wraps). `shrink` also keeps a prefix/suffix text atom
// from being auto-promoted to the shrink atom.
let should_shrink = clip_text || multiline;
// We need a closure here, so we can calculate the galley based on the available
// width (after adding suffix and prefix), for correct wrapping in multi line text
// edits
// Lay out the galley at the editable width and handle events right away, so the
// galley updates the same frame on keystrokes and `scroll_to` works. We paint the
// galley ourselves (clipped/offset, with cursor) later, so the atom just reserves
// its size.
let mut galley = layouter(ui, text, editable_width);
handle_events(ui, &mut galley, layouter, editable_width, text);
let mut size = galley.size();
size.y = size.y.at_least(min_inner_height);
if clip_text {
size.x = size.x.at_most(editable_width);
}
atoms.push_right(
AtomKind::closure(|ui, args| {
let mut galley = layouter(ui, text, args.available_size.x);
// Handling events here allows us to update the galley immediately on
// keystrokes, avoiding frame delays, and ensuring the scroll_to within
// ScrollAreas works correctly.
handle_events(ui, &mut galley, layouter, args.available_size.x, text);
let intrinsic_size = galley.intrinsic_size();
let mut size = galley.size();
size.y = size.y.at_least(min_inner_height);
if clip_text {
size.x = size.x.at_most(args.available_size.x);
}
// We paint the galley later, so we can do clipping and offsetting
get_galley = Some(galley);
IntoSizedResult {
intrinsic_size,
sized: SizedAtomKind::Empty { size: Some(size) },
}
})
.atom_grow(true)
.atom_align(self.align)
.atom_id(inner_rect_id)
.atom_shrink(should_shrink),
Atom::custom(inner_rect_id, size)
.atom_grow(true)
.atom_align(align)
.atom_shrink(should_shrink),
);
get_galley = Some(galley);
}
// TODO(servo/rust-smallvec#146): Use extend_right instead of the loop once we have
@@ -667,9 +694,6 @@ impl TextEdit<'_> {
atoms.push_right(atom);
}
let custom_frame = frame.is_some();
let frame = frame.unwrap_or_else(|| Frame::new().inner_margin(margin));
let min_height = min_inner_height + frame.total_margin().sum().y;
// This wrap mode only affects the hint_text

View File

@@ -0,0 +1,220 @@
use egui::{
Align2, Atom, AtomExt as _, AtomLayout, Atoms, Color32, CornerRadius, Direction, Frame, Margin,
RichText, Stroke, TextWrapMode, Vec2,
};
// A small palette used to colour-code the cards.
const BLUE: Color32 = Color32::from_rgb(0x61, 0xAF, 0xEF);
const RED: Color32 = Color32::from_rgb(0xE0, 0x6C, 0x75);
const GREEN: Color32 = Color32::from_rgb(0x98, 0xC3, 0x79);
const AMBER: Color32 = Color32::from_rgb(0xD1, 0x9A, 0x66);
const PURPLE: Color32 = Color32::from_rgb(0xC6, 0x78, 0xDD);
const CYAN: Color32 = Color32::from_rgb(0x56, 0xB6, 0xC2);
struct Card {
accent: Color32,
title: &'static str,
description: &'static str,
tags: &'static [&'static str],
}
const CARDS: &[Card] = &[
Card {
accent: BLUE,
title: "Northern Lights",
description: "Chasing the shimmering green aurora across a frozen lake under a perfectly \
clear arctic sky.",
tags: &["aurora", "night", "long-exposure", "iceland", "winter"],
},
Card {
accent: GREEN,
title: "Rainforest Canopy",
description: "A slow walk through the misty treetops at dawn, alive with insects and \
distant birdsong.",
tags: &["jungle", "macro", "wildlife", "humid"],
},
Card {
accent: AMBER,
title: "Desert Dunes",
description: "Endless ridges of fine sand shifting and glowing in the warm late afternoon \
light.",
tags: &["sahara", "golden-hour", "minimal", "heat", "travel", "sand"],
},
Card {
accent: PURPLE,
title: "City After Rain",
description: "Saturated neon reflections rippling on the wet pavement in the heart of a \
busy downtown.",
tags: &["urban", "neon", "reflections"],
},
Card {
accent: CYAN,
title: "Coral Gardens",
description: "Drifting weightlessly over a vivid reef that is absolutely bursting with \
colour and motion.",
tags: &["ocean", "diving", "macro", "blue", "fish", "warm", "reef"],
},
Card {
accent: RED,
title: "Autumn Trail",
description: "A quiet woodland path carpeted in red and gold maple leaves on a crisp \
October morning.",
tags: &["forest", "fall", "hike", "leaves"],
},
];
/// A responsive card gallery built entirely out of [`AtomLayout`]s.
///
/// Think of it as flexbox: the outer list is a wrapping row of `grow` cards, and inside each card
/// a column holds a mock image, a title, a description and a wrapping row of `grow` tags. Resize
/// the window to watch the cards and tags reflow.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Default)]
pub struct AtomLayoutDemo {}
impl crate::Demo for AtomLayoutDemo {
fn name(&self) -> &'static str {
"🖼 Atom Layout"
}
fn show(&mut self, ui: &mut egui::Ui, open: &mut bool) {
use crate::View as _;
egui::Window::new(self.name())
.default_width(640.0)
.default_height(560.0)
.open(open)
.constrain_to(ui.available_rect_before_wrap())
.show(ui, |ui| self.ui(ui));
}
}
impl crate::View for AtomLayoutDemo {
fn ui(&mut self, ui: &mut egui::Ui) {
ui.small(
"A responsive card gallery — one wrapping AtomLayout of grow cards, each itself a \
column with a wrapping row of grow tags. Resize the window to watch it reflow.",
);
ui.add_space(8.0);
egui::ScrollArea::vertical()
.id_salt("cards_scroll")
.auto_shrink([false, false])
.show(ui, |ui| {
let mut cards = Atoms::default();
for c in CARDS {
cards.push_right(card(ui, c));
}
AtomLayout::new(cards)
.wrap(true)
.gap(12.0)
.align2(Align2::LEFT_TOP)
// Fill the available width so the `grow` cards stretch to share each row.
.min_size(Vec2::new(ui.available_width(), 0.0))
.show(ui);
});
}
}
/// One card: a vertical column of [ mock image · title · description · tags · footer ]. Marked
/// `grow` so cards share each row's width; the contents re-wrap to the grown width automatically.
fn card(ui: &egui::Ui, card: &Card) -> Atom<'static> {
// Mock image: an empty layout with a coloured fill. It's a nested layout, so it stretches to
// the full card width.
let image = Atom::layout(
AtomLayout::new(())
.frame(
Frame::new()
.fill(card.accent.gamma_multiply(0.8))
.corner_radius(CornerRadius::same(6)),
)
.min_size(Vec2::new(0.0, 96.0)),
);
// Tags: a wrapping row where each tag grows to justify the line.
let mut tag_atoms = Atoms::default();
for t in card.tags {
tag_atoms.push_right(tag(ui, card.accent, t).atom_grow(true));
}
let tags = Atom::layout(
AtomLayout::new(tag_atoms)
.wrap(true)
.gap(4.0)
.align2(Align2::LEFT_TOP),
);
// Footer: Like / Share buttons that split the card width.
let footer = Atom::layout(
AtomLayout::new((
footer_button(ui, "♥ Like").atom_grow(true),
footer_button(ui, "↗ Share").atom_grow(true),
))
.gap(6.0),
);
let card_frame = Frame::new()
.fill(ui.visuals().faint_bg_color)
.stroke(ui.visuals().widgets.noninteractive.bg_stroke)
.corner_radius(CornerRadius::same(8))
.inner_margin(Margin::same(8));
let column = AtomLayout::new((
image,
RichText::new(card.title)
.strong()
.atom_align(Align2::LEFT_CENTER),
RichText::new(card.description)
.small()
.weak()
.atom_align(Align2::LEFT_TOP)
// `shrink` lets the description wrap to the card width instead of its full text width
// dictating how wide the card has to be.
.atom_shrink(true),
tags,
// A `grow` spacer eats any leftover vertical space, pinning the footer to the bottom — so
// footers line up across cards of different heights (cards in a row are equal height).
Atom::grow(),
footer,
))
.direction(Direction::TopDown)
.frame(card_frame)
.gap(6.0)
.wrap_mode(TextWrapMode::Wrap)
// Stretch full-width pieces (image, footer) to the card's grown width.
.cross_justify(true)
.align2(Align2::LEFT_TOP);
// `atom_max_width` sets the card's natural (flex-basis) width; `grow` lets it stretch to share
// the row, and the core re-measures the contents at that grown width so they reflow.
Atom::layout(column).atom_grow(true).atom_max_width(230.0)
}
/// A flat footer button (e.g. Like / Share).
fn footer_button(ui: &egui::Ui, text: &str) -> Atom<'static> {
let visuals = &ui.visuals().widgets.inactive;
let frame = Frame::new()
.inner_margin(Margin::symmetric(8, 4))
.corner_radius(CornerRadius::same(6))
.fill(visuals.bg_fill)
.stroke(visuals.bg_stroke);
Atom::layout(
AtomLayout::new(RichText::new(text.to_owned()).color(ui.visuals().weak_text_color()))
.frame(frame)
.align2(Align2::CENTER_CENTER),
)
}
/// A small filled tag chip.
fn tag(ui: &egui::Ui, accent: Color32, text: &str) -> Atom<'static> {
let bg = ui.visuals().window_fill();
let frame = Frame::new()
.inner_margin(Margin::symmetric(6, 1))
.corner_radius(CornerRadius::same(8))
.fill(bg.lerp_to_gamma(accent, 0.22))
.stroke(Stroke::new(1.0, accent.gamma_multiply(0.6)));
Atom::layout(
AtomLayout::new(RichText::new(text.to_owned()).small().color(accent))
.frame(frame)
.align2(Align2::CENTER_CENTER)
.gap(0.0),
)
}

View File

@@ -72,6 +72,7 @@ impl Default for DemoGroups {
Self {
about: About::default(),
demos: DemoGroup::new(vec![
Box::<super::atom_layout_demo::AtomLayoutDemo>::default(),
Box::<super::paint_bezier::PaintBezier>::default(),
Box::<super::code_editor::CodeEditor>::default(),
Box::<super::code_example::CodeExample>::default(),

View File

@@ -5,6 +5,7 @@
// ----------------------------------------------------------------------------
pub mod about;
pub mod atom_layout_demo;
pub mod code_editor;
pub mod code_example;
pub mod dancing_strings;

View File

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

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2d941c979def6f30fd227c144d20c7f138f9c06c3338d563ac075c5e17e9b795
size 26911
oid sha256:79feb3aea8e5cec9e192e949098c04756d524be5b21569192ecd4e45cbcb5882
size 33164

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6df3c0a298d48a5f2e1b36207909e20ea545a72a97461a3ae0792d21e0554f93
size 76567
oid sha256:09d43457c340dde9ba1b3515996796ab0663220d38cb080550f29e7624b40217
size 75330

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3edbe6debf700364949ef481ba1e8b2319db624ddc316570b6180636dcd88935
size 57262
oid sha256:549a1eff3f1bc54a95cc67253abfaf8c48e0c299977a063d78be9131d419b4ba
size 56071

View File

@@ -0,0 +1,56 @@
//! Experiment (Taffy-style deep-tree blowup):
//!
//! egui's atom layout re-measures a grown nested `Layout` atom so its content can reflow at the
//! resolved width (the cross-after-main pass in `AtomLayout::measure`, plus the re-measure in
//! `paint_at`). Unlike Taffy, there is currently *no measurement cache*. So a chain of nested
//! `grow` layouts where every level fills past its content should re-measure each child more than
//! once per level — `O(2^depth)` — reproducing the kind of exponential blowup Taffy's PR #246
//! cache fixed (a deep tree that went from ~17s to ~3ms).
//!
//! Run with:
//! ```sh
//! cargo nextest run -p egui_tests -E 'test(atom_reflow_blowup)' --run-ignored all --no-capture
//! ```
use egui::{Atom, AtomExt as _, AtomLayout, Vec2};
use egui_kittest::Harness;
use std::time::{Duration, Instant};
/// A chain of `depth` nested layouts. Each level is a single `grow` child inside a parent whose
/// `min_size` is *strictly larger* than the child's own (the child's natural width = its own
/// `min_size`). So every parent grows its child past its natural width — `grow_main > 0` — which
/// triggers a re-measure of the child, recursively, all the way down.
fn nested(depth: usize) -> AtomLayout<'static> {
if depth == 0 {
AtomLayout::new("leaf")
} else {
let child = Atom::layout(nested(depth - 1)).atom_grow(true);
// Larger at the top, decreasing toward the leaves, so each level genuinely grows its child.
AtomLayout::new(child).min_size(Vec2::new(50.0 * depth as f32, 0.0))
}
}
#[test]
#[ignore = "perf experiment, run manually with --run-ignored"]
fn atom_reflow_blowup() {
let mut prev: Option<Duration> = None;
for depth in 1..=30 {
let start = Instant::now();
let mut harness = Harness::builder().build_ui(|ui| {
nested(depth).show(ui);
});
harness.run_steps(1);
let elapsed = start.elapsed();
let ratio = prev.map_or(String::new(), |p| {
format!("(x{:.2} vs prev)", elapsed.as_secs_f64() / p.as_secs_f64())
});
println!("depth {depth:2}: {elapsed:>12.3?} {ratio}");
prev = Some(elapsed);
if elapsed > Duration::from_secs(3) {
println!("... aborting: blowup confirmed");
break;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
use egui::{
Align, Atom, AtomExt as _, AtomLayout, Button, Direction, Frame, Layout, TextWrapMode, Ui, Vec2,
Align, Atom, AtomExt as _, AtomLayout, Atoms, Button, Direction, Frame, Layout, TextWrapMode,
Ui, Vec2,
};
use egui_kittest::{HarnessBuilder, SnapshotResult, SnapshotResults};
@@ -186,3 +187,106 @@ fn test_atom_letter_spacing() {
harness.snapshot("atom_letter_spacing");
}
/// A list of button-framed texts of varying widths, used by the wrapping tests.
///
/// When `grow` is set, each atom is marked `grow` so lines stretch to fill the available extent.
fn fruit_atoms(button_frame: Frame, grow: bool) -> Atoms<'static> {
let words = [
"apple",
"banana",
"kiwi",
"strawberry",
"fig",
"pomegranate",
"pear",
"plum",
"blackberry",
"lime",
"cantaloupe",
"date",
"guava",
"melon",
];
let mut atoms = Atoms::default();
for word in words {
let atom = Atom::layout(AtomLayout::new(word).frame(button_frame.clone()));
atoms.push_right(if grow { atom.atom_grow(true) } else { atom });
}
atoms
}
fn button_frame(ui: &Ui) -> Frame {
ui.style()
.button_style(
&egui::widget_style::Classes::default(),
egui::widget_style::WidgetState::Inactive,
)
.frame
}
/// Tests flex-like wrapping ([`AtomLayout::wrap`]) of a justified list.
///
/// Each entry is a button-framed text of a different width, marked `grow`. Inside a
/// main-justified [`Layout`] the atoms wrap onto multiple lines, and every line stretches its
/// atoms to fill the full width.
#[test]
fn test_atom_wrap_justified() {
let mut harness = HarnessBuilder::default()
.with_size(Vec2::new(320.0, 240.0))
.build_ui(|ui| {
let atoms = fruit_atoms(button_frame(ui), true);
ui.with_layout(
Layout::left_to_right(Align::Min).with_main_justify(true),
|ui| {
AtomLayout::new(atoms).wrap(true).show(ui);
},
);
});
harness.run();
harness.snapshot("atom_wrap_justified");
}
/// Tests non-justified (left-aligned, ragged) flex-like wrapping.
///
/// The atoms are not marked `grow`, so each line keeps its natural width and is left-aligned;
/// `max_width` forces wrapping onto multiple lines.
#[test]
fn test_atom_wrap_ragged() {
let mut harness = HarnessBuilder::default()
.with_size(Vec2::new(320.0, 240.0))
.build_ui(|ui| {
let atoms = fruit_atoms(button_frame(ui), false);
AtomLayout::new(atoms)
.wrap(true)
.max_width(220.0)
.align2(egui::Align2::LEFT_TOP)
.show(ui);
});
harness.run();
harness.snapshot("atom_wrap_ragged");
}
/// Tests flex-like wrapping along a vertical ([`Direction::TopDown`]) main axis.
///
/// Atoms flow downward and wrap into new columns to the right once they exceed `max_height`.
#[test]
fn test_atom_wrap_top_down() {
let mut harness = HarnessBuilder::default()
.with_size(Vec2::new(320.0, 240.0))
.build_ui(|ui| {
let atoms = fruit_atoms(button_frame(ui), false);
AtomLayout::new(atoms)
.wrap(true)
.direction(Direction::TopDown)
.max_height(140.0)
.align2(egui::Align2::LEFT_TOP)
.show(ui);
});
harness.run();
harness.snapshot("atom_wrap_top_down");
}

View File

@@ -134,6 +134,19 @@ fn widget_tests() {
},
&mut results,
);
test_widget(
"text_edit_multiline_prefix_suffix",
|ui| {
ui.spacing_mut().text_edit_width = 80.0;
// Multiline wraps at the editable width (the width left after prefix/suffix), so this
// exercises that the editable width is derived correctly.
TextEdit::multiline(&mut "Wrap this longer text".to_owned())
.prefix("🔎")
.suffix("!")
.ui(ui)
},
&mut results,
);
test_widget(
"slider",