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); 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 let size = self
.size .size
@ -89,7 +89,7 @@ impl<'a> Atom<'a> {
SizedAtom { SizedAtom {
size, size,
preferred_size: preferred, intrinsic_size: intrinsic.at_least(self.size.unwrap_or_default()),
grow: self.grow, grow: self.grow,
kind, kind,
} }

View File

@ -81,11 +81,10 @@ impl<'a> AtomKind<'a> {
) -> (Vec2, SizedAtomKind<'a>) { ) -> (Vec2, SizedAtomKind<'a>) {
match self { match self {
AtomKind::Text(text) => { AtomKind::Text(text) => {
let galley = text.into_galley(ui, wrap_mode, available_size.x, TextStyle::Button); let wrap_mode = wrap_mode.unwrap_or(ui.wrap_mode());
( let galley =
galley.size(), // TODO(#5762): calculate the preferred size text.into_galley(ui, Some(wrap_mode), available_size.x, TextStyle::Button);
SizedAtomKind::Text(galley), (galley.intrinsic_size, SizedAtomKind::Text(galley))
)
} }
AtomKind::Image(image) => { AtomKind::Image(image) => {
let size = image.load_and_calc_size(ui, available_size); 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; 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. // text is not wrapped. Used to set Response::intrinsic_size.
let mut preferred_width = 0.0; let mut intrinsic_width = 0.0;
let mut preferred_height = 0.0; let mut intrinsic_height = 0.0;
let mut height: f32 = 0.0; let mut height: f32 = 0.0;
@ -203,7 +203,7 @@ impl<'a> AtomLayout<'a> {
if atoms.len() > 1 { if atoms.len() > 1 {
let gap_space = gap * (atoms.len() as f32 - 1.0); let gap_space = gap * (atoms.len() as f32 - 1.0);
desired_width += gap_space; desired_width += gap_space;
preferred_width += gap_space; intrinsic_width += gap_space;
} }
for (idx, item) in atoms.into_iter().enumerate() { for (idx, item) in atoms.into_iter().enumerate() {
@ -224,10 +224,10 @@ impl<'a> AtomLayout<'a> {
let size = sized.size; let size = sized.size;
desired_width += size.x; desired_width += size.x;
preferred_width += sized.preferred_size.x; intrinsic_width += sized.intrinsic_size.x;
height = height.at_least(size.y); 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); sized_items.push(sized);
} }
@ -243,10 +243,10 @@ impl<'a> AtomLayout<'a> {
let size = sized.size; let size = sized.size;
desired_width += size.x; desired_width += size.x;
preferred_width += sized.preferred_size.x; intrinsic_width += sized.intrinsic_size.x;
height = height.at_least(size.y); 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); sized_items.insert(index, sized);
} }
@ -259,7 +259,7 @@ impl<'a> AtomLayout<'a> {
let mut response = ui.interact(rect, id, sense); let mut response = ui.interact(rect, id, sense);
response.intrinsic_size = 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 { AllocatedAtomLayout {
sized_atoms: sized_items, sized_atoms: sized_items,

View File

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

View File

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

View File

@ -1072,6 +1072,7 @@ mod tests {
use core::f32; use core::f32;
use super::*; use super::*;
use crate::text::{TextWrapping, layout};
use crate::{Stroke, text::TextFormat}; use crate::{Stroke, text::TextFormat};
use ecolor::Color32; use ecolor::Color32;
use emath::Align; 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, num_indices: 0,
pixels_per_point: fonts.pixels_per_point(), pixels_per_point: fonts.pixels_per_point(),
elided: true, 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 point_scale = PointScale::new(fonts.pixels_per_point());
let intrinsic_size = calculate_intrinsic_size(point_scale, &job, &paragraphs);
let mut elided = false; let mut elided = false;
let mut rows = rows_from_paragraphs(paragraphs, &job, &mut elided); let mut rows = rows_from_paragraphs(paragraphs, &job, &mut elided);
if 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: // 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. // 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. // Ignores the Y coordinate.
fn rows_from_paragraphs( fn rows_from_paragraphs(
paragraphs: Vec<Paragraph>, paragraphs: Vec<Paragraph>,
@ -610,6 +653,7 @@ fn galley_from_rows(
job: Arc<LayoutJob>, job: Arc<LayoutJob>,
mut rows: Vec<PlacedRow>, mut rows: Vec<PlacedRow>,
elided: bool, elided: bool,
intrinsic_size: Vec2,
) -> Galley { ) -> Galley {
let mut first_row_min_height = job.first_row_min_height; let mut first_row_min_height = job.first_row_min_height;
let mut cursor_y = 0.0; let mut cursor_y = 0.0;
@ -680,6 +724,7 @@ fn galley_from_rows(
num_vertices, num_vertices,
num_indices, num_indices,
pixels_per_point: point_scale.pixels_per_point, pixels_per_point: point_scale.pixels_per_point,
intrinsic_size,
}; };
if galley.job.round_output_to_gui { 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 /// so that we can warn if this has changed once we get to
/// tessellation. /// tessellation.
pub pixels_per_point: f32, 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)] #[derive(Clone, Debug, PartialEq)]
@ -821,6 +827,8 @@ impl Galley {
.at_most(rect.min.x + self.job.wrap.max_width) .at_most(rect.min.x + self.job.wrap.max_width)
.floor_ui(); .floor_ui();
} }
self.intrinsic_size = self.intrinsic_size.round_ui();
} }
/// Append each galley under the previous one. /// Append each galley under the previous one.
@ -836,6 +844,7 @@ impl Galley {
num_vertices: 0, num_vertices: 0,
num_indices: 0, num_indices: 0,
pixels_per_point, pixels_per_point,
intrinsic_size: Vec2::ZERO,
}; };
for (i, galley) in galleys.iter().enumerate() { 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 // Note that if `galley.elided` is true this will be the last `Galley` in
// the vector and the loop will end. // the vector and the loop will end.
merged_galley.elided |= galley.elided; 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 { 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) 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));
}