diff --git a/crates/egui/src/atomics/atom_layout.rs b/crates/egui/src/atomics/atom_layout.rs index f8ff88676..310ad088e 100644 --- a/crates/egui/src/atomics/atom_layout.rs +++ b/crates/egui/src/atomics/atom_layout.rs @@ -454,8 +454,6 @@ impl<'a> AtomLayout<'a> { let mut cross_size: f32 = 0.0; - let mut sized_items = Vec::new(); - let mut grow_count = 0; let mut shrink_item = None; @@ -470,6 +468,35 @@ impl<'a> AtomLayout<'a> { intrinsic_main += gap_space; } + // A `grow` nested `Layout` in a non-wrapping layout is measured *after* its siblings, at + // the main-axis space they leave over, instead of at the full available width. A fill + // layout (e.g. a wrapping gallery) expands to whatever width it is measured at, so sizing + // it at the full width inflates it past its own cell — and because the line is then already + // over-full, `grow` has no slack to pull it back, so it overflows its fixed siblings. + // Wrapping layouts wrap onto new lines instead of overflowing, so they keep the simpler + // full-width measurement. + let defer_grow = + |item: &crate::Atom<'a>| !wrap && item.grow && matches!(item.kind, AtomKind::Layout(_)); + + // Measured atoms are parked by index so `sized_items` stays in atom order even though we + // size the shrink item and deferred `grow` items out of order. + let mut slots: Vec>> = + std::iter::repeat_with(|| None).take(atoms.len()).collect(); + let mut deferred_grow: Vec = Vec::new(); + + let measure_into = |slot: &mut Option>, + sized: SizedAtom<'a>, + inner_main: &mut f32, + intrinsic_main: &mut f32, + cross_size: &mut f32, + intrinsic_cross: &mut f32| { + *inner_main += sized.size[main_axis]; + *intrinsic_main += sized.intrinsic_size[main_axis]; + *cross_size = cross_size.at_least(sized.size[cross_axis]); + *intrinsic_cross = intrinsic_cross.at_least(sized.intrinsic_size[cross_axis]); + *slot = Some(sized); + }; + for (idx, item) in atoms.iter().enumerate() { if item.grow { grow_count += 1; @@ -487,6 +514,10 @@ impl<'a> AtomLayout<'a> { continue; } } + if defer_grow(item) { + deferred_grow.push(idx); + continue; + } let sized = item.as_sized( ui, available_inner_size, @@ -494,15 +525,14 @@ impl<'a> AtomLayout<'a> { fallback_font.clone(), cache, ); - let size = sized.size; - - inner_main += size[main_axis]; - intrinsic_main += sized.intrinsic_size[main_axis]; - - cross_size = cross_size.at_least(size[cross_axis]); - intrinsic_cross = intrinsic_cross.at_least(sized.intrinsic_size[cross_axis]); - - sized_items.push(sized); + measure_into( + &mut slots[idx], + sized, + &mut inner_main, + &mut intrinsic_main, + &mut cross_size, + &mut intrinsic_cross, + ); } if let Some((index, item)) = shrink_item { @@ -521,7 +551,7 @@ impl<'a> AtomLayout<'a> { ui, available_size_for_shrink_item, Some(wrap_mode), - fallback_font, + fallback_font.clone(), cache, ) } else { @@ -531,21 +561,51 @@ impl<'a> AtomLayout<'a> { ui, available_size_for_shrink_item, Some(wrap_mode), - fallback_font, + fallback_font.clone(), cache, ) }; - let size = sized.size; - - inner_main += size[main_axis]; - intrinsic_main += sized.intrinsic_size[main_axis]; - - cross_size = cross_size.at_least(size[cross_axis]); - intrinsic_cross = intrinsic_cross.at_least(sized.intrinsic_size[cross_axis]); - - sized_items.insert(index, sized); + measure_into( + &mut slots[index], + sized, + &mut inner_main, + &mut intrinsic_main, + &mut cross_size, + &mut intrinsic_cross, + ); } + // Deferred `grow` layouts share whatever main-axis space the other atoms left over. Split + // it across *all* `grow` atoms (matching how `paint_at` distributes the slack), so a + // deferred layout never claims more than its fair share and the line can't overflow. + if !deferred_grow.is_empty() { + let leftover = (available_inner_size[main_axis] - inner_main).max(0.0); + let share = leftover / grow_count.max(1) as f32; + let grow_available = main_cross_vec(direction, share, available_inner_size[cross_axis]); + for idx in std::mem::take(&mut deferred_grow) { + let sized = atoms[idx].as_sized( + ui, + grow_available, + Some(wrap_mode), + fallback_font.clone(), + cache, + ); + measure_into( + &mut slots[idx], + sized, + &mut inner_main, + &mut intrinsic_main, + &mut cross_size, + &mut intrinsic_cross, + ); + } + } + + let mut sized_items: Vec> = slots + .into_iter() + .map(|slot| slot.expect("every atom is measured into its slot")) + .collect(); + // 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 { diff --git a/crates/egui/src/atomics/atom_widget.rs b/crates/egui/src/atomics/atom_widget.rs index 4a0a7c151..669938552 100644 --- a/crates/egui/src/atomics/atom_widget.rs +++ b/crates/egui/src/atomics/atom_widget.rs @@ -1,4 +1,7 @@ -use crate::{Atom, AtomExt, AtomKind, AtomLayout, Atoms, Button, Color32, Context, Id, InnerResponse, IntoAtoms, Layout, Response, Sense, Spacing, Style, Ui, UiBuilder, Visuals, Widget, WidgetRect}; +use crate::{ + Atom, AtomExt, AtomKind, AtomLayout, Atoms, Button, Color32, Context, Id, InnerResponse, + IntoAtoms, Layout, Response, Sense, Spacing, Style, Ui, UiBuilder, Visuals, Widget, WidgetRect, +}; use emath::{Align, Pos2, Rect, Vec2}; use epaint::Direction; @@ -138,7 +141,7 @@ impl<'ui, 'layout> AtomUi<'ui, 'layout> { pub fn add(&mut self, mut config: Atom<'layout>, widget: impl AtomWidget<'layout>) -> Response { let (layout, response) = widget.show_for(self.ctx); - config.kind = AtomKind::Layout(Box::new(layout)); + config.kind = AtomKind::Layout(std::rc::Rc::new(layout)); self.layout.push_right(config); response @@ -156,7 +159,7 @@ impl<'ui, 'layout> AtomUi<'ui, 'layout> { inner, response: child.response(), }; - atom.kind = AtomKind::Layout(Box::new(child.layout)); + atom.kind = AtomKind::Layout(std::rc::Rc::new(child.layout)); self.layout.push_right(atom); response } diff --git a/crates/egui/src/atomics/mod.rs b/crates/egui/src/atomics/mod.rs index aa8919702..60fae4441 100644 --- a/crates/egui/src/atomics/mod.rs +++ b/crates/egui/src/atomics/mod.rs @@ -2,16 +2,16 @@ mod atom; mod atom_ext; mod atom_kind; mod atom_layout; +mod atom_widget; mod atoms; mod sized_atom; mod sized_atom_kind; -mod atom_widget; pub use atom::*; pub use atom_ext::*; pub use atom_kind::*; pub use atom_layout::*; +pub use atom_widget::*; pub use atoms::*; pub use sized_atom::*; pub use sized_atom_kind::*; -pub use atom_widget::*; diff --git a/crates/egui/src/widgets/button.rs b/crates/egui/src/widgets/button.rs index c712e138e..b3d08ebb0 100644 --- a/crates/egui/src/widgets/button.rs +++ b/crates/egui/src/widgets/button.rs @@ -1,6 +1,12 @@ use epaint::Margin; -use crate::{Atom, AtomExt as _, AtomKind, AtomLayout, AtomLayoutResponse, Color32, CornerRadius, Frame, Id, Image, IntoAtoms, NumExt as _, Rect, Response, Sense, Stroke, TextStyle, TextWrapMode, Ui, Vec2, Widget, WidgetInfo, WidgetRect, WidgetText, WidgetType, widget_style::{ButtonStyle, Classes, HasClasses, SELECTED_CLASS, WidgetState}, AtomWidget, impl_widget_for_atom_widget, AtomWidgetContext}; +use crate::{ + Atom, AtomExt as _, AtomKind, AtomLayout, AtomLayoutResponse, AtomWidget, AtomWidgetContext, + Color32, CornerRadius, Frame, Id, Image, IntoAtoms, NumExt as _, Rect, Response, Sense, Stroke, + TextStyle, TextWrapMode, Ui, Vec2, Widget, WidgetInfo, WidgetRect, WidgetText, WidgetType, + impl_widget_for_atom_widget, + widget_style::{ButtonStyle, Classes, HasClasses, SELECTED_CLASS, WidgetState}, +}; /// Clickable button with text. /// @@ -357,9 +363,6 @@ impl<'a> AtomWidget<'a> for Button<'a> { layout = layout.min_size(min_size); - - - if let Some(cursor) = ui.visuals().interact_cursor && response.hovered() { diff --git a/crates/egui_demo_lib/src/demo/atom_layout_demo.rs b/crates/egui_demo_lib/src/demo/atom_layout_demo.rs index edd038349..ae2967b8a 100644 --- a/crates/egui_demo_lib/src/demo/atom_layout_demo.rs +++ b/crates/egui_demo_lib/src/demo/atom_layout_demo.rs @@ -1,6 +1,8 @@ +use std::collections::BTreeSet; + use egui::{ - Align2, Atom, AtomExt as _, AtomLayout, Atoms, Color32, CornerRadius, Direction, Frame, Margin, - RichText, Stroke, TextWrapMode, Vec2, + Align2, AtomExt as _, AtomLayout, AtomUi, Button, Color32, CornerRadius, Direction, Frame, + Margin, RichText, Stroke, TextWrapMode, Vec2, atom, }; // A small palette used to colour-code the cards. @@ -63,14 +65,34 @@ const CARDS: &[Card] = &[ }, ]; -/// A responsive card gallery built entirely out of [`AtomLayout`]s. +/// Colours pulled from the [`egui::Visuals`] up front, so the card builders (which only see an +/// [`AtomUi`]) don't need to reach back into the [`egui::Ui`]. +#[derive(Clone, Copy)] +struct CardTheme { + card_fill: Color32, + card_stroke: Stroke, + chip_fill_base: Color32, +} + +/// A responsive card gallery built with the [`AtomUi`] widget API. /// -/// 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. +/// Think of it as nested flexbox, all assembled by adding widgets to an [`AtomUi`] (never +/// hand-building [`egui::Atoms`]): +/// - a top-level row of [ tag-filter sidebar · card gallery ], +/// - the gallery is a wrapping row of `grow` cards, +/// - each card is a column with a wrapping row of `grow` tags and a footer of real [`Button`]s. +/// +/// Resize the window to watch every level reflow, and click sidebar tags to filter the cards. #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[derive(Default)] -pub struct AtomLayoutDemo {} +pub struct AtomLayoutDemo { + /// Titles of the cards the user has "liked" — proves the footer buttons are real, clickable + /// widgets with their own [`egui::Response`]. + liked: BTreeSet, + + /// Tags selected in the sidebar. A card is shown if this is empty or the card has one of them. + selected_tags: BTreeSet, +} impl crate::Demo for AtomLayoutDemo { fn name(&self) -> &'static str { @@ -91,130 +113,272 @@ impl crate::Demo for AtomLayoutDemo { 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.", + "Nested flexbox built entirely with the AtomUi widget API: a tag-filter sidebar next \ + to a wrapping gallery of grow cards, each a column with a wrapping row of grow tags \ + and a footer of real Button widgets. Resize the window to watch every level reflow.", ); ui.add_space(8.0); + let theme = CardTheme { + card_fill: ui.visuals().faint_bg_color, + card_stroke: ui.visuals().widgets.noninteractive.bg_stroke, + chip_fill_base: ui.visuals().window_fill(), + }; + 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); + let full_width = ui.available_width(); + let gap = 12.0; + // Split the row between sidebar and gallery: the sidebar is locked to this fraction + // of the width (minus the gap) and the `grow` gallery fills the rest. + let sidebar_width = (full_width - gap) / 4.0; + let Self { + liked, + selected_tags, + } = self; + // Top-level flex row: [ sidebar · gallery ]. `min_size` makes the row fill the + // available width so the `grow` gallery expands beside the fixed-width sidebar. + ui.atom_builder( + AtomLayout::new(()) + .gap(gap) + .align2(Align2::LEFT_TOP) + .min_size(Vec2::new(full_width, 0.0)), + |root| { + sidebar(root, theme, selected_tags, sidebar_width); + gallery(root, theme, liked, selected_tags); + }, + ); }); } } -/// 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) +/// The tag-filter sidebar: a header above a wrapping, justified row of tag [`Button`]s — laid out +/// exactly like the in-card tags. Clicking a tag toggles it in `selected_tags`, filtering the +/// gallery. +fn sidebar<'a>( + root: &mut AtomUi<'_, 'a>, + theme: CardTheme, + selected_tags: &mut BTreeSet, + width: f32, +) { + let frame = Frame::new() + .fill(theme.card_fill) + .stroke(theme.card_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())) + root.scope_builder( + AtomLayout::new(()) + .direction(Direction::TopDown) .frame(frame) - .align2(Align2::CENTER_CENTER), - ) + .gap(6.0) + // Stretch the header and tag row to the sidebar's width. + .cross_justify(true) + .align2(Align2::LEFT_TOP) + // Lock the sidebar to `width`; the `grow` gallery fills the rest of the row. + .min_size(Vec2::new(width, 0.0)), + // `min_size` (above) and `atom_max_width` pin the sidebar to `width` exactly: the min stops + // it shrinking and the max stops the wrapping tag row from expanding past it. + atom().atom_max_width(width), + |bar| { + bar.add( + atom().atom_align(Align2::LEFT_CENTER), + AtomLayout::new(RichText::new("Filter by tag").strong()), + ); + + // A wrapping row where each tag button grows to justify the line — just like the tags + // inside each card. + bar.scope_builder( + AtomLayout::new(()) + .wrap(true) + .gap(4.0) + .align2(Align2::LEFT_TOP), + atom(), + |tags| { + for tag in all_tags() { + let selected = selected_tags.contains(tag); + // `grow` spacers either side center the label in the grown button. + let button = + Button::new((atom().atom_grow(true), tag, atom().atom_grow(true))) + .selected(selected); + if tags.add(atom().atom_grow(true), button).clicked() { + if selected { + selected_tags.remove(tag); + } else { + selected_tags.insert(tag.to_owned()); + } + } + } + }, + ); + + // Reset filter. Disabling it when nothing is selected would need enabled-state plumbing + // through AtomUi, so keep it simple and always clear. + if !selected_tags.is_empty() + && bar + .add( + atom(), + Button::new((atom().atom_grow(true), "Clear", atom().atom_grow(true))), + ) + .clicked() + { + selected_tags.clear(); + } + }, + ); } -/// A small filled tag chip. -fn tag(ui: &egui::Ui, accent: Color32, text: &str) -> Atom<'static> { - let bg = ui.visuals().window_fill(); +/// The card gallery: a `grow`, wrapping row of cards filtered by the selected tags. +fn gallery<'a>( + root: &mut AtomUi<'_, 'a>, + theme: CardTheme, + liked: &mut BTreeSet, + selected_tags: &BTreeSet, +) { + root.scope_builder( + AtomLayout::new(()) + .wrap(true) + .gap(12.0) + .align2(Align2::LEFT_TOP), + // `grow` lets the gallery fill the width left over by the sidebar; the core re-measures it + // at that grown width, so the cards inside wrap to fit. + atom().atom_grow(true), + |cards| { + for c in CARDS { + if card_matches(c, selected_tags) { + card(cards, c, theme, liked); + } + } + }, + ); +} + +/// A card is shown when no tag is selected, or it carries at least one of the selected tags. +fn card_matches(c: &Card, selected_tags: &BTreeSet) -> bool { + selected_tags.is_empty() || c.tags.iter().any(|t| selected_tags.contains(*t)) +} + +/// All tags across every card, de-duplicated and sorted (a `BTreeSet` iterates in order). +fn all_tags() -> Vec<&'static str> { + CARDS + .iter() + .flat_map(|c| c.tags.iter().copied()) + .collect::>() + .into_iter() + .collect() +} + +/// One card: a vertical column of [ mock image · title · description · tags · footer ]. The +/// wrapper atom is marked `grow` so cards share each row's width; `atom_max_width` sets its natural +/// (flex-basis) width, and the core re-measures the contents at the grown width so they reflow. +fn card<'a>( + cards: &mut AtomUi<'_, 'a>, + c: &'static Card, + theme: CardTheme, + liked: &mut BTreeSet, +) { + let card_frame = Frame::new() + .fill(theme.card_fill) + .stroke(theme.card_stroke) + .corner_radius(CornerRadius::same(8)) + .inner_margin(Margin::same(8)); + + cards.scope_builder( + AtomLayout::new(()) + .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().atom_grow(true).atom_max_width(230.0), + |col| { + // Mock image: an empty nested layout with a coloured fill. `cross_justify` stretches it + // to the full card width. + col.add( + atom(), + AtomLayout::new(()) + .frame( + Frame::new() + .fill(c.accent.gamma_multiply(0.8)) + .corner_radius(CornerRadius::same(6)), + ) + .min_size(Vec2::new(0.0, 96.0)), + ); + + // Title. + col.add( + atom().atom_align(Align2::LEFT_CENTER), + AtomLayout::new(RichText::new(c.title).strong()), + ); + + // Description. `shrink` lets it wrap to the card width instead of its full text width + // dictating how wide the card has to be; the nested layout needs its own `wrap_mode` + // because nested layouts don't inherit the column's. + col.add( + atom().atom_shrink(true).atom_align(Align2::LEFT_TOP), + AtomLayout::new(RichText::new(c.description).small().weak()) + .wrap_mode(TextWrapMode::Wrap), + ); + + // Tags: a wrapping row where each tag grows to justify the line. + col.scope_builder( + AtomLayout::new(()) + .wrap(true) + .gap(4.0) + .align2(Align2::LEFT_TOP), + atom(), + |tags| { + for t in c.tags { + tags.add(atom().atom_grow(true), tag_chip(theme, c.accent, t)); + } + }, + ); + + // 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). + col.add(atom().atom_grow(true), atom()); + + // Footer: real Like / Share buttons that split the card width. + col.scope_builder(AtomLayout::new(()).gap(6.0), atom(), |footer| { + let is_liked = liked.contains(c.title); + // `grow` spacers on either side of the label center it in the grown button. + let like_text = if is_liked { "♥ Liked" } else { "♡ Like" }; + if footer + .add( + atom().atom_grow(true), + Button::new((atom().atom_grow(true), like_text, atom().atom_grow(true))), + ) + .clicked() + { + if is_liked { + liked.remove(c.title); + } else { + liked.insert(c.title.to_owned()); + } + } + footer.add( + atom().atom_grow(true), + Button::new((atom().atom_grow(true), "↗ Share", atom().atom_grow(true))), + ); + }); + }, + ); +} + +/// A small filled tag chip (decorative, accent-coloured). +fn tag_chip<'a>(theme: CardTheme, accent: Color32, text: &str) -> AtomLayout<'a> { let frame = Frame::new() .inner_margin(Margin::symmetric(6, 1)) .corner_radius(CornerRadius::same(8)) - .fill(bg.lerp_to_gamma(accent, 0.22)) + .fill(theme.chip_fill_base.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), - ) + 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/tests/snapshots/demos/Atom Layout.png b/crates/egui_demo_lib/tests/snapshots/demos/Atom Layout.png index 286c435f6..18b286360 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Atom Layout.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Atom Layout.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:829557a546a642f7ae6808ada89fb02d7062537d0c428ad4e314557d9adb994c -size 78507 +oid sha256:e08edc240db2ea09b5b36f1fa1772a17999248898848651a56c25d135dfa2dea +size 81763 diff --git a/tests/egui_tests/tests/atom_wrap_overflow.rs b/tests/egui_tests/tests/atom_wrap_overflow.rs new file mode 100644 index 000000000..3bddd16de --- /dev/null +++ b/tests/egui_tests/tests/atom_wrap_overflow.rs @@ -0,0 +1,95 @@ +//! Repro for a wrapping bug: a `grow`, wrapping nested [`AtomLayout`] placed next to a fixed-width +//! sibling (think: the atom-layout demo's tag-filter sidebar + card gallery) wraps "too late" and +//! overflows the container. +//! +//! The layout under test is a non-wrapping row of `[ fixed sidebar · grow wrapping gallery ]`, +//! forced to the container width via `min_size`. We render it at a range of shrinking container +//! widths and assert the laid-out width never exceeds the container. + +use egui::{Atom, AtomExt as _, AtomLayout, Atoms, CornerRadius, Frame, Margin, Stroke, Vec2}; +use egui_kittest::Harness; +use std::cell::Cell; + +const SIDEBAR_WIDTH: f32 = 120.0; +const GAP: f32 = 12.0; + +fn chip_frame() -> Frame { + Frame::new() + .inner_margin(Margin::symmetric(6, 2)) + .corner_radius(CornerRadius::same(4)) + .stroke(Stroke::new(1.0, egui::Color32::GRAY)) +} + +/// A single grow chip, like a tag button in the demo. +fn chip(word: &str) -> Atom<'static> { + Atom::layout(AtomLayout::new(word.to_owned()).frame(chip_frame())).atom_grow(true) +} + +/// `[ fixed sidebar · grow wrapping gallery of chips ]`, forced to `width` via `min_size`. +fn root(width: f32) -> AtomLayout<'static> { + let words = [ + "aurora", + "night", + "long-exposure", + "iceland", + "winter", + "jungle", + "macro", + "wildlife", + "humid", + "sahara", + "golden-hour", + "minimal", + ]; + let mut chips = Atoms::default(); + for word in words { + chips.push_right(chip(word)); + } + let gallery = Atom::layout(AtomLayout::new(chips).wrap(true).gap(6.0)).atom_grow(true); + + let sidebar = + Atom::layout(AtomLayout::new("sidebar").frame(chip_frame())).atom_max_width(SIDEBAR_WIDTH); + + AtomLayout::new((sidebar, gallery)) + .gap(GAP) + .min_size(Vec2::new(width, 0.0)) +} + +/// Render `root` at the given container width and return the laid-out (allocated) width. +fn laid_out_width(width: f32) -> f32 { + let measured = Cell::new(0.0_f32); + let mut harness = Harness::builder() + .with_size(Vec2::new(width, 600.0)) + .build_ui(|ui| { + let avail = ui.available_width(); + let response = root(avail).show(ui); + measured.set(response.response.rect.width()); + }); + harness.run(); + measured.get() +} + +#[test] +fn atom_wrap_no_overflow_when_shrinking() { + let mut failures = Vec::new(); + + // Shrink the container step by step. + for width in (260..=820).rev().step_by(40) { + let w = width as f32; + let laid_out = laid_out_width(w); + let overflow = laid_out - w; + println!("container {w:6.1} -> laid out {laid_out:7.1} (overflow {overflow:+.1})"); + // Allow a pixel of rounding slack. + if overflow > 1.0 { + failures.push(format!( + "container {w:.1}: laid out {laid_out:.1} (overflow {overflow:.1}px)" + )); + } + } + + assert!( + failures.is_empty(), + "the wrapping layout overflowed its container:\n{}", + failures.join("\n") + ); +} diff --git a/tests/egui_tests/tests/snapshots/atom_layout_nesting.png b/tests/egui_tests/tests/snapshots/atom_layout_nesting.png index bbff7f706..af40f1130 100644 --- a/tests/egui_tests/tests/snapshots/atom_layout_nesting.png +++ b/tests/egui_tests/tests/snapshots/atom_layout_nesting.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e43b410ab0a06b40da5ad9c7fd105d11201d44a3afd63fd213429cc39013c0d4 +oid sha256:d1aac2317afe29ae297b9082d236432327df4f5210b91b0d5694ae2deec765e1 size 6964