diff --git a/crates/egui_demo_app/tests/snapshots/easymarkeditor.png b/crates/egui_demo_app/tests/snapshots/easymarkeditor.png index 1d370bccd..81485c09a 100644 --- a/crates/egui_demo_app/tests/snapshots/easymarkeditor.png +++ b/crates/egui_demo_app/tests/snapshots/easymarkeditor.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:406fa40c9e69e353128ea3dde985148773cd0e148c24096ca55ee0cc1aab0258 -size 168849 +oid sha256:1f066e712888a57b5c5ca6ccd6e138c933ab04acc44a3fce5912cfe47852c672 +size 168875 diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index 77ca74fea..1c8aba1e3 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -100,6 +100,8 @@ impl Paragraph { pub fn layout(fonts: &mut FontsImpl, pixels_per_point: f32, job: Arc) -> Galley { profiling::function_scope!(); + job.debug_sanity_check(); + if job.wrap.max_rows == 0 { // Early-out: no text return Galley { diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index bce2fa48f..d471a433a 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -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, /// 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); + §ion.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, + /// How to format the text in this section (font, color, etc). pub format: TextFormat, }