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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
202
crates/epaint/src/text/index.rs
Normal file
202
crates/epaint/src/text/index.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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::*,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user