mirror of
https://github.com/emilk/egui.git
synced 2026-06-26 22:53:14 -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 cross_size: f32 = 0.0;
|
||||||
|
|
||||||
let mut sized_items = Vec::new();
|
|
||||||
|
|
||||||
let mut grow_count = 0;
|
let mut grow_count = 0;
|
||||||
|
|
||||||
let mut shrink_item = None;
|
let mut shrink_item = None;
|
||||||
@@ -470,6 +468,35 @@ impl<'a> AtomLayout<'a> {
|
|||||||
intrinsic_main += gap_space;
|
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() {
|
for (idx, item) in atoms.iter().enumerate() {
|
||||||
if item.grow {
|
if item.grow {
|
||||||
grow_count += 1;
|
grow_count += 1;
|
||||||
@@ -487,6 +514,10 @@ impl<'a> AtomLayout<'a> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if defer_grow(item) {
|
||||||
|
deferred_grow.push(idx);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let sized = item.as_sized(
|
let sized = item.as_sized(
|
||||||
ui,
|
ui,
|
||||||
available_inner_size,
|
available_inner_size,
|
||||||
@@ -494,15 +525,14 @@ impl<'a> AtomLayout<'a> {
|
|||||||
fallback_font.clone(),
|
fallback_font.clone(),
|
||||||
cache,
|
cache,
|
||||||
);
|
);
|
||||||
let size = sized.size;
|
measure_into(
|
||||||
|
&mut slots[idx],
|
||||||
inner_main += size[main_axis];
|
sized,
|
||||||
intrinsic_main += sized.intrinsic_size[main_axis];
|
&mut inner_main,
|
||||||
|
&mut intrinsic_main,
|
||||||
cross_size = cross_size.at_least(size[cross_axis]);
|
&mut cross_size,
|
||||||
intrinsic_cross = intrinsic_cross.at_least(sized.intrinsic_size[cross_axis]);
|
&mut intrinsic_cross,
|
||||||
|
);
|
||||||
sized_items.push(sized);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((index, item)) = shrink_item {
|
if let Some((index, item)) = shrink_item {
|
||||||
@@ -521,7 +551,7 @@ impl<'a> AtomLayout<'a> {
|
|||||||
ui,
|
ui,
|
||||||
available_size_for_shrink_item,
|
available_size_for_shrink_item,
|
||||||
Some(wrap_mode),
|
Some(wrap_mode),
|
||||||
fallback_font,
|
fallback_font.clone(),
|
||||||
cache,
|
cache,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -531,21 +561,51 @@ impl<'a> AtomLayout<'a> {
|
|||||||
ui,
|
ui,
|
||||||
available_size_for_shrink_item,
|
available_size_for_shrink_item,
|
||||||
Some(wrap_mode),
|
Some(wrap_mode),
|
||||||
fallback_font,
|
fallback_font.clone(),
|
||||||
cache,
|
cache,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let size = sized.size;
|
measure_into(
|
||||||
|
&mut slots[index],
|
||||||
inner_main += size[main_axis];
|
sized,
|
||||||
intrinsic_main += sized.intrinsic_size[main_axis];
|
&mut inner_main,
|
||||||
|
&mut intrinsic_main,
|
||||||
cross_size = cross_size.at_least(size[cross_axis]);
|
&mut cross_size,
|
||||||
intrinsic_cross = intrinsic_cross.at_least(sized.intrinsic_size[cross_axis]);
|
&mut intrinsic_cross,
|
||||||
|
);
|
||||||
sized_items.insert(index, sized);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Group the (flat) sized atoms into lines. Without wrapping that's a single line
|
||||||
// spanning everything, which reproduces the previous single-line behavior exactly.
|
// spanning everything, which reproduces the previous single-line behavior exactly.
|
||||||
let mut lines = if wrap {
|
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 emath::{Align, Pos2, Rect, Vec2};
|
||||||
use epaint::Direction;
|
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 {
|
pub fn add(&mut self, mut config: Atom<'layout>, widget: impl AtomWidget<'layout>) -> Response {
|
||||||
let (layout, response) = widget.show_for(self.ctx);
|
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);
|
self.layout.push_right(config);
|
||||||
|
|
||||||
response
|
response
|
||||||
@@ -156,7 +159,7 @@ impl<'ui, 'layout> AtomUi<'ui, 'layout> {
|
|||||||
inner,
|
inner,
|
||||||
response: child.response(),
|
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);
|
self.layout.push_right(atom);
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,16 @@ mod atom;
|
|||||||
mod atom_ext;
|
mod atom_ext;
|
||||||
mod atom_kind;
|
mod atom_kind;
|
||||||
mod atom_layout;
|
mod atom_layout;
|
||||||
|
mod atom_widget;
|
||||||
mod atoms;
|
mod atoms;
|
||||||
mod sized_atom;
|
mod sized_atom;
|
||||||
mod sized_atom_kind;
|
mod sized_atom_kind;
|
||||||
mod atom_widget;
|
|
||||||
|
|
||||||
pub use atom::*;
|
pub use atom::*;
|
||||||
pub use atom_ext::*;
|
pub use atom_ext::*;
|
||||||
pub use atom_kind::*;
|
pub use atom_kind::*;
|
||||||
pub use atom_layout::*;
|
pub use atom_layout::*;
|
||||||
|
pub use atom_widget::*;
|
||||||
pub use atoms::*;
|
pub use atoms::*;
|
||||||
pub use sized_atom::*;
|
pub use sized_atom::*;
|
||||||
pub use sized_atom_kind::*;
|
pub use sized_atom_kind::*;
|
||||||
pub use atom_widget::*;
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
use epaint::Margin;
|
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.
|
/// Clickable button with text.
|
||||||
///
|
///
|
||||||
@@ -357,9 +363,6 @@ impl<'a> AtomWidget<'a> for Button<'a> {
|
|||||||
|
|
||||||
layout = layout.min_size(min_size);
|
layout = layout.min_size(min_size);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if let Some(cursor) = ui.visuals().interact_cursor
|
if let Some(cursor) = ui.visuals().interact_cursor
|
||||||
&& response.hovered()
|
&& response.hovered()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use egui::{
|
use egui::{
|
||||||
Align2, Atom, AtomExt as _, AtomLayout, Atoms, Color32, CornerRadius, Direction, Frame, Margin,
|
Align2, AtomExt as _, AtomLayout, AtomUi, Button, Color32, CornerRadius, Direction, Frame,
|
||||||
RichText, Stroke, TextWrapMode, Vec2,
|
Margin, RichText, Stroke, TextWrapMode, Vec2, atom,
|
||||||
};
|
};
|
||||||
|
|
||||||
// A small palette used to colour-code the cards.
|
// 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
|
/// Think of it as nested flexbox, all assembled by adding widgets to an [`AtomUi`] (never
|
||||||
/// a column holds a mock image, a title, a description and a wrapping row of `grow` tags. Resize
|
/// hand-building [`egui::Atoms`]):
|
||||||
/// the window to watch the cards and tags reflow.
|
/// - 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))]
|
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
||||||
#[derive(Default)]
|
#[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 {
|
impl crate::Demo for AtomLayoutDemo {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
@@ -91,130 +113,272 @@ impl crate::Demo for AtomLayoutDemo {
|
|||||||
impl crate::View for AtomLayoutDemo {
|
impl crate::View for AtomLayoutDemo {
|
||||||
fn ui(&mut self, ui: &mut egui::Ui) {
|
fn ui(&mut self, ui: &mut egui::Ui) {
|
||||||
ui.small(
|
ui.small(
|
||||||
"A responsive card gallery — one wrapping AtomLayout of grow cards, each itself a \
|
"Nested flexbox built entirely with the AtomUi widget API: a tag-filter sidebar next \
|
||||||
column with a wrapping row of grow tags. Resize the window to watch it reflow.",
|
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);
|
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()
|
egui::ScrollArea::vertical()
|
||||||
.id_salt("cards_scroll")
|
.id_salt("cards_scroll")
|
||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
let mut cards = Atoms::default();
|
let full_width = ui.available_width();
|
||||||
for c in CARDS {
|
let gap = 12.0;
|
||||||
cards.push_right(card(ui, c));
|
// 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.
|
||||||
AtomLayout::new(cards)
|
let sidebar_width = (full_width - gap) / 4.0;
|
||||||
.wrap(true)
|
let Self {
|
||||||
.gap(12.0)
|
liked,
|
||||||
.align2(Align2::LEFT_TOP)
|
selected_tags,
|
||||||
// Fill the available width so the `grow` cards stretch to share each row.
|
} = self;
|
||||||
.min_size(Vec2::new(ui.available_width(), 0.0))
|
// Top-level flex row: [ sidebar · gallery ]. `min_size` makes the row fill the
|
||||||
.show(ui);
|
// 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
|
/// The tag-filter sidebar: a header above a wrapping, justified row of tag [`Button`]s — laid out
|
||||||
/// `grow` so cards share each row's width; the contents re-wrap to the grown width automatically.
|
/// exactly like the in-card tags. Clicking a tag toggles it in `selected_tags`, filtering the
|
||||||
fn card(ui: &egui::Ui, card: &Card) -> Atom<'static> {
|
/// gallery.
|
||||||
// Mock image: an empty layout with a coloured fill. It's a nested layout, so it stretches to
|
fn sidebar<'a>(
|
||||||
// the full card width.
|
root: &mut AtomUi<'_, 'a>,
|
||||||
let image = Atom::layout(
|
theme: CardTheme,
|
||||||
AtomLayout::new(())
|
selected_tags: &mut BTreeSet<String>,
|
||||||
.frame(
|
width: f32,
|
||||||
Frame::new()
|
) {
|
||||||
.fill(card.accent.gamma_multiply(0.8))
|
let frame = Frame::new()
|
||||||
.corner_radius(CornerRadius::same(6)),
|
.fill(theme.card_fill)
|
||||||
)
|
.stroke(theme.card_stroke)
|
||||||
.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))
|
.corner_radius(CornerRadius::same(8))
|
||||||
.inner_margin(Margin::same(8));
|
.inner_margin(Margin::same(8));
|
||||||
|
|
||||||
let column = AtomLayout::new((
|
root.scope_builder(
|
||||||
image,
|
AtomLayout::new(())
|
||||||
RichText::new(card.title)
|
.direction(Direction::TopDown)
|
||||||
.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)
|
.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.
|
/// The card gallery: a `grow`, wrapping row of cards filtered by the selected tags.
|
||||||
fn tag(ui: &egui::Ui, accent: Color32, text: &str) -> Atom<'static> {
|
fn gallery<'a>(
|
||||||
let bg = ui.visuals().window_fill();
|
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()
|
let frame = Frame::new()
|
||||||
.inner_margin(Margin::symmetric(6, 1))
|
.inner_margin(Margin::symmetric(6, 1))
|
||||||
.corner_radius(CornerRadius::same(8))
|
.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)));
|
.stroke(Stroke::new(1.0, accent.gamma_multiply(0.6)));
|
||||||
Atom::layout(
|
AtomLayout::new(RichText::new(text.to_owned()).small().color(accent))
|
||||||
AtomLayout::new(RichText::new(text.to_owned()).small().color(accent))
|
.frame(frame)
|
||||||
.frame(frame)
|
.align2(Align2::CENTER_CENTER)
|
||||||
.align2(Align2::CENTER_CENTER)
|
.gap(0.0)
|
||||||
.gap(0.0),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:829557a546a642f7ae6808ada89fb02d7062537d0c428ad4e314557d9adb994c
|
oid sha256:e08edc240db2ea09b5b36f1fa1772a17999248898848651a56c25d135dfa2dea
|
||||||
size 78507
|
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
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:e43b410ab0a06b40da5ad9c7fd105d11201d44a3afd63fd213429cc39013c0d4
|
oid sha256:d1aac2317afe29ae297b9082d236432327df4f5210b91b0d5694ae2deec765e1
|
||||||
size 6964
|
size 6964
|
||||||
|
|||||||
Reference in New Issue
Block a user