From 080ce81c8b6078d9215959c64409c5bd307a4135 Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Wed, 24 Jun 2026 13:56:54 +0200 Subject: [PATCH] Add `AtomLayout::direction` (#8221) * To be merged after #8219 Adds support for vertical atom layout. Together with #8219 this basically creates a minimal flex layout engine --- crates/egui/src/atomics/atom_layout.rs | 178 +++++++++++++----- .../tests/snapshots/atom_layout_nesting.png | 3 + tests/egui_tests/tests/test_atoms.rs | 48 ++++- 3 files changed, 181 insertions(+), 48 deletions(-) create mode 100644 tests/egui_tests/tests/snapshots/atom_layout_nesting.png diff --git a/crates/egui/src/atomics/atom_layout.rs b/crates/egui/src/atomics/atom_layout.rs index 8614eeb9e..ceb43e048 100644 --- a/crates/egui/src/atomics/atom_layout.rs +++ b/crates/egui/src/atomics/atom_layout.rs @@ -1,6 +1,6 @@ use crate::{ - AtomKind, Atoms, FontSelection, Frame, Id, Image, IntoAtoms, Response, Sense, SizedAtom, - SizedAtomKind, Stroke, Ui, Widget, text_selection::LabelSelectionState, + AtomKind, Atoms, Direction, FontSelection, Frame, Id, Image, IntoAtoms, Response, Sense, + SizedAtom, SizedAtomKind, Stroke, Ui, Widget, text_selection::LabelSelectionState, }; use emath::{Align2, GuiRounding as _, NumExt as _, Rect, Vec2}; use epaint::text::TextWrapMode; @@ -9,6 +9,34 @@ use smallvec::SmallVec; use std::ops::{Deref, DerefMut}; use std::sync::Arc; +/// The `(main, cross)` axis indices for `direction`, for indexing a [`Vec2`] (0 = x, 1 = y). +#[inline] +fn main_cross_axis(direction: Direction) -> (usize, usize) { + let main = usize::from(!direction.is_horizontal()); + (main, 1 - main) +} + +/// Build a [`Vec2`] from `main`/`cross` components for `direction`. +#[inline] +fn main_cross_vec(direction: Direction, main: f32, cross: f32) -> Vec2 { + if direction.is_horizontal() { + Vec2::new(main, cross) + } else { + Vec2::new(cross, main) + } +} + +/// Build a cell [`Rect`] spanning `aligned_rect` fully on the cross axis and `[min_main, max_main]` +/// along the main axis. +#[inline] +fn main_cross_rect(direction: Direction, aligned_rect: Rect, min_main: f32, max_main: f32) -> Rect { + if direction.is_horizontal() { + Rect::from_x_y_ranges(min_main..=max_main, aligned_rect.y_range()) + } else { + Rect::from_x_y_ranges(aligned_rect.x_range(), min_main..=max_main) + } +} + /// Intra-widget layout utility. /// /// Used to lay out and paint [`crate::Atom`]s. @@ -30,7 +58,7 @@ use std::sync::Arc; /// [`AllocatedAtomLayout`] for interaction styling. #[derive(Clone)] pub struct AtomLayout<'a> { - id: Option, + pub(crate) id: Option, pub atoms: Atoms<'a>, gap: Option, pub(crate) frame: Frame, @@ -42,6 +70,7 @@ pub struct AtomLayout<'a> { max_size: Vec2, wrap_mode: Option, align2: Option, + direction: Direction, } impl Default for AtomLayout<'_> { @@ -65,6 +94,7 @@ impl<'a> AtomLayout<'a> { max_size: Vec2::INFINITY, wrap_mode: None, align2: None, + direction: Direction::LeftToRight, } } @@ -187,6 +217,20 @@ impl<'a> AtomLayout<'a> { self } + /// Set the [`Direction`] the [`crate::Atom`]s are laid out along. + /// + /// The default is [`Direction::LeftToRight`] (a horizontal row). Use + /// [`Direction::TopDown`] (or [`Direction::BottomUp`]) to stack atoms vertically. + /// + /// The main axis (the direction) is where `grow`/`shrink` and the gap apply; the cross axis + /// is sized to the largest atom. [`Self::align2`] positions the whole block within the + /// allocated [`Rect`]. + #[inline] + pub fn direction(mut self, direction: Direction) -> Self { + self.direction = direction; + self + } + /// [`AtomLayout::allocate`] and [`AllocatedAtomLayout::paint`] in one go. pub fn show(self, ui: &mut Ui) -> AtomLayoutResponse { self.allocate(ui).paint(ui) @@ -217,6 +261,7 @@ impl<'a> AtomLayout<'a> { wrap_mode, align2, fallback_font, + direction, } = self; let fallback_font = fallback_font.unwrap_or_default(); @@ -258,8 +303,13 @@ impl<'a> AtomLayout<'a> { // 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. - if ui.layout().horizontal_justify() { - max_size.x = f32::INFINITY; + // This only applies along the main axis (the direction we lay atoms out along). + if direction.is_horizontal() { + if ui.layout().horizontal_justify() { + max_size.x = f32::INFINITY; + } + } else if ui.layout().vertical_justify() { + max_size.y = f32::INFINITY; } let available_size = available_size.at_most(max_size).at_least(min_size); @@ -267,14 +317,20 @@ impl<'a> AtomLayout<'a> { // The size available for the content let available_inner_size = available_size - frame.total_margin().sum(); - let mut inner_width = 0.0; + // We work in main/cross axis terms so the same code handles horizontal and vertical + // layouts. For a horizontal `direction`, main = x and cross = y; for vertical it's + // swapped. `grow`/`shrink`/`gap` apply along the main axis; the cross axis is sized to + // the largest atom. `main_axis`/`cross_axis` index into a `Vec2` (0 = x, 1 = y). + let (main_axis, cross_axis) = main_cross_axis(direction); - // intrinsic width / height is the ideal size of the widget, e.g. the size where the + let mut inner_main = 0.0; + + // intrinsic main / cross is the ideal size of the widget, e.g. the size where the // text is not wrapped. Used to set Response::intrinsic_size. - let mut intrinsic_width = 0.0; - let mut intrinsic_height = 0.0; + let mut intrinsic_main = 0.0; + let mut intrinsic_cross: f32 = 0.0; - let mut height: f32 = 0.0; + let mut cross_size: f32 = 0.0; let mut sized_items = Vec::new(); @@ -288,8 +344,8 @@ impl<'a> AtomLayout<'a> { if atoms.len() > 1 { let gap_space = gap * (atoms.len() as f32 - 1.0); - inner_width += gap_space; - intrinsic_width += gap_space; + inner_main += gap_space; + intrinsic_main += gap_space; } for (idx, item) in atoms.into_iter().enumerate() { @@ -314,19 +370,22 @@ impl<'a> AtomLayout<'a> { ); let size = sized.size; - inner_width += size.x; - intrinsic_width += sized.intrinsic_size.x; + inner_main += size[main_axis]; + intrinsic_main += sized.intrinsic_size[main_axis]; - height = height.at_least(size.y); - intrinsic_height = intrinsic_height.at_least(sized.intrinsic_size.y); + cross_size = cross_size.at_least(size[cross_axis]); + intrinsic_cross = intrinsic_cross.at_least(sized.intrinsic_size[cross_axis]); sized_items.push(sized); } if let Some((index, item)) = shrink_item { - // The `shrink` item gets the remaining space - let available_size_for_shrink_item = - Vec2::new(available_inner_size.x - inner_width, available_inner_size.y); + // The `shrink` item gets the remaining space along the main axis. + let available_size_for_shrink_item = main_cross_vec( + direction, + available_inner_size[main_axis] - inner_main, + available_inner_size[cross_axis], + ); let sized = item.into_sized( ui, @@ -336,20 +395,21 @@ impl<'a> AtomLayout<'a> { ); let size = sized.size; - inner_width += size.x; - intrinsic_width += sized.intrinsic_size.x; + inner_main += size[main_axis]; + intrinsic_main += sized.intrinsic_size[main_axis]; - height = height.at_least(size.y); - intrinsic_height = intrinsic_height.at_least(sized.intrinsic_size.y); + 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); } let margin = frame.total_margin(); - let inner_size = Vec2::new(inner_width, height); + let inner_size = main_cross_vec(direction, inner_main, cross_size); let outer_size = (inner_size + margin.sum()).at_least(min_size); - let intrinsic_size = - (Vec2::new(intrinsic_width, intrinsic_height) + margin.sum()).at_least(min_size); + let intrinsic_size = (main_cross_vec(direction, intrinsic_main, intrinsic_cross) + + margin.sum()) + .at_least(min_size); SizedAtomLayout { sized_atoms: sized_items, @@ -363,6 +423,7 @@ impl<'a> AtomLayout<'a> { inner_size, align2, gap, + direction, selectable, } } @@ -423,6 +484,10 @@ pub struct SizedAtomLayout<'a> { /// The gap between each [`crate::Atom`] gap: f32, + + /// The axis the atoms are laid out along. The main axis carries `grow`/`shrink`/`gap`. + direction: Direction, + selectable: bool, } @@ -519,13 +584,14 @@ 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 { - sized_atoms, + mut sized_atoms, frame, fallback_text_color, grow_count, inner_size, align2, gap, + direction, selectable, .. } = self; @@ -534,17 +600,32 @@ impl<'atom> SizedAtomLayout<'atom> { ui.painter().add(frame.paint(inner_rect)); - let width_to_fill = inner_rect.width(); - let extra_space = f32::max(width_to_fill - inner_size.x, 0.0); - let grow_width = f32::max(extra_space / grow_count as f32, 0.0).floor_ui(); + let (main_axis, cross_axis) = main_cross_axis(direction); - let aligned_rect = if grow_count > 0 { - align2.align_size_within_rect(Vec2::new(width_to_fill, inner_size.y), inner_rect) + // 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 } else { - align2.align_size_within_rect(inner_size, inner_rect) + inner_main }; + 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 mut cursor = aligned_rect.left(); + // 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(); + } + + // 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]; let mut response = AtomLayoutResponse::empty(response); @@ -552,20 +633,21 @@ impl<'atom> SizedAtomLayout<'atom> { 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_width } else { 0.0 }; + let growth = if sized.is_grow() { grow_main } else { 0.0 }; - let frame = aligned_rect - .with_min_x(cursor) - .with_max_x(cursor + size.x + growth); - cursor = frame.right() + gap; - let rect = sized.align.align_size_within_rect(size, frame); + let atom_main = size[main_axis] + growth; + + // The cell spans the cross axis fully and `atom_main` along the main axis. + let cell = main_cross_rect(direction, aligned_rect, cursor, cursor + atom_main); + 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, rect)); + response.custom_rects.push((id, item_rect)); } match sized.kind { @@ -577,23 +659,25 @@ impl<'atom> SizedAtomLayout<'atom> { LabelSelectionState::label_text_selection( ui, &response.response, - rect.min, + item_rect.min, galley, fallback_text_color, Stroke::NONE, ); } else { - ui.painter().galley(rect.min, galley, fallback_text_color); + ui.painter() + .galley(item_rect.min, galley, fallback_text_color); } } SizedAtomKind::Image { image, size: _ } => { - image.paint_at(ui, rect); + image.paint_at(ui, item_rect); } SizedAtomKind::Empty { .. } => {} SizedAtomKind::Layout(layout) => { - // TODO(lucasmerlin): Add some kind of justify flag to AtomLayout - let layout_response = ui.interact(frame, layout.id, layout.sense); - layout.paint_at(ui, frame, layout_response); + // 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); } } } diff --git a/tests/egui_tests/tests/snapshots/atom_layout_nesting.png b/tests/egui_tests/tests/snapshots/atom_layout_nesting.png new file mode 100644 index 000000000..bbff7f706 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/atom_layout_nesting.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e43b410ab0a06b40da5ad9c7fd105d11201d44a3afd63fd213429cc39013c0d4 +size 6964 diff --git a/tests/egui_tests/tests/test_atoms.rs b/tests/egui_tests/tests/test_atoms.rs index cb0d89619..48babfbd7 100644 --- a/tests/egui_tests/tests/test_atoms.rs +++ b/tests/egui_tests/tests/test_atoms.rs @@ -1,4 +1,6 @@ -use egui::{Align, AtomExt as _, Button, Layout, TextWrapMode, Ui, Vec2}; +use egui::{ + Align, Atom, AtomExt as _, AtomLayout, Button, Direction, Frame, Layout, TextWrapMode, Ui, Vec2, +}; use egui_kittest::{HarnessBuilder, SnapshotResult, SnapshotResults}; #[test] @@ -120,6 +122,50 @@ fn test_button_shortcut_text() { harness.snapshot("button_shortcut"); } +/// Test atom nesting and [`egui::AtomLayout::direction`]. +#[test] +fn test_atom_layout_nesting_and_direction() { + let mut harness = HarnessBuilder::default().build_ui(|ui| { + let style = ui.style(); + let canvas_frame = Frame::canvas(style); + + let button_frame = style + .button_style( + &egui::widget_style::Classes::default(), + egui::widget_style::WidgetState::Inactive, + ) + .frame; + + let row = |direction: Direction| { + Atom::layout( + AtomLayout::new(("one", "two", "three")) + .direction(direction) + .frame(button_frame), + ) + }; + + AtomLayout::new(( + Atom::layout( + AtomLayout::new(( + row(Direction::LeftToRight).atom_grow(true), + row(Direction::RightToLeft).atom_grow(true), + )) + .direction(Direction::TopDown), + ) + .atom_grow(true), + row(Direction::TopDown), + row(Direction::BottomUp), + )) + .direction(Direction::LeftToRight) + .frame(canvas_frame) + .show(ui); + }); + + harness.fit_contents(); + + harness.snapshot("atom_layout_nesting"); +} + /// Tests the spacing between galleys. /// All of these should look the same. #[test]