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

Use strongly typed CharIndex and ByteIndex + bug fixes (#8245)

Less risk of confusing the two.

Found and fix a couple real bugs in the process!

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Emil Ernerfeldt
2026-06-21 02:24:00 +02:00
committed by GitHub
parent eac51da9ca
commit 13d6b5afcf
15 changed files with 396 additions and 138 deletions

View File

@@ -1,5 +1,7 @@
//! Different types of text cursors, i.e. ways to point into a [`super::Galley`].
use super::index::CharIndex;
/// Character cursor.
///
/// The default cursor is zero.
@@ -7,7 +9,7 @@
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct CCursor {
/// Character offset (NOT byte offset!).
pub index: usize,
pub index: CharIndex,
/// If this cursors sits right at the border of a wrapped row break (NOT paragraph break)
/// do we prefer the next row?
@@ -18,9 +20,9 @@ pub struct CCursor {
impl CCursor {
#[inline]
pub fn new(index: usize) -> Self {
pub fn new(index: impl Into<CharIndex>) -> Self {
Self {
index,
index: index.into(),
prefer_next_row: false,
}
}
@@ -83,5 +85,5 @@ pub struct LayoutCursor {
/// Character based (NOT bytes).
/// It is fine if this points to something beyond the end of the current row.
/// When moving up/down it may again be within the next row.
pub column: usize,
pub column: CharIndex,
}

View File

@@ -10,7 +10,7 @@ use std::{
use crate::{
TextureAtlas,
text::{
Galley, LayoutJob, LayoutSection, TextOptions, VariationCoords,
ByteIndex, Galley, LayoutJob, LayoutSection, TextOptions, VariationCoords,
font::{Font, FontFace},
},
};
@@ -1070,10 +1070,10 @@ impl GalleyCache {
// `start` and `end` are the byte range of the current paragraph.
// How does the current section overlap with the paragraph range?
if section_range.end <= start {
if section_range.end <= ByteIndex(start) {
// The section is behind us
current_section += 1;
} else if end < section_range.start {
} else if ByteIndex(end) < section_range.start {
break; // Haven't reached this one yet.
} else {
// Section range overlaps with paragraph range
@@ -1082,13 +1082,13 @@ impl GalleyCache {
"Bad byte_range: {section_range:?}"
);
let new_range = section_range.start.saturating_sub(start)
..(section_range.end.at_most(end)).saturating_sub(start);
..(section_range.end.min(ByteIndex(end))).saturating_sub(start);
debug_assert!(
new_range.start <= new_range.end,
"Bad new section range: {new_range:?}"
);
paragraph_job.sections.push(LayoutSection {
leading_space: if start <= section_range.start {
leading_space: if ByteIndex(start) <= section_range.start {
*leading_space
} else {
0.0

View File

@@ -0,0 +1,202 @@
//! Strongly-typed offsets into text.
//!
//! UTF-8 text can be indexed either by _byte_ offset or by _character_
//! (Unicode scalar) offset. Mixing the two is a common source of bugs,
//! so we use distinct types to keep them apart.
use std::ops::Range;
/// A byte offset into a UTF-8 string.
///
/// This is what you use to slice a [`str`] (e.g. `&text[range.start.0..range.end.0]`).
/// Not to be confused with [`CharIndex`], which counts characters instead of bytes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(
feature = "serde",
derive(serde::Deserialize, serde::Serialize),
serde(transparent)
)]
pub struct ByteIndex(pub usize);
/// A character (Unicode scalar) offset into a string.
///
/// Counts characters, not bytes, so it is independent of the UTF-8 encoding.
/// Not to be confused with [`ByteIndex`]. See also [`super::cursor::CCursor`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(
feature = "serde",
derive(serde::Deserialize, serde::Serialize),
serde(transparent)
)]
pub struct CharIndex(pub usize);
macro_rules! impl_text_index {
($Type:ident) => {
impl $Type {
/// The zero offset, i.e. the very start of the text.
pub const ZERO: Self = Self(0);
/// Saturating integer addition.
#[inline]
pub fn saturating_add(self, rhs: usize) -> Self {
Self(self.0.saturating_add(rhs))
}
/// Saturating integer subtraction.
#[inline]
pub fn saturating_sub(self, rhs: usize) -> Self {
Self(self.0.saturating_sub(rhs))
}
}
impl From<usize> for $Type {
#[inline]
fn from(index: usize) -> Self {
Self(index)
}
}
impl From<$Type> for usize {
#[inline]
fn from(index: $Type) -> Self {
index.0
}
}
impl std::ops::Add<usize> for $Type {
type Output = Self;
#[inline]
fn add(self, rhs: usize) -> Self {
Self(self.0 + rhs)
}
}
/// Compose offsets, e.g. a base position plus a relative one.
impl std::ops::Add<$Type> for $Type {
type Output = Self;
#[inline]
fn add(self, rhs: Self) -> Self {
Self(self.0 + rhs.0)
}
}
impl std::ops::Sub<usize> for $Type {
type Output = Self;
#[inline]
fn sub(self, rhs: usize) -> Self {
Self(self.0 - rhs)
}
}
impl std::ops::Sub<$Type> for $Type {
type Output = Self;
#[inline]
fn sub(self, rhs: Self) -> Self {
Self(self.0 - rhs.0)
}
}
impl std::ops::AddAssign<usize> for $Type {
#[inline]
fn add_assign(&mut self, rhs: usize) {
self.0 += rhs;
}
}
impl std::ops::AddAssign<$Type> for $Type {
#[inline]
fn add_assign(&mut self, rhs: Self) {
self.0 += rhs.0;
}
}
impl std::ops::SubAssign<usize> for $Type {
#[inline]
fn sub_assign(&mut self, rhs: usize) {
self.0 -= rhs;
}
}
impl std::fmt::Display for $Type {
#[inline]
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
};
}
impl_text_index!(ByteIndex);
impl_text_index!(CharIndex);
/// A range of [`ByteIndex`], i.e. a byte range into a [`str`].
pub type ByteRange = Range<ByteIndex>;
/// A range of [`CharIndex`], i.e. a character range into a [`str`].
pub type CharRange = Range<CharIndex>;
/// Extension methods for a [`ByteRange`].
pub trait ByteRangeExt {
/// The full byte range covering `text`, i.e. `0..text.len()`.
fn full(text: &str) -> Self;
/// The `start..end` byte range as plain `usize`, for slicing a [`str`].
fn as_usize(&self) -> Range<usize>;
/// Slice the given string by this byte range.
fn slice<'s>(&self, text: &'s str) -> &'s str;
}
impl ByteRangeExt for ByteRange {
#[inline]
fn full(text: &str) -> Self {
ByteIndex::ZERO..ByteIndex(text.len())
}
#[inline]
fn as_usize(&self) -> Range<usize> {
self.start.0..self.end.0
}
#[inline]
fn slice<'s>(&self, text: &'s str) -> &'s str {
&text[self.as_usize()]
}
}
/// Extension methods for a [`CharRange`].
pub trait CharRangeExt {
/// The full character range covering `text`, i.e. `0..text.chars().count()`.
fn full(text: &str) -> Self;
}
impl CharRangeExt for CharRange {
#[inline]
fn full(text: &str) -> Self {
CharIndex::ZERO..CharIndex(text.chars().count())
}
}
#[cfg(test)]
mod tests {
use super::CharIndex;
#[test]
fn arithmetic() {
// Add a relative offset to a base position:
assert_eq!(CharIndex(2) + CharIndex(3), CharIndex(5));
assert_eq!(CharIndex(2) + 3, CharIndex(5));
let mut idx = CharIndex(2);
idx += CharIndex(3);
assert_eq!(idx, CharIndex(5));
// Subtract a relative offset from a position:
assert_eq!(CharIndex(5) - CharIndex(2), CharIndex(3));
assert_eq!(CharIndex(5) - 2, CharIndex(3));
}
}

View File

@@ -3,6 +3,7 @@
pub mod cursor;
mod font;
mod fonts;
mod index;
mod text_layout;
mod text_layout_types;
@@ -11,6 +12,7 @@ pub use {
FontData, FontDefinitions, FontFamily, FontId, FontInsert, FontPriority, FontTweak, Fonts,
FontsImpl, FontsView, InsertFontFamily,
},
index::{ByteIndex, ByteRange, ByteRangeExt, CharIndex, CharRange, CharRangeExt},
text_layout::*,
text_layout_types::*,
};

View File

@@ -15,8 +15,8 @@ use crate::{
};
use super::{
FontsImpl, Galley, Glyph, LayoutJob, LayoutSection, PlacedRow, Row, RowVisuals,
VariationCoords,
ByteRangeExt as _, FontsImpl, Galley, Glyph, LayoutJob, LayoutSection, PlacedRow, Row,
RowVisuals, VariationCoords,
font::{Font, FontFace, ShapedGlyph},
};
@@ -454,7 +454,7 @@ fn layout_section(
}
paragraph.cursor_x_px += leading_space * pixels_per_point;
let section_text = &job.text[byte_range.clone()];
let section_text = &job.text[byte_range.as_usize()];
let mut ctx = ShapingContext {
pixels_per_point,
font_size,
@@ -1574,7 +1574,7 @@ mod tests {
pixels_per_point,
Arc::new(LayoutJob::single_section(
iter::chain(
(0..elided_galley.rows[0].char_count_excluding_newline()).map(|_| ch),
(0..elided_galley.rows[0].char_count_excluding_newline().0).map(|_| ch),
iter::once('…'),
)
.collect::<String>(),
@@ -1866,7 +1866,7 @@ mod tests {
// Verify cursor round-trip: end cursor index == char count.
assert_eq!(
galley.end().index,
galley.end().index.0,
expected_chars,
"Galley::end().index mismatch for {text:?}",
);
@@ -1892,9 +1892,9 @@ mod tests {
let galley = layout(&mut fonts, pixels_per_point, job.into());
// Walking through every cursor index should produce valid positions.
for i in 0..=galley.end().index {
for i in 0..=galley.end().index.0 {
let cursor = CCursor {
index: i,
index: CharIndex(i),
prefer_next_row: false,
};
let rect = galley.pos_from_cursor(cursor);

View File

@@ -4,6 +4,7 @@ use std::{ops::Range, str::FromStr as _};
use super::{
cursor::{CCursor, LayoutCursor},
font::UvRect,
index::{ByteIndex, ByteRange, ByteRangeExt as _, CharIndex},
};
use crate::{Color32, FontId, Mesh, Stroke, text::FontsView};
use emath::{Align, GuiRounding as _, NumExt as _, OrderedFloat, Pos2, Rect, Vec2, pos2, vec2};
@@ -119,7 +120,7 @@ impl LayoutJob {
Self {
sections: vec![LayoutSection {
leading_space: 0.0,
byte_range: 0..text.len(),
byte_range: ByteRange::full(&text),
format: TextFormat::simple(font_id, color),
}],
text,
@@ -138,7 +139,7 @@ impl LayoutJob {
Self {
sections: vec![LayoutSection {
leading_space: 0.0,
byte_range: 0..text.len(),
byte_range: ByteRange::full(&text),
format,
}],
text,
@@ -153,7 +154,7 @@ impl LayoutJob {
Self {
sections: vec![LayoutSection {
leading_space: 0.0,
byte_range: 0..text.len(),
byte_range: ByteRange::full(&text),
format: TextFormat::simple(font_id, color),
}],
text,
@@ -168,7 +169,7 @@ impl LayoutJob {
Self {
sections: vec![LayoutSection {
leading_space: 0.0,
byte_range: 0..text.len(),
byte_range: ByteRange::full(&text),
format,
}],
text,
@@ -192,7 +193,7 @@ impl LayoutJob {
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();
let byte_range = ByteIndex(start)..ByteIndex(self.text.len());
// Optimization: merge into the previous section if it has the same format
// and this one adds no leading space.
@@ -217,7 +218,7 @@ impl LayoutJob {
///
/// 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 {
pub fn format_at_byte(&self, byte_idx: ByteIndex) -> &TextFormat {
self.debug_sanity_check();
let last = self.sections.last().expect("LayoutJob has no sections");
let idx = self
@@ -250,12 +251,12 @@ impl LayoutJob {
.expect("checked above")
.byte_range
.start,
0,
ByteIndex::ZERO,
"First LayoutSection must start at byte 0"
);
assert_eq!(
self.sections.last().expect("checked above").byte_range.end,
self.text.len(),
ByteIndex(self.text.len()),
"Last LayoutSection must end at the end of the text"
);
@@ -341,7 +342,7 @@ pub struct LayoutSection {
pub leading_space: f32,
/// Range into [`LayoutJob::text`].
pub byte_range: Range<usize>,
pub byte_range: Range<ByteIndex>,
/// How to format the text in this section (font, color, etc).
pub format: TextFormat,
@@ -946,23 +947,23 @@ impl Row {
/// Excludes the implicit `\n` after the [`Row`], if any.
#[inline]
pub fn char_count_excluding_newline(&self) -> usize {
self.glyphs.len()
pub fn char_count_excluding_newline(&self) -> CharIndex {
CharIndex(self.glyphs.len())
}
/// Closest char at the desired x coordinate in row-relative coordinates.
/// Returns something in the range `[0, char_count_excluding_newline()]`.
pub fn char_at(&self, desired_x: f32) -> usize {
pub fn char_at(&self, desired_x: f32) -> CharIndex {
for (i, glyph) in self.glyphs.iter().enumerate() {
if desired_x < glyph.logical_rect().center().x {
return i;
return CharIndex(i);
}
}
self.char_count_excluding_newline()
}
pub fn x_offset(&self, column: usize) -> f32 {
if let Some(glyph) = self.glyphs.get(column) {
pub fn x_offset(&self, column: CharIndex) -> f32 {
if let Some(glyph) = self.glyphs.get(column.0) {
glyph.pos.x
} else {
self.size.x
@@ -988,8 +989,8 @@ impl PlacedRow {
/// Includes the implicit `\n` after the [`PlacedRow`], if any.
#[inline]
pub fn char_count_including_newline(&self) -> usize {
self.row.glyphs.len() + (self.ends_with_newline as usize)
pub fn char_count_including_newline(&self) -> CharIndex {
CharIndex(self.row.glyphs.len() + (self.ends_with_newline as usize))
}
}
@@ -1188,7 +1189,7 @@ impl Galley {
let mut best_y_dist = f32::INFINITY;
let mut cursor = CCursor::default();
let mut ccursor_index = 0;
let mut ccursor_index = CharIndex::ZERO;
for row in &self.rows {
let min_y = row.min_y();
@@ -1234,7 +1235,7 @@ impl Galley {
return Default::default();
}
let mut ccursor = CCursor {
index: 0,
index: CharIndex::ZERO,
prefer_next_row: true,
};
for row in &self.rows {
@@ -1251,7 +1252,7 @@ impl Galley {
pub fn layout_from_cursor(&self, cursor: CCursor) -> LayoutCursor {
let prefer_next_row = cursor.prefer_next_row;
let mut ccursor_it = CCursor {
index: 0,
index: CharIndex::ZERO,
prefer_next_row,
};
@@ -1294,15 +1295,13 @@ impl Galley {
let prefer_next_row =
layout_cursor.column < self.rows[layout_cursor.row].char_count_excluding_newline();
let mut cursor_it = CCursor {
index: 0,
index: CharIndex::ZERO,
prefer_next_row,
};
for (row_nr, row) in self.rows.iter().enumerate() {
if row_nr == layout_cursor.row {
cursor_it.index += layout_cursor
.column
.at_most(row.char_count_excluding_newline());
cursor_it.index += layout_cursor.column.min(row.char_count_excluding_newline());
return cursor_it;
}
@@ -1316,7 +1315,7 @@ impl Galley {
impl Galley {
#[expect(clippy::unused_self)]
pub fn cursor_left_one_character(&self, cursor: &CCursor) -> CCursor {
if cursor.index == 0 {
if cursor.index == CharIndex::ZERO {
Default::default()
} else {
CCursor {
@@ -1392,7 +1391,7 @@ impl Galley {
let layout_cursor = self.layout_from_cursor(*cursor);
self.cursor_from_layout(LayoutCursor {
row: layout_cursor.row,
column: 0,
column: CharIndex::ZERO,
})
}
@@ -1406,7 +1405,7 @@ impl Galley {
pub fn cursor_begin_of_paragraph(&self, cursor: &CCursor) -> CCursor {
let mut layout_cursor = self.layout_from_cursor(*cursor);
layout_cursor.column = 0;
layout_cursor.column = CharIndex::ZERO;
loop {
let prev_row = layout_cursor