From 3a85b165aa6e9d88cbfac7b5717b237b9015252b Mon Sep 17 00:00:00 2001 From: lucasmerlin Date: Thu, 4 Jun 2026 15:31:45 +0200 Subject: [PATCH 1/2] Implement wrapping atom layout --- crates/egui/src/atomics/atom.rs | 13 +- crates/egui/src/atomics/atom_kind.rs | 82 ++- crates/egui/src/atomics/atom_layout.rs | 474 ++++++++++++++---- crates/egui/src/atomics/sized_atom_kind.rs | 37 +- crates/egui/src/widgets/text_edit/builder.rs | 110 ++-- .../src/demo/atom_layout_demo.rs | 220 ++++++++ .../src/demo/demo_app_windows.rs | 1 + crates/egui_demo_lib/src/demo/mod.rs | 1 + .../tests/snapshots/demos/Atom Layout.png | 3 + .../tests/snapshots/demos/Code Editor.png | 4 +- .../tests/snapshots/demos/Code Example.png | 4 +- .../tests/snapshots/demos/Popups.png | 4 +- tests/egui_tests/tests/atom_reflow_blowup.rs | 56 +++ .../tests/snapshots/atom_wrap_justified.png | 3 + .../tests/snapshots/atom_wrap_ragged.png | 3 + .../tests/snapshots/atom_wrap_top_down.png | 3 + .../text_edit_multiline_prefix_suffix.png | 3 + .../text_edit_multiline_prefix_suffix.png | 3 + tests/egui_tests/tests/test_atoms.rs | 106 +++- tests/egui_tests/tests/test_widgets.rs | 13 + 20 files changed, 931 insertions(+), 212 deletions(-) create mode 100644 crates/egui_demo_lib/src/demo/atom_layout_demo.rs create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Atom Layout.png create mode 100644 tests/egui_tests/tests/atom_reflow_blowup.rs create mode 100644 tests/egui_tests/tests/snapshots/atom_wrap_justified.png create mode 100644 tests/egui_tests/tests/snapshots/atom_wrap_ragged.png create mode 100644 tests/egui_tests/tests/snapshots/atom_wrap_top_down.png create mode 100644 tests/egui_tests/tests/snapshots/layout/text_edit_multiline_prefix_suffix.png create mode 100644 tests/egui_tests/tests/snapshots/visuals/text_edit_multiline_prefix_suffix.png diff --git a/crates/egui/src/atomics/atom.rs b/crates/egui/src/atomics/atom.rs index d465d84ab..34e0a21ce 100644 --- a/crates/egui/src/atomics/atom.rs +++ b/crates/egui/src/atomics/atom.rs @@ -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, @@ -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, diff --git a/crates/egui/src/atomics/atom_kind.rs b/crates/egui/src/atomics/atom_kind.rs index f996c173b..ceec9083d 100644 --- a/crates/egui/src/atomics/atom_kind.rs +++ b/crates/egui/src/atomics/atom_kind.rs @@ -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 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>), -} - -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>), } 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()"), AtomKind::Layout(_) => write!(f, "AtomKind::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> for AtomKind<'a> { fn from(layout: AtomLayout<'a>) -> Self { - AtomKind::Layout(Box::new(layout)) + AtomKind::Layout(Rc::new(layout)) } } diff --git a/crates/egui/src/atomics/atom_layout.rs b/crates/egui/src/atomics/atom_layout.rs index 494e07b0a..4b137c582 100644 --- a/crates/egui/src/atomics/atom_layout.rs +++ b/crates/egui/src/atomics/atom_layout.rs @@ -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 { + 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, align2: Option, 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::() + + 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, + + /// 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, /// 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> = + (&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); + } + } } } } diff --git a/crates/egui/src/atomics/sized_atom_kind.rs b/crates/egui/src/atomics/sized_atom_kind.rs index 7dc0b7c40..bd4b333ce 100644 --- a/crates/egui/src/atomics/sized_atom_kind.rs +++ b/crates/egui/src/atomics/sized_atom_kind.rs @@ -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 }, + Empty { + size: Option, + }, Text(Arc), - Image { image: Image<'a>, size: Vec2 }, - Layout(Box>), + 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>, + + /// The layout measured at its natural size, used for the parent's own sizing. + sized: Box>, + }, +} + +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, } } } diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index 1489fc67c..b015a0f60 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -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 diff --git a/crates/egui_demo_lib/src/demo/atom_layout_demo.rs b/crates/egui_demo_lib/src/demo/atom_layout_demo.rs new file mode 100644 index 000000000..edd038349 --- /dev/null +++ b/crates/egui_demo_lib/src/demo/atom_layout_demo.rs @@ -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), + ) +} diff --git a/crates/egui_demo_lib/src/demo/demo_app_windows.rs b/crates/egui_demo_lib/src/demo/demo_app_windows.rs index 8e59e0933..9b1bd0972 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -72,6 +72,7 @@ impl Default for DemoGroups { Self { about: About::default(), demos: DemoGroup::new(vec![ + Box::::default(), Box::::default(), Box::::default(), Box::::default(), diff --git a/crates/egui_demo_lib/src/demo/mod.rs b/crates/egui_demo_lib/src/demo/mod.rs index 6d1906166..0993bf158 100644 --- a/crates/egui_demo_lib/src/demo/mod.rs +++ b/crates/egui_demo_lib/src/demo/mod.rs @@ -5,6 +5,7 @@ // ---------------------------------------------------------------------------- pub mod about; +pub mod atom_layout_demo; pub mod code_editor; pub mod code_example; pub mod dancing_strings; diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Atom Layout.png b/crates/egui_demo_lib/tests/snapshots/demos/Atom Layout.png new file mode 100644 index 000000000..286c435f6 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Atom Layout.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:829557a546a642f7ae6808ada89fb02d7062537d0c428ad4e314557d9adb994c +size 78507 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Code Editor.png b/crates/egui_demo_lib/tests/snapshots/demos/Code Editor.png index 12b0c24d3..81894d7e2 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Code Editor.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Code Editor.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2d941c979def6f30fd227c144d20c7f138f9c06c3338d563ac075c5e17e9b795 -size 26911 +oid sha256:79feb3aea8e5cec9e192e949098c04756d524be5b21569192ecd4e45cbcb5882 +size 33164 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Code Example.png b/crates/egui_demo_lib/tests/snapshots/demos/Code Example.png index 17f557c8d..79242f6df 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Code Example.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Code Example.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6df3c0a298d48a5f2e1b36207909e20ea545a72a97461a3ae0792d21e0554f93 -size 76567 +oid sha256:09d43457c340dde9ba1b3515996796ab0663220d38cb080550f29e7624b40217 +size 75330 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Popups.png b/crates/egui_demo_lib/tests/snapshots/demos/Popups.png index c452f78da..363c77e8f 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Popups.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Popups.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3edbe6debf700364949ef481ba1e8b2319db624ddc316570b6180636dcd88935 -size 57262 +oid sha256:549a1eff3f1bc54a95cc67253abfaf8c48e0c299977a063d78be9131d419b4ba +size 56071 diff --git a/tests/egui_tests/tests/atom_reflow_blowup.rs b/tests/egui_tests/tests/atom_reflow_blowup.rs new file mode 100644 index 000000000..6c4d31d6e --- /dev/null +++ b/tests/egui_tests/tests/atom_reflow_blowup.rs @@ -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 = 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; + } + } +} diff --git a/tests/egui_tests/tests/snapshots/atom_wrap_justified.png b/tests/egui_tests/tests/snapshots/atom_wrap_justified.png new file mode 100644 index 000000000..68c4c31c5 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/atom_wrap_justified.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7506fa98769b5d387dac906206859824b8cf8c86665c383e3721cbe74e839379 +size 13160 diff --git a/tests/egui_tests/tests/snapshots/atom_wrap_ragged.png b/tests/egui_tests/tests/snapshots/atom_wrap_ragged.png new file mode 100644 index 000000000..f443a2116 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/atom_wrap_ragged.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f56cb05b59acf3550f73ad9609922f1b49da83ba7671148f5f5067b7e45c8918 +size 13219 diff --git a/tests/egui_tests/tests/snapshots/atom_wrap_top_down.png b/tests/egui_tests/tests/snapshots/atom_wrap_top_down.png new file mode 100644 index 000000000..b552cef44 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/atom_wrap_top_down.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:133af05ea2a6c387547cbba17b27d5e93a3f13df7d7ade5c134a39b057d48d03 +size 13241 diff --git a/tests/egui_tests/tests/snapshots/layout/text_edit_multiline_prefix_suffix.png b/tests/egui_tests/tests/snapshots/layout/text_edit_multiline_prefix_suffix.png new file mode 100644 index 000000000..a70d40729 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/layout/text_edit_multiline_prefix_suffix.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca11ca5fbb4d1efa83929decfbea0fe723ed8da32277bcffbe509f444b5e8fa5 +size 572550 diff --git a/tests/egui_tests/tests/snapshots/visuals/text_edit_multiline_prefix_suffix.png b/tests/egui_tests/tests/snapshots/visuals/text_edit_multiline_prefix_suffix.png new file mode 100644 index 000000000..a2140b0f4 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/visuals/text_edit_multiline_prefix_suffix.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:860b8f749bfbbc27f037ac0f643a19873d0e8c879baa919d76878ad658efa846 +size 22237 diff --git a/tests/egui_tests/tests/test_atoms.rs b/tests/egui_tests/tests/test_atoms.rs index 15ce9dfc9..123912f14 100644 --- a/tests/egui_tests/tests/test_atoms.rs +++ b/tests/egui_tests/tests/test_atoms.rs @@ -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"); +} diff --git a/tests/egui_tests/tests/test_widgets.rs b/tests/egui_tests/tests/test_widgets.rs index 5283b21d4..59a64c05c 100644 --- a/tests/egui_tests/tests/test_widgets.rs +++ b/tests/egui_tests/tests/test_widgets.rs @@ -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", From ca2131824d44de33d49a1fcaaf802ff5feebd67a Mon Sep 17 00:00:00 2001 From: lucasmerlin Date: Fri, 5 Jun 2026 13:15:35 +0200 Subject: [PATCH 2/2] Add measurement cache --- crates/egui/src/atomics/atom.rs | 3 ++ crates/egui/src/atomics/atom_kind.rs | 7 ++- crates/egui/src/atomics/atom_layout.rs | 53 +++++++++++++++++++- crates/egui/src/widgets/text_edit/builder.rs | 1 + 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/crates/egui/src/atomics/atom.rs b/crates/egui/src/atomics/atom.rs index 34e0a21ce..b9bb0bcde 100644 --- a/crates/egui/src/atomics/atom.rs +++ b/crates/egui/src/atomics/atom.rs @@ -1,3 +1,4 @@ +use super::MeasureCache; use crate::{ AtomKind, AtomLayout, FontSelection, Id, IntoSizedArgs, IntoSizedResult, SizedAtom, Ui, }; @@ -124,6 +125,7 @@ impl<'a> Atom<'a> { mut available_size: Vec2, mut wrap_mode: Option, fallback_font: FontSelection, + cache: &mut MeasureCache<'a>, ) -> SizedAtom<'a> { if !self.shrink && self.max_size.x.is_infinite() { wrap_mode = Some(TextWrapMode::Extend); @@ -149,6 +151,7 @@ impl<'a> Atom<'a> { wrap_mode, fallback_font, }, + cache, ); let size = self diff --git a/crates/egui/src/atomics/atom_kind.rs b/crates/egui/src/atomics/atom_kind.rs index ceec9083d..526086332 100644 --- a/crates/egui/src/atomics/atom_kind.rs +++ b/crates/egui/src/atomics/atom_kind.rs @@ -102,6 +102,7 @@ impl<'a> AtomKind<'a> { wrap_mode, fallback_font, }: IntoSizedArgs, + cache: &mut super::MeasureCache<'a>, ) -> IntoSizedResult<'a> { match self { AtomKind::Text(text) => { @@ -131,8 +132,10 @@ impl<'a> AtomKind<'a> { 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); + // `paint_at` (cheap `Rc` clone, no deep copy). `measure_rc` shares the `cache` + // (keyed by the `Rc`'s identity) so a deep tree of `grow` layouts doesn't + // re-measure its descendants exponentially. + let sized = AtomLayout::measure_rc(layout, ui, available_size, cache); IntoSizedResult { intrinsic_size: sized.intrinsic_size, sized: SizedAtomKind::Layout { diff --git a/crates/egui/src/atomics/atom_layout.rs b/crates/egui/src/atomics/atom_layout.rs index 4b137c582..f8ff88676 100644 --- a/crates/egui/src/atomics/atom_layout.rs +++ b/crates/egui/src/atomics/atom_layout.rs @@ -6,9 +6,19 @@ use emath::{Align2, GuiRounding as _, NumExt as _, Rect, Vec2}; use epaint::text::TextWrapMode; use epaint::{Color32, Galley}; use smallvec::SmallVec; +use std::collections::HashMap; use std::ops::{Deref, DerefMut}; +use std::rc::Rc; use std::sync::Arc; +/// Frame-pass-local memoization cache for [`AtomLayout::measure_rc`]. +/// +/// Keyed by an [`Rc::as_ptr`] identity plus the available-size bits. Both are stable within a +/// single top-level measure pass (nested layouts are held alive via `Rc`), so repeatedly measuring +/// the same nested layout at the same size — which a deep tree of `grow` layouts does `O(2^depth)` +/// times — becomes a cache hit instead of a full re-measure. +pub(crate) type MeasureCache<'a> = HashMap<(usize, u64), SizedAtomLayout<'a>>; + /// The `(main, cross)` axis indices for `direction`, for indexing a [`Vec2`] (0 = x, 1 = y). #[inline] fn main_cross_axis(direction: Direction) -> (usize, usize) { @@ -341,6 +351,44 @@ impl<'a> AtomLayout<'a> { /// 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> { + self.measure_impl(ui, available_size, &mut MeasureCache::default()) + } + + /// Measure a nested layout held by an [`Rc`], memoizing the result in `cache`. + /// + /// A grown nested `Layout` atom is re-measured (the cross-after-main reflow) at its grown + /// size, recursively. Without memoization a deep tree of `grow` layouts re-measures its + /// descendants `O(2^depth)` times. Keyed by the layout's [`Rc::as_ptr`] identity and the + /// available size — both stable within a pass — repeated `(layout, size)` measures become + /// cache hits. The `Rc` is held by the caller (the `Layout` atom / reflow source), which is + /// why the identity lives here rather than in [`Self::measure_impl`]. + pub(crate) fn measure_rc( + layout: &Rc, + ui: &Ui, + available_size: Vec2, + cache: &mut MeasureCache<'a>, + ) -> SizedAtomLayout<'a> { + let key = ( + Rc::as_ptr(layout) as usize, + (u64::from(available_size.x.to_bits()) << 32) | u64::from(available_size.y.to_bits()), + ); + if let Some(cached) = cache.get(&key) { + return cached.clone(); + } + let result = layout.measure_impl(ui, available_size, cache); + cache.insert(key, result.clone()); + result + } + + /// The measure body. Threads `cache` so nested [`Rc`] layouts are memoized via + /// [`Self::measure_rc`]; it does not memoize its own result (a top-level layout is measured + /// once, and a nested one is keyed by its `Rc` at the call site). + pub(crate) fn measure_impl( + &self, + ui: &Ui, + available_size: Vec2, + cache: &mut MeasureCache<'a>, + ) -> SizedAtomLayout<'a> { let atoms = &self.atoms; let frame = self.frame; let sense = self.sense; @@ -444,6 +492,7 @@ impl<'a> AtomLayout<'a> { available_inner_size, Some(wrap_mode), fallback_font.clone(), + cache, ); let size = sized.size; @@ -473,6 +522,7 @@ impl<'a> AtomLayout<'a> { available_size_for_shrink_item, Some(wrap_mode), fallback_font, + cache, ) } else { let mut item = item.clone(); @@ -482,6 +532,7 @@ impl<'a> AtomLayout<'a> { available_size_for_shrink_item, Some(wrap_mode), fallback_font, + cache, ) }; let size = sized.size; @@ -553,7 +604,7 @@ impl<'a> AtomLayout<'a> { sized.size[main_axis] + grow_main, available_inner_size[cross_axis], ); - let remeasured = source.measure(ui, grown); + let remeasured = AtomLayout::measure_rc(source, ui, grown, cache); sized.size[cross_axis] = remeasured.outer_size[cross_axis]; **inner = remeasured; } diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index b015a0f60..740423006 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -594,6 +594,7 @@ impl TextEdit<'_> { Vec2::new(available_inner_width, f32::INFINITY), Some(TextWrapMode::Extend), FontSelection::default(), + &mut Default::default(), ) .size .x