Add `Galley::intrinsic_size` and use it in `AtomLayout` (#7146)

- part of https://github.com/emilk/egui/issues/5762
- also allows me to simplify sizing logic in egui_flex
This commit is contained in:
Lucas Meurer 2025-07-09 08:19:04 +02:00 committed by GitHub
parent f46926aaf1
commit 508c60b2e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 166 additions and 19 deletions

View File

@ -81,7 +81,7 @@ impl<'a> Atom<'a> {
wrap_mode = Some(TextWrapMode::Truncate);
}
let (preferred, kind) = self.kind.into_sized(ui, available_size, wrap_mode);
let (intrinsic, kind) = self.kind.into_sized(ui, available_size, wrap_mode);
let size = self
.size
@ -89,7 +89,7 @@ impl<'a> Atom<'a> {
SizedAtom {
size,
preferred_size: preferred,
intrinsic_size: intrinsic.at_least(self.size.unwrap_or_default()),
grow: self.grow,
kind,
}

View File

@ -81,11 +81,10 @@ impl<'a> AtomKind<'a> {
) -> (Vec2, SizedAtomKind<'a>) {
match self {
AtomKind::Text(text) => {
let galley = text.into_galley(ui, wrap_mode, available_size.x, TextStyle::Button);
(
galley.size(), // TODO(#5762): calculate the preferred size
SizedAtomKind::Text(galley),
)
let wrap_mode = wrap_mode.unwrap_or(ui.wrap_mode());
let galley =
text.into_galley(ui, Some(wrap_mode), available_size.x, TextStyle::Button);
(galley.intrinsic_size, SizedAtomKind::Text(galley))
}
AtomKind::Image(image) => {
let size = image.load_and_calc_size(ui, available_size);

View File

@ -183,10 +183,10 @@ impl<'a> AtomLayout<'a> {
let mut desired_width = 0.0;
// Preferred width / height is the ideal size of the widget, e.g. the size where the
// intrinsic width / height 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 preferred_width = 0.0;
let mut preferred_height = 0.0;
let mut intrinsic_width = 0.0;
let mut intrinsic_height = 0.0;
let mut height: f32 = 0.0;
@ -203,7 +203,7 @@ impl<'a> AtomLayout<'a> {
if atoms.len() > 1 {
let gap_space = gap * (atoms.len() as f32 - 1.0);
desired_width += gap_space;
preferred_width += gap_space;
intrinsic_width += gap_space;
}
for (idx, item) in atoms.into_iter().enumerate() {
@ -224,10 +224,10 @@ impl<'a> AtomLayout<'a> {
let size = sized.size;
desired_width += size.x;
preferred_width += sized.preferred_size.x;
intrinsic_width += sized.intrinsic_size.x;
height = height.at_least(size.y);
preferred_height = preferred_height.at_least(sized.preferred_size.y);
intrinsic_height = intrinsic_height.at_least(sized.intrinsic_size.y);
sized_items.push(sized);
}
@ -243,10 +243,10 @@ impl<'a> AtomLayout<'a> {
let size = sized.size;
desired_width += size.x;
preferred_width += sized.preferred_size.x;
intrinsic_width += sized.intrinsic_size.x;
height = height.at_least(size.y);
preferred_height = preferred_height.at_least(sized.preferred_size.y);
intrinsic_height = intrinsic_height.at_least(sized.intrinsic_size.y);
sized_items.insert(index, sized);
}
@ -259,7 +259,7 @@ impl<'a> AtomLayout<'a> {
let mut response = ui.interact(rect, id, sense);
response.intrinsic_size =
Some((Vec2::new(preferred_width, preferred_height) + margin.sum()).at_least(min_size));
Some((Vec2::new(intrinsic_width, intrinsic_height) + margin.sum()).at_least(min_size));
AllocatedAtomLayout {
sized_atoms: sized_items,

View File

@ -12,8 +12,8 @@ pub struct SizedAtom<'a> {
/// size.x + gap.
pub size: Vec2,
/// Preferred size of the atom. This is used to calculate `Response::intrinsic_size`.
pub preferred_size: Vec2,
/// Intrinsic size of the atom. This is used to calculate `Response::intrinsic_size`.
pub intrinsic_size: Vec2,
pub kind: SizedAtomKind<'a>,
}

View File

@ -130,10 +130,12 @@ impl TextShape {
num_vertices: _,
num_indices: _,
pixels_per_point: _,
intrinsic_size,
} = Arc::make_mut(galley);
*rect = transform.scaling * *rect;
*mesh_bounds = transform.scaling * *mesh_bounds;
*intrinsic_size = transform.scaling * *intrinsic_size;
for text::PlacedRow { pos, row } in rows {
*pos *= transform.scaling;

View File

@ -1072,6 +1072,7 @@ mod tests {
use core::f32;
use super::*;
use crate::text::{TextWrapping, layout};
use crate::{Stroke, text::TextFormat};
use ecolor::Color32;
use emath::Align;
@ -1183,4 +1184,60 @@ mod tests {
}
}
}
#[test]
fn test_intrinsic_size() {
let pixels_per_point = [1.0, 1.3, 2.0, 0.867];
let max_widths = [40.0, 80.0, 133.0, 200.0];
let rounded_output_to_gui = [false, true];
for pixels_per_point in pixels_per_point {
let mut fonts = FontsImpl::new(
pixels_per_point,
1024,
AlphaFromCoverage::default(),
FontDefinitions::default(),
);
for &max_width in &max_widths {
for round_output_to_gui in rounded_output_to_gui {
for mut job in jobs() {
job.wrap = TextWrapping::wrap_at_width(max_width);
job.round_output_to_gui = round_output_to_gui;
let galley_wrapped = layout(&mut fonts, job.clone().into());
job.wrap = TextWrapping::no_max_width();
let text = job.text.clone();
let galley_unwrapped = layout(&mut fonts, job.into());
let intrinsic_size = galley_wrapped.intrinsic_size;
let unwrapped_size = galley_unwrapped.size();
let difference = (intrinsic_size - unwrapped_size).length().abs();
similar_asserts::assert_eq!(
format!("{intrinsic_size:.4?}"),
format!("{unwrapped_size:.4?}"),
"Wrapped intrinsic size should almost match unwrapped size. Intrinsic: {intrinsic_size:.8?} vs unwrapped: {unwrapped_size:.8?}
Difference: {difference:.8?}
wrapped rows: {}, unwrapped rows: {}
pixels_per_point: {pixels_per_point}, text: {text:?}, max_width: {max_width}, round_output_to_gui: {round_output_to_gui}",
galley_wrapped.rows.len(),
galley_unwrapped.rows.len()
);
similar_asserts::assert_eq!(
format!("{intrinsic_size:.4?}"),
format!("{unwrapped_size:.4?}"),
"Unwrapped galley intrinsic size should exactly match its size. \
{:.8?} vs {:8?}",
galley_unwrapped.intrinsic_size,
galley_unwrapped.size(),
);
}
}
}
}
}
}

View File

@ -82,6 +82,7 @@ pub fn layout(fonts: &mut FontsImpl, job: Arc<LayoutJob>) -> Galley {
num_indices: 0,
pixels_per_point: fonts.pixels_per_point(),
elided: true,
intrinsic_size: Vec2::ZERO,
};
}
@ -94,6 +95,8 @@ pub fn layout(fonts: &mut FontsImpl, job: Arc<LayoutJob>) -> Galley {
let point_scale = PointScale::new(fonts.pixels_per_point());
let intrinsic_size = calculate_intrinsic_size(point_scale, &job, &paragraphs);
let mut elided = false;
let mut rows = rows_from_paragraphs(paragraphs, &job, &mut elided);
if elided {
@ -124,7 +127,7 @@ pub fn layout(fonts: &mut FontsImpl, job: Arc<LayoutJob>) -> Galley {
}
// Calculate the Y positions and tessellate the text:
galley_from_rows(point_scale, job, rows, elided)
galley_from_rows(point_scale, job, rows, elided, intrinsic_size)
}
// Ignores the Y coordinate.
@ -190,6 +193,46 @@ fn layout_section(
}
}
/// Calculate the intrinsic size of the text.
///
/// The result is eventually passed to `Response::intrinsic_size`.
/// This works by calculating the size of each `Paragraph` (instead of each `Row`).
fn calculate_intrinsic_size(
point_scale: PointScale,
job: &LayoutJob,
paragraphs: &[Paragraph],
) -> Vec2 {
let mut intrinsic_size = Vec2::ZERO;
for (idx, paragraph) in paragraphs.iter().enumerate() {
if paragraph.glyphs.is_empty() {
if idx == 0 {
intrinsic_size.y += point_scale.round_to_pixel(paragraph.empty_paragraph_height);
}
continue;
}
intrinsic_size.x = f32::max(
paragraph
.glyphs
.last()
.map(|l| l.max_x())
.unwrap_or_default(),
intrinsic_size.x,
);
let mut height = paragraph
.glyphs
.iter()
.map(|g| g.line_height)
.max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or(paragraph.empty_paragraph_height);
if idx == 0 {
height = f32::max(height, job.first_row_min_height);
}
intrinsic_size.y += point_scale.round_to_pixel(height);
}
intrinsic_size
}
// Ignores the Y coordinate.
fn rows_from_paragraphs(
paragraphs: Vec<Paragraph>,
@ -610,6 +653,7 @@ fn galley_from_rows(
job: Arc<LayoutJob>,
mut rows: Vec<PlacedRow>,
elided: bool,
intrinsic_size: Vec2,
) -> Galley {
let mut first_row_min_height = job.first_row_min_height;
let mut cursor_y = 0.0;
@ -680,6 +724,7 @@ fn galley_from_rows(
num_vertices,
num_indices,
pixels_per_point: point_scale.pixels_per_point,
intrinsic_size,
};
if galley.job.round_output_to_gui {

View File

@ -560,6 +560,12 @@ pub struct Galley {
/// so that we can warn if this has changed once we get to
/// tessellation.
pub pixels_per_point: f32,
/// This is the size that a non-wrapped, non-truncated, non-justified version of the text
/// would have.
///
/// Useful for advanced layouting.
pub intrinsic_size: Vec2,
}
#[derive(Clone, Debug, PartialEq)]
@ -821,6 +827,8 @@ impl Galley {
.at_most(rect.min.x + self.job.wrap.max_width)
.floor_ui();
}
self.intrinsic_size = self.intrinsic_size.round_ui();
}
/// Append each galley under the previous one.
@ -836,6 +844,7 @@ impl Galley {
num_vertices: 0,
num_indices: 0,
pixels_per_point,
intrinsic_size: Vec2::ZERO,
};
for (i, galley) in galleys.iter().enumerate() {
@ -872,6 +881,9 @@ impl Galley {
// Note that if `galley.elided` is true this will be the last `Galley` in
// the vector and the loop will end.
merged_galley.elided |= galley.elided;
merged_galley.intrinsic_size.x =
f32::max(merged_galley.intrinsic_size.x, galley.intrinsic_size.x);
merged_galley.intrinsic_size.y += galley.intrinsic_size.y;
}
if merged_galley.job.round_output_to_gui {

View File

@ -69,3 +69,35 @@ fn single_test(name: &str, mut f: impl FnMut(&mut Ui)) -> SnapshotResult {
harness.try_snapshot(name)
}
#[test]
fn test_intrinsic_size() {
let mut intrinsic_size = None;
for wrapping in [
TextWrapMode::Extend,
TextWrapMode::Wrap,
TextWrapMode::Truncate,
] {
_ = HarnessBuilder::default()
.with_size(Vec2::new(100.0, 100.0))
.build_ui(|ui| {
ui.style_mut().wrap_mode = Some(wrapping);
let response = ui.add(Button::new(
"Hello world this is a long text that should be wrapped.",
));
if let Some(current_intrinsic_size) = intrinsic_size {
assert_eq!(
Some(current_intrinsic_size),
response.intrinsic_size,
"For wrapping: {wrapping:?}"
);
}
assert!(
response.intrinsic_size.is_some(),
"intrinsic_size should be set for `Button`"
);
intrinsic_size = response.intrinsic_size;
});
}
assert_eq!(intrinsic_size.unwrap().round(), Vec2::new(305.0, 18.0));
}