mirror of
https://github.com/emilk/egui.git
synced 2026-06-26 14:49:06 -04:00
Combined example
This commit is contained in:
@@ -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<Option<SizedAtom<'a>>> =
|
||||
std::iter::repeat_with(|| None).take(atoms.len()).collect();
|
||||
let mut deferred_grow: Vec<usize> = Vec::new();
|
||||
|
||||
let measure_into = |slot: &mut Option<SizedAtom<'a>>,
|
||||
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<SizedAtom<'a>> = 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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// Tags selected in the sidebar. A card is shown if this is empty or the card has one of them.
|
||||
selected_tags: BTreeSet<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
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<String>,
|
||||
selected_tags: &BTreeSet<String>,
|
||||
) {
|
||||
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<String>) -> 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::<BTreeSet<_>>()
|
||||
.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<String>,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:829557a546a642f7ae6808ada89fb02d7062537d0c428ad4e314557d9adb994c
|
||||
size 78507
|
||||
oid sha256:e08edc240db2ea09b5b36f1fa1772a17999248898848651a56c25d135dfa2dea
|
||||
size 81763
|
||||
|
||||
95
tests/egui_tests/tests/atom_wrap_overflow.rs
Normal file
95
tests/egui_tests/tests/atom_wrap_overflow.rs
Normal file
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e43b410ab0a06b40da5ad9c7fd105d11201d44a3afd63fd213429cc39013c0d4
|
||||
oid sha256:d1aac2317afe29ae297b9082d236432327df4f5210b91b0d5694ae2deec765e1
|
||||
size 6964
|
||||
|
||||
Reference in New Issue
Block a user