1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 14:49:06 -04:00

Add LayoutJob::format_at_byte (#8244)

This PR adds two small additions to `LayoutJob`:

- `LayoutJob::format_at_byte` to query the `TextFormat` of the section
covering a given byte index.
- An optimization to `LayoutJob::append` that merges newly appended text
into the previous section when the format matches and there is no
leading space.

It also documents the `LayoutJob::sections` invariant (sections are
ordered and together cover the whole text with no gaps or overlaps) and
adds `LayoutJob::debug_sanity_check`, which verifies this in debug
builds. It is called from `format_at_byte` and from the text layouter.

## Why the `easymarkeditor` snapshot changed

The `append` optimization changes how many sections a `LayoutJob` ends
up with: consecutive runs of identically-formatted text now collapse
into a single section instead of one section per `append` call. The
easymark editor produces many such adjacent same-format sections, so it
is affected.

This matters because text is **shaped per section**: `layout_section`
runs the shaper once per section, so each section is an independent
shaping run. Merging two adjacent sections into one means the text
across the old boundary is now shaped together as a single run, which
enables cross-boundary kerning (and, in principle, ligatures) that
previously did not happen. Additionally, `extra_letter_spacing` is
skipped before the first glyph of a section, so merging removes a "first
glyph" boundary and lets the spacing apply there.

The net effect is sub-pixel glyph position shifts at the former section
boundaries, which is why `easymarkeditor.png` was regenerated. The new
output is the more correct one — the text is now shaped as the author
wrote it, rather than being artificially split at `append` boundaries.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Emil Ernerfeldt
2026-06-20 22:07:18 +02:00
committed by GitHub
parent 86fcffb229
commit eac51da9ca
3 changed files with 98 additions and 3 deletions

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:406fa40c9e69e353128ea3dde985148773cd0e148c24096ca55ee0cc1aab0258
size 168849
oid sha256:1f066e712888a57b5c5ca6ccd6e138c933ab04acc44a3fce5912cfe47852c672
size 168875

View File

@@ -100,6 +100,8 @@ impl Paragraph {
pub fn layout(fonts: &mut FontsImpl, pixels_per_point: f32, job: Arc<LayoutJob>) -> Galley {
profiling::function_scope!();
job.debug_sanity_check();
if job.wrap.max_rows == 0 {
// Early-out: no text
return Galley {

View File

@@ -50,6 +50,12 @@ pub struct LayoutJob {
pub text: String,
/// The different section, which can have different fonts, colors, etc.
///
/// Invariant: the sections are ordered by their `byte_range`,
/// and together cover the whole of [`Self::text`] with no gaps and no overlaps.
/// That is: the first section starts at byte 0, the last section ends at `text.len()`,
/// and each section starts exactly where the previous one ended.
/// This is checked by [`Self::debug_sanity_check`].
pub sections: Vec<LayoutSection>,
/// Controls the text wrapping and elision.
@@ -178,10 +184,26 @@ impl LayoutJob {
}
/// Helper for adding a new section when building a [`LayoutJob`].
///
/// If the appended text has the same [`TextFormat`] as the last section and no
/// `leading_space`, it is merged into that section instead of adding a new one.
/// This keeps the section count down and lets text shaping (e.g. kerning) work
/// across the appended text, since shaping is done per [`LayoutSection`].
pub fn append(&mut self, text: &str, leading_space: f32, format: TextFormat) {
let start = self.text.len();
self.text += text;
let byte_range = start..self.text.len();
// Optimization: merge into the previous section if it has the same format
// and this one adds no leading space.
if leading_space == 0.0
&& let Some(last) = self.sections.last_mut()
&& last.format == format
{
last.byte_range.end = byte_range.end;
return;
}
self.sections.push(LayoutSection {
leading_space,
byte_range,
@@ -189,6 +211,67 @@ impl LayoutJob {
});
}
/// The [`TextFormat`] of the section containing the character starting at the given byte index.
///
/// If the index is past the end, the format of the last section is returned.
///
/// Panics if the job has no sections.
/// Assumes [`LayoutJob::sections`] are ordered by increasing `byte_range` (as produced by [`Self::append`]).
pub fn format_at_byte(&self, byte_idx: usize) -> &TextFormat {
self.debug_sanity_check();
let last = self.sections.last().expect("LayoutJob has no sections");
let idx = self
.sections
.partition_point(|section| section.byte_range.end <= byte_idx);
let section = self.sections.get(idx).unwrap_or(last);
&section.format
}
/// Check the [`Self::sections`] invariant: the sections are ordered and together
/// cover the whole of [`Self::text`] with no gaps and no overlaps.
///
/// Only does anything in debug builds.
#[cfg_attr(not(debug_assertions), expect(clippy::unused_self))]
pub fn debug_sanity_check(&self) {
#[cfg(debug_assertions)]
{
if self.sections.is_empty() {
assert!(
self.text.is_empty(),
"LayoutJob has text but no sections: {:?}",
self.text
);
return;
}
assert_eq!(
self.sections
.first()
.expect("checked above")
.byte_range
.start,
0,
"First LayoutSection must start at byte 0"
);
assert_eq!(
self.sections.last().expect("checked above").byte_range.end,
self.text.len(),
"Last LayoutSection must end at the end of the text"
);
for section in &self.sections {
let Range { start, end } = section.byte_range;
assert!(start <= end, "LayoutSection has a reversed byte_range");
}
for (prev, next) in std::iter::zip(&self.sections, self.sections.iter().skip(1)) {
assert_eq!(
prev.byte_range.end, next.byte_range.start,
"LayoutSections must be ordered with no gaps and no overlaps"
);
}
}
}
/// The height of the tallest font used in the job.
///
/// Returns a value rounded to [`emath::GUI_ROUNDING`].
@@ -242,15 +325,25 @@ impl std::hash::Hash for LayoutJob {
// ----------------------------------------------------------------------------
/// A contiguous range of [`LayoutJob::text`] that shares the same [`TextFormat`].
///
/// The sections of a [`LayoutJob`] are ordered and together cover the whole text
/// with no gaps and no overlaps. See [`LayoutJob::sections`] for the full invariant.
///
/// Text is shaped on a per-section basis: each section is an independent shaping run.
/// This means kerning (and ligatures) are only correct _within_ a single section,
/// and not across the boundary between two adjacent sections.
/// For this reason [`LayoutJob::append`] merges consecutive sections when possible.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct LayoutSection {
/// Can be used for first row indentation.
pub leading_space: f32,
/// Range into the galley text
/// Range into [`LayoutJob::text`].
pub byte_range: Range<usize>,
/// How to format the text in this section (font, color, etc).
pub format: TextFormat,
}