mirror of
https://github.com/emilk/egui.git
synced 2026-06-26 22:53:14 -04:00
Implement wrapping atom layout
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
// `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,34 +785,86 @@ 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.
|
||||
// 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_rect.y_range()
|
||||
};
|
||||
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];
|
||||
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 {
|
||||
// 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
|
||||
};
|
||||
|
||||
// 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 line in lines {
|
||||
let mut line_atoms: Vec<SizedAtom<'_>> =
|
||||
(&mut atoms_iter).take(line.range.len()).collect();
|
||||
|
||||
// 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
|
||||
};
|
||||
|
||||
// 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 {
|
||||
inner_main
|
||||
line.main_extent
|
||||
};
|
||||
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 line_main = align2.0[main_axis].align_size_within_range(block_main, main_range);
|
||||
|
||||
// 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) {
|
||||
sized_atoms.reverse();
|
||||
line_atoms.reverse();
|
||||
}
|
||||
|
||||
// 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];
|
||||
// The cursor walks the main axis from the start of the aligned line.
|
||||
let mut cursor = line_rect.min.to_vec2()[main_axis];
|
||||
|
||||
let mut response = AtomLayoutResponse::empty(response);
|
||||
|
||||
for sized in sized_atoms {
|
||||
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
|
||||
@@ -605,8 +872,8 @@ impl<'atom> SizedAtomLayout<'atom> {
|
||||
|
||||
let atom_main = size[main_axis] + growth;
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
|
||||
@@ -627,11 +894,22 @@ impl<'atom> SizedAtomLayout<'atom> {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
atoms.push_right(
|
||||
AtomKind::closure(|ui, args| {
|
||||
let mut galley = layouter(ui, text, args.available_size.x);
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
size.x = size.x.at_most(editable_width);
|
||||
}
|
||||
|
||||
// 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) },
|
||||
}
|
||||
})
|
||||
atoms.push_right(
|
||||
Atom::custom(inner_rect_id, size)
|
||||
.atom_grow(true)
|
||||
.atom_align(self.align)
|
||||
.atom_id(inner_rect_id)
|
||||
.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
|
||||
|
||||
220
crates/egui_demo_lib/src/demo/atom_layout_demo.rs
Normal file
220
crates/egui_demo_lib/src/demo/atom_layout_demo.rs
Normal 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),
|
||||
)
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
pub mod about;
|
||||
pub mod atom_layout_demo;
|
||||
pub mod code_editor;
|
||||
pub mod code_example;
|
||||
pub mod dancing_strings;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:829557a546a642f7ae6808ada89fb02d7062537d0c428ad4e314557d9adb994c
|
||||
size 78507
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2d941c979def6f30fd227c144d20c7f138f9c06c3338d563ac075c5e17e9b795
|
||||
size 26911
|
||||
oid sha256:79feb3aea8e5cec9e192e949098c04756d524be5b21569192ecd4e45cbcb5882
|
||||
size 33164
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6df3c0a298d48a5f2e1b36207909e20ea545a72a97461a3ae0792d21e0554f93
|
||||
size 76567
|
||||
oid sha256:09d43457c340dde9ba1b3515996796ab0663220d38cb080550f29e7624b40217
|
||||
size 75330
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3edbe6debf700364949ef481ba1e8b2319db624ddc316570b6180636dcd88935
|
||||
size 57262
|
||||
oid sha256:549a1eff3f1bc54a95cc67253abfaf8c48e0c299977a063d78be9131d419b4ba
|
||||
size 56071
|
||||
|
||||
56
tests/egui_tests/tests/atom_reflow_blowup.rs
Normal file
56
tests/egui_tests/tests/atom_reflow_blowup.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
3
tests/egui_tests/tests/snapshots/atom_wrap_justified.png
Normal file
3
tests/egui_tests/tests/snapshots/atom_wrap_justified.png
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7506fa98769b5d387dac906206859824b8cf8c86665c383e3721cbe74e839379
|
||||
size 13160
|
||||
3
tests/egui_tests/tests/snapshots/atom_wrap_ragged.png
Normal file
3
tests/egui_tests/tests/snapshots/atom_wrap_ragged.png
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f56cb05b59acf3550f73ad9609922f1b49da83ba7671148f5f5067b7e45c8918
|
||||
size 13219
|
||||
3
tests/egui_tests/tests/snapshots/atom_wrap_top_down.png
Normal file
3
tests/egui_tests/tests/snapshots/atom_wrap_top_down.png
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:133af05ea2a6c387547cbba17b27d5e93a3f13df7d7ade5c134a39b057d48d03
|
||||
size 13241
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ca11ca5fbb4d1efa83929decfbea0fe723ed8da32277bcffbe509f444b5e8fa5
|
||||
size 572550
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:860b8f749bfbbc27f037ac0f643a19873d0e8c879baa919d76878ad658efa846
|
||||
size 22237
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user