1
0
mirror of https://github.com/emilk/egui.git synced 2026-08-29 04:40:03 -04:00

Simplify the text cursor API (#5785)

<!--
Please read the "Making a PR" section of
[`CONTRIBUTING.md`](https://github.com/emilk/egui/blob/master/CONTRIBUTING.md)
before opening a Pull Request!

* Keep your PR:s small and focused.
* The PR title is what ends up in the changelog, so make it descriptive!
* If applicable, add a screenshot or gif.
* If it is a non-trivial addition, consider adding a demo for it to
`egui_demo_lib`, or a new example.
* Do NOT open PR:s from your `master` branch, as that makes it hard for
maintainers to test and add commits to your PR.
* Remember to run `cargo fmt` and `cargo clippy`.
* Open the PR as a draft until you have self-reviewed it and run
`./scripts/check.sh`.
* When you have addressed a PR comment, mark it as resolved.

Please be patient! I will review your PR, but my time is limited!
-->

* Closes N/A, but this is part of
https://github.com/emilk/egui/issues/3378
* [x] I have followed the instructions in the PR template

Other text layout libraries in Rust--namely, Parley and Cosmic
Text--have one canonical text cursor type (Parley's is a byte index,
Cosmic Text's also stores the line index). To prepare for migrating egui
to one of those libraries, it should also have only one text cursor
type. I also think simplifying the API is a good idea in and of
itself--having three different cursor types that you have to convert
between (and a `Cursor` struct which contains all three at once) is
confusing.

After a bit of experimentation, I found that the best cursor type to
coalesce around is `CCursor`. In the few places where we need a
paragraph index or row/column position, we can calculate them as
necessary.

I've removed `CursorRange` and `PCursorRange` (the latter appears to
have never been used), merging the functionality with `CCursorRange`. To
preserve the cursor position when navigating row-by-row, `CCursorRange`
now stores the previous horizontal position of the cursor.

I've also removed `PCursor`, and renamed `RowCursor` to `LayoutCursor`
(since it includes not only the row but the column). I have not renamed
either `CCursorRange` or `CCursor` as those names are used in a lot of
places, and I don't want to clutter this PR with a bunch of renames.
I'll leave it for a later PR.

Finally, I've removed the deprecated methods from `TextEditState`--it
made the refactoring easier, and it should be pretty easy to migrate to
the equivalent `TextCursorState` methods.

I'm not sure how many breaking changes people will actually encounter. A
lot of these APIs were technically public, but I don't think many were
useful. The `TextBuffer` trait now takes `&CCursorRange` instead of
`&CursorRange` in a couple of methods, and I renamed
`CCursorRange::sorted` to `CCursorRange::sorted_cursors` to match
`CursorRange`.

I did encounter a couple of apparent minor bugs when testing out text
cursor behavior, but I checked them against the current version of egui
and they're all pre-existing.
This commit is contained in:
valadaptive
2025-03-20 05:49:38 -04:00
committed by GitHub
parent 6a8ee29a4e
commit 267485976b
14 changed files with 394 additions and 730 deletions

View File

@@ -26,13 +26,6 @@ impl CCursor {
}
}
impl From<Cursor> for CCursor {
#[inline]
fn from(c: Cursor) -> Self {
c.ccursor
}
}
/// Two `CCursor`s are considered equal if they refer to the same character boundary,
/// even if one prefers the start of the next row.
impl PartialEq for CCursor {
@@ -76,10 +69,12 @@ impl std::ops::SubAssign<usize> for CCursor {
}
}
/// Row Cursor
/// Row/column cursor.
///
/// This refers to rows and columns in layout terms--text wrapping creates multiple rows.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct RCursor {
pub struct LayoutCursor {
/// 0 is first row, and so on.
/// Note that a single paragraph can span multiple rows.
/// (a paragraph is text separated by `\n`).
@@ -90,48 +85,3 @@ pub struct RCursor {
/// When moving up/down it may again be within the next row.
pub column: usize,
}
/// Paragraph Cursor
#[derive(Clone, Copy, Debug, Default)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct PCursor {
/// 0 is first paragraph, and so on.
/// Note that a single paragraph can span multiple rows.
/// (a paragraph is text separated by `\n`).
pub paragraph: usize,
/// Character based (NOT bytes).
/// It is fine if this points to something beyond the end of the current paragraph.
/// When moving up/down it may again be within the next paragraph.
pub offset: usize,
/// If this cursors sits right at the border of a wrapped row break (NOT paragraph break)
/// do we prefer the next row?
/// This is *almost* always what you want, *except* for when
/// explicitly clicking the end of a row or pressing the end key.
pub prefer_next_row: bool,
}
/// Two `PCursor`s are considered equal if they refer to the same character boundary,
/// even if one prefers the start of the next row.
impl PartialEq for PCursor {
#[inline]
fn eq(&self, other: &Self) -> bool {
self.paragraph == other.paragraph && self.offset == other.offset
}
}
/// All different types of cursors together.
///
/// They all point to the same place, but in their own different ways.
/// pcursor/rcursor can also point to after the end of the paragraph/row.
/// Does not implement `PartialEq` because you must think which cursor should be equivalent.
///
/// The default cursor is the zero-cursor, to the first character.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct Cursor {
pub ccursor: CCursor,
pub rcursor: RCursor,
pub pcursor: PCursor,
}

View File

@@ -5,7 +5,7 @@ use std::ops::Range;
use std::sync::Arc;
use super::{
cursor::{CCursor, Cursor, PCursor, RCursor},
cursor::{CCursor, LayoutCursor},
font::UvRect,
};
use crate::{Color32, FontId, Mesh, Stroke};
@@ -766,53 +766,18 @@ impl Galley {
}
/// Returns a 0-width Rect.
pub fn pos_from_cursor(&self, cursor: &Cursor) -> Rect {
self.pos_from_pcursor(cursor.pcursor) // pcursor is what TextEdit stores
fn pos_from_layout_cursor(&self, layout_cursor: &LayoutCursor) -> Rect {
let Some(row) = self.rows.get(layout_cursor.row) else {
return self.end_pos();
};
let x = row.x_offset(layout_cursor.column);
Rect::from_min_max(pos2(x, row.min_y()), pos2(x, row.max_y()))
}
/// Returns a 0-width Rect.
pub fn pos_from_pcursor(&self, pcursor: PCursor) -> Rect {
let mut it = PCursor::default();
for row in &self.rows {
if it.paragraph == pcursor.paragraph {
// Right paragraph, but is it the right row in the paragraph?
if it.offset <= pcursor.offset
&& (pcursor.offset <= it.offset + row.char_count_excluding_newline()
|| row.ends_with_newline)
{
let column = pcursor.offset - it.offset;
let select_next_row_instead = pcursor.prefer_next_row
&& !row.ends_with_newline
&& column >= row.char_count_excluding_newline();
if !select_next_row_instead {
let x = row.x_offset(column);
return Rect::from_min_max(pos2(x, row.min_y()), pos2(x, row.max_y()));
}
}
}
if row.ends_with_newline {
it.paragraph += 1;
it.offset = 0;
} else {
it.offset += row.char_count_including_newline();
}
}
self.end_pos()
}
/// Returns a 0-width Rect.
pub fn pos_from_ccursor(&self, ccursor: CCursor) -> Rect {
self.pos_from_cursor(&self.from_ccursor(ccursor))
}
/// Returns a 0-width Rect.
pub fn pos_from_rcursor(&self, rcursor: RCursor) -> Rect {
self.pos_from_cursor(&self.from_rcursor(rcursor))
pub fn pos_from_cursor(&self, cursor: CCursor) -> Rect {
self.pos_from_layout_cursor(&self.layout_from_cursor(cursor))
}
/// Cursor at the given position within the galley.
@@ -822,7 +787,7 @@ impl Galley {
/// and a cursor below the galley is considered
/// same as a cursor at the end.
/// This allows implementing text-selection by dragging above/below the galley.
pub fn cursor_from_pos(&self, pos: Vec2) -> Cursor {
pub fn cursor_from_pos(&self, pos: Vec2) -> CCursor {
if let Some(first_row) = self.rows.first() {
if pos.y < first_row.min_y() {
return self.begin();
@@ -835,32 +800,20 @@ impl Galley {
}
let mut best_y_dist = f32::INFINITY;
let mut cursor = Cursor::default();
let mut cursor = CCursor::default();
let mut ccursor_index = 0;
let mut pcursor_it = PCursor::default();
for (row_nr, row) in self.rows.iter().enumerate() {
for row in &self.rows {
let is_pos_within_row = row.min_y() <= pos.y && pos.y <= row.max_y();
let y_dist = (row.min_y() - pos.y).abs().min((row.max_y() - pos.y).abs());
if is_pos_within_row || y_dist < best_y_dist {
best_y_dist = y_dist;
let column = row.char_at(pos.x);
let prefer_next_row = column < row.char_count_excluding_newline();
cursor = Cursor {
ccursor: CCursor {
index: ccursor_index + column,
prefer_next_row,
},
rcursor: RCursor {
row: row_nr,
column,
},
pcursor: PCursor {
paragraph: pcursor_it.paragraph,
offset: pcursor_it.offset + column,
prefer_next_row,
},
cursor = CCursor {
index: ccursor_index + column,
prefer_next_row,
};
if is_pos_within_row {
@@ -868,12 +821,6 @@ impl Galley {
}
}
ccursor_index += row.char_count_including_newline();
if row.ends_with_newline {
pcursor_it.paragraph += 1;
pcursor_it.offset = 0;
} else {
pcursor_it.offset += row.char_count_including_newline();
}
}
cursor
@@ -884,15 +831,15 @@ impl Galley {
impl Galley {
/// Cursor to the first character.
///
/// This is the same as [`Cursor::default`].
/// This is the same as [`CCursor::default`].
#[inline]
#[allow(clippy::unused_self)]
pub fn begin(&self) -> Cursor {
Cursor::default()
pub fn begin(&self) -> CCursor {
CCursor::default()
}
/// Cursor to one-past last character.
pub fn end(&self) -> Cursor {
pub fn end(&self) -> CCursor {
if self.rows.is_empty() {
return Default::default();
}
@@ -900,31 +847,47 @@ impl Galley {
index: 0,
prefer_next_row: true,
};
let mut pcursor = PCursor {
paragraph: 0,
offset: 0,
prefer_next_row: true,
};
for row in &self.rows {
let row_char_count = row.char_count_including_newline();
ccursor.index += row_char_count;
if row.ends_with_newline {
pcursor.paragraph += 1;
pcursor.offset = 0;
} else {
pcursor.offset += row_char_count;
}
}
Cursor {
ccursor,
rcursor: self.end_rcursor(),
pcursor,
}
ccursor
}
}
/// ## Cursor conversions
impl Galley {
// The returned cursor is clamped.
pub fn layout_from_cursor(&self, cursor: CCursor) -> LayoutCursor {
let prefer_next_row = cursor.prefer_next_row;
let mut ccursor_it = CCursor {
index: 0,
prefer_next_row,
};
for (row_nr, row) in self.rows.iter().enumerate() {
let row_char_count = row.char_count_excluding_newline();
if ccursor_it.index <= cursor.index && cursor.index <= ccursor_it.index + row_char_count
{
let column = cursor.index - ccursor_it.index;
let select_next_row_instead = prefer_next_row
&& !row.ends_with_newline
&& column >= row.char_count_excluding_newline();
if !select_next_row_instead {
return LayoutCursor {
row: row_nr,
column,
};
}
}
ccursor_it.index += row.char_count_including_newline();
}
debug_assert!(ccursor_it == self.end());
pub fn end_rcursor(&self) -> RCursor {
if let Some(last_row) = self.rows.last() {
RCursor {
LayoutCursor {
row: self.rows.len() - 1,
column: last_row.char_count_including_newline(),
}
@@ -932,268 +895,156 @@ impl Galley {
Default::default()
}
}
}
/// ## Cursor conversions
impl Galley {
// The returned cursor is clamped.
pub fn from_ccursor(&self, ccursor: CCursor) -> Cursor {
let prefer_next_row = ccursor.prefer_next_row;
let mut ccursor_it = CCursor {
index: 0,
prefer_next_row,
};
let mut pcursor_it = PCursor {
paragraph: 0,
offset: 0,
prefer_next_row,
};
for (row_nr, row) in self.rows.iter().enumerate() {
let row_char_count = row.char_count_excluding_newline();
if ccursor_it.index <= ccursor.index
&& ccursor.index <= ccursor_it.index + row_char_count
{
let column = ccursor.index - ccursor_it.index;
let select_next_row_instead = prefer_next_row
&& !row.ends_with_newline
&& column >= row.char_count_excluding_newline();
if !select_next_row_instead {
pcursor_it.offset += column;
return Cursor {
ccursor,
rcursor: RCursor {
row: row_nr,
column,
},
pcursor: pcursor_it,
};
}
}
ccursor_it.index += row.char_count_including_newline();
if row.ends_with_newline {
pcursor_it.paragraph += 1;
pcursor_it.offset = 0;
} else {
pcursor_it.offset += row.char_count_including_newline();
}
}
debug_assert!(ccursor_it == self.end().ccursor);
Cursor {
ccursor: ccursor_it, // clamp
rcursor: self.end_rcursor(),
pcursor: pcursor_it,
}
}
pub fn from_rcursor(&self, rcursor: RCursor) -> Cursor {
if rcursor.row >= self.rows.len() {
fn cursor_from_layout(&self, layout_cursor: LayoutCursor) -> CCursor {
if layout_cursor.row >= self.rows.len() {
return self.end();
}
let prefer_next_row =
rcursor.column < self.rows[rcursor.row].char_count_excluding_newline();
let mut ccursor_it = CCursor {
layout_cursor.column < self.rows[layout_cursor.row].char_count_excluding_newline();
let mut cursor_it = CCursor {
index: 0,
prefer_next_row,
};
let mut pcursor_it = PCursor {
paragraph: 0,
offset: 0,
prefer_next_row,
};
for (row_nr, row) in self.rows.iter().enumerate() {
if row_nr == rcursor.row {
ccursor_it.index += rcursor.column.at_most(row.char_count_excluding_newline());
if row_nr == layout_cursor.row {
cursor_it.index += layout_cursor
.column
.at_most(row.char_count_excluding_newline());
if row.ends_with_newline {
// Allow offset to go beyond the end of the paragraph
pcursor_it.offset += rcursor.column;
} else {
pcursor_it.offset += rcursor.column.at_most(row.char_count_excluding_newline());
}
return Cursor {
ccursor: ccursor_it,
rcursor,
pcursor: pcursor_it,
};
}
ccursor_it.index += row.char_count_including_newline();
if row.ends_with_newline {
pcursor_it.paragraph += 1;
pcursor_it.offset = 0;
} else {
pcursor_it.offset += row.char_count_including_newline();
return cursor_it;
}
cursor_it.index += row.char_count_including_newline();
}
Cursor {
ccursor: ccursor_it,
rcursor: self.end_rcursor(),
pcursor: pcursor_it,
}
}
// TODO(emilk): return identical cursor, or clamp?
pub fn from_pcursor(&self, pcursor: PCursor) -> Cursor {
let prefer_next_row = pcursor.prefer_next_row;
let mut ccursor_it = CCursor {
index: 0,
prefer_next_row,
};
let mut pcursor_it = PCursor {
paragraph: 0,
offset: 0,
prefer_next_row,
};
for (row_nr, row) in self.rows.iter().enumerate() {
if pcursor_it.paragraph == pcursor.paragraph {
// Right paragraph, but is it the right row in the paragraph?
if pcursor_it.offset <= pcursor.offset
&& (pcursor.offset <= pcursor_it.offset + row.char_count_excluding_newline()
|| row.ends_with_newline)
{
let column = pcursor.offset - pcursor_it.offset;
let select_next_row_instead = pcursor.prefer_next_row
&& !row.ends_with_newline
&& column >= row.char_count_excluding_newline();
if !select_next_row_instead {
ccursor_it.index += column.at_most(row.char_count_excluding_newline());
return Cursor {
ccursor: ccursor_it,
rcursor: RCursor {
row: row_nr,
column,
},
pcursor,
};
}
}
}
ccursor_it.index += row.char_count_including_newline();
if row.ends_with_newline {
pcursor_it.paragraph += 1;
pcursor_it.offset = 0;
} else {
pcursor_it.offset += row.char_count_including_newline();
}
}
Cursor {
ccursor: ccursor_it,
rcursor: self.end_rcursor(),
pcursor,
}
cursor_it
}
}
/// ## Cursor positions
impl Galley {
pub fn cursor_left_one_character(&self, cursor: &Cursor) -> Cursor {
if cursor.ccursor.index == 0 {
#[allow(clippy::unused_self)]
pub fn cursor_left_one_character(&self, cursor: &CCursor) -> CCursor {
if cursor.index == 0 {
Default::default()
} else {
let ccursor = CCursor {
index: cursor.ccursor.index,
prefer_next_row: true, // default to this when navigating. It is more often useful to put cursor at the begging of a row than at the end.
};
self.from_ccursor(ccursor - 1)
CCursor {
index: cursor.index - 1,
prefer_next_row: true, // default to this when navigating. It is more often useful to put cursor at the beginning of a row than at the end.
}
}
}
pub fn cursor_right_one_character(&self, cursor: &Cursor) -> Cursor {
let ccursor = CCursor {
index: cursor.ccursor.index,
prefer_next_row: true, // default to this when navigating. It is more often useful to put cursor at the begging of a row than at the end.
};
self.from_ccursor(ccursor + 1)
pub fn cursor_right_one_character(&self, cursor: &CCursor) -> CCursor {
CCursor {
index: (cursor.index + 1).min(self.end().index),
prefer_next_row: true, // default to this when navigating. It is more often useful to put cursor at the beginning of a row than at the end.
}
}
pub fn cursor_up_one_row(&self, cursor: &Cursor) -> Cursor {
if cursor.rcursor.row == 0 {
Cursor::default()
pub fn cursor_up_one_row(
&self,
cursor: &CCursor,
h_pos: Option<f32>,
) -> (CCursor, Option<f32>) {
let layout_cursor = self.layout_from_cursor(*cursor);
let h_pos = h_pos.unwrap_or_else(|| self.pos_from_layout_cursor(&layout_cursor).center().x);
if layout_cursor.row == 0 {
(CCursor::default(), None)
} else {
let new_row = cursor.rcursor.row - 1;
let new_row = layout_cursor.row - 1;
let cursor_is_beyond_end_of_current_row = cursor.rcursor.column
>= self.rows[cursor.rcursor.row].char_count_excluding_newline();
let new_rcursor = if cursor_is_beyond_end_of_current_row {
// keep same column
RCursor {
row: new_row,
column: cursor.rcursor.column,
}
} else {
let new_layout_cursor = {
// keep same X coord
let x = self.pos_from_cursor(cursor).center().x;
let column = if x > self.rows[new_row].rect.right() {
// beyond the end of this row - keep same column
cursor.rcursor.column
} else {
self.rows[new_row].char_at(x)
};
RCursor {
let column = self.rows[new_row].char_at(h_pos);
LayoutCursor {
row: new_row,
column,
}
};
self.from_rcursor(new_rcursor)
(self.cursor_from_layout(new_layout_cursor), Some(h_pos))
}
}
pub fn cursor_down_one_row(&self, cursor: &Cursor) -> Cursor {
if cursor.rcursor.row + 1 < self.rows.len() {
let new_row = cursor.rcursor.row + 1;
pub fn cursor_down_one_row(
&self,
cursor: &CCursor,
h_pos: Option<f32>,
) -> (CCursor, Option<f32>) {
let layout_cursor = self.layout_from_cursor(*cursor);
let h_pos = h_pos.unwrap_or_else(|| self.pos_from_layout_cursor(&layout_cursor).center().x);
if layout_cursor.row + 1 < self.rows.len() {
let new_row = layout_cursor.row + 1;
let cursor_is_beyond_end_of_current_row = cursor.rcursor.column
>= self.rows[cursor.rcursor.row].char_count_excluding_newline();
let new_rcursor = if cursor_is_beyond_end_of_current_row {
// keep same column
RCursor {
row: new_row,
column: cursor.rcursor.column,
}
} else {
let new_layout_cursor = {
// keep same X coord
let x = self.pos_from_cursor(cursor).center().x;
let column = if x > self.rows[new_row].rect.right() {
// beyond the end of the next row - keep same column
cursor.rcursor.column
} else {
self.rows[new_row].char_at(x)
};
RCursor {
let column = self.rows[new_row].char_at(h_pos);
LayoutCursor {
row: new_row,
column,
}
};
self.from_rcursor(new_rcursor)
(self.cursor_from_layout(new_layout_cursor), Some(h_pos))
} else {
self.end()
(self.end(), None)
}
}
pub fn cursor_begin_of_row(&self, cursor: &Cursor) -> Cursor {
self.from_rcursor(RCursor {
row: cursor.rcursor.row,
pub fn cursor_begin_of_row(&self, cursor: &CCursor) -> CCursor {
let layout_cursor = self.layout_from_cursor(*cursor);
self.cursor_from_layout(LayoutCursor {
row: layout_cursor.row,
column: 0,
})
}
pub fn cursor_end_of_row(&self, cursor: &Cursor) -> Cursor {
self.from_rcursor(RCursor {
row: cursor.rcursor.row,
column: self.rows[cursor.rcursor.row].char_count_excluding_newline(),
pub fn cursor_end_of_row(&self, cursor: &CCursor) -> CCursor {
let layout_cursor = self.layout_from_cursor(*cursor);
self.cursor_from_layout(LayoutCursor {
row: layout_cursor.row,
column: self.rows[layout_cursor.row].char_count_excluding_newline(),
})
}
pub fn cursor_begin_of_paragraph(&self, cursor: &CCursor) -> CCursor {
let mut layout_cursor = self.layout_from_cursor(*cursor);
layout_cursor.column = 0;
loop {
let prev_row = layout_cursor
.row
.checked_sub(1)
.and_then(|row| self.rows.get(row));
let Some(prev_row) = prev_row else {
// This is the first row
break;
};
if prev_row.ends_with_newline {
break;
}
layout_cursor.row -= 1;
}
self.cursor_from_layout(layout_cursor)
}
pub fn cursor_end_of_paragraph(&self, cursor: &CCursor) -> CCursor {
let mut layout_cursor = self.layout_from_cursor(*cursor);
loop {
let row = &self.rows[layout_cursor.row];
if row.ends_with_newline || layout_cursor.row == self.rows.len() - 1 {
layout_cursor.column = row.char_count_excluding_newline();
break;
}
layout_cursor.row += 1;
}
self.cursor_from_layout(layout_cursor)
}
}