mirror of
https://github.com/emilk/egui.git
synced 2026-06-26 14:49:06 -04:00
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
This commit is contained in:
@@ -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<Id>,
|
||||
pub(crate) id: Option<Id>,
|
||||
pub atoms: Atoms<'a>,
|
||||
gap: Option<f32>,
|
||||
pub(crate) frame: Frame,
|
||||
@@ -42,6 +70,7 @@ pub struct AtomLayout<'a> {
|
||||
max_size: Vec2,
|
||||
wrap_mode: Option<TextWrapMode>,
|
||||
align2: Option<Align2>,
|
||||
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,23 +303,34 @@ 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.
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3
tests/egui_tests/tests/snapshots/atom_layout_nesting.png
Normal file
3
tests/egui_tests/tests/snapshots/atom_layout_nesting.png
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e43b410ab0a06b40da5ad9c7fd105d11201d44a3afd63fd213429cc39013c0d4
|
||||
size 6964
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user