egui_richedit/lib.rs
1//! Rich-text editing for egui, over paragraphs the application lays out and
2//! owns itself.
3//!
4//! egui's `TextEdit` edits one string drawn in one format. A document editor
5//! draws paragraphs of styled runs among lists, tables and pictures of its own,
6//! and wants a caret that moves through all of them as if the page were one
7//! surface. This crate is that caret. The application keeps its document and
8//! its layout; it describes each paragraph's text as a [`ParagraphJob`] and
9//! implements [`Model`] so that an edit lands in its own document. A
10//! [`RichEdit`] turns keys, the pointer and the clipboard into [`Edit`]s, and
11//! paints the selection and the caret over the application's galleys.
12//!
13//! # A frame
14//!
15//! 1. [`RichEdit::input`], before anything is laid out, takes this frame's
16//! events and applies them to the model, so the layout that follows already
17//! shows what was typed.
18//! 2. Then, for every editable paragraph in document order, whether or not it
19//! is on screen: build its [`ParagraphJob`], lay it out, allocate a
20//! response that senses clicks and drags, and hand all of it to
21//! [`RichEdit::paragraph`], which paints it.
22//!
23//! # Offsets
24//!
25//! A [`Position`] is a paragraph and a character offset (characters, not
26//! bytes) into the model's text of that paragraph. What is drawn need not be
27//! that text: a tab drawn as spaces, a footnote's citation that the model does
28//! not count, a field's current value. [`ParagraphJob::atom`] records each such
29//! piece, and the caret steps over it whole.
30//
31// Author: David M. Anderson
32// Built with AI assistance (Claude, Anthropic)
33
34#![forbid(unsafe_code)]
35#![warn(missing_docs, clippy::pedantic)]
36#![allow(clippy::must_use_candidate)]
37
38mod editor;
39mod job;
40
41pub use editor::{Laid, RichEdit};
42pub use job::{OffsetMap, ParagraphJob};
43
44use std::fmt::Debug;
45use std::hash::Hash;
46
47/// Where a caret can stand: a paragraph, and a character offset into the
48/// model's text of it.
49#[derive(Clone, Debug, PartialEq, Eq, Hash)]
50pub struct Position<P> {
51 /// The paragraph, as the model names it.
52 pub paragraph: P,
53 /// Characters from the start of the paragraph's text.
54 pub offset: usize,
55}
56
57impl<P> Position<P> {
58 /// A position in a paragraph.
59 pub fn new(paragraph: P, offset: usize) -> Self {
60 Self { paragraph, offset }
61 }
62}
63
64/// A selection: where it began and where it has been taken to. The two are
65/// the same position when the selection is a caret.
66#[derive(Clone, Debug, PartialEq, Eq)]
67pub struct Selection<P> {
68 /// Where the selection began, which stays put while it is extended.
69 pub anchor: Position<P>,
70 /// Where the selection ends, which is where the caret is drawn.
71 pub focus: Position<P>,
72}
73
74impl<P: Clone + PartialEq> Selection<P> {
75 /// A caret: a selection of nothing, at a position.
76 pub fn caret(at: Position<P>) -> Self {
77 Self {
78 anchor: at.clone(),
79 focus: at,
80 }
81 }
82
83 /// Whether the selection selects nothing.
84 pub fn is_caret(&self) -> bool {
85 self.anchor == self.focus
86 }
87}
88
89/// A change the editor asks the model to make.
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub enum Edit<'a, P> {
92 /// Replace the text from one position to another with new text. The two
93 /// may be in different paragraphs, `from` first in document order; the
94 /// model then joins what is left of the first and the last. The new text
95 /// never holds a paragraph break: `'\n'` in it is a line break inside the
96 /// paragraph, and `'\t'` a tab.
97 Replace {
98 /// Where the replaced text begins.
99 from: Position<P>,
100 /// Where it ends.
101 to: Position<P>,
102 /// What goes in its place.
103 text: &'a str,
104 },
105 /// Split a paragraph in two at a position, the second half becoming the
106 /// paragraph after it.
107 Split {
108 /// Where the paragraph is split.
109 at: Position<P>,
110 },
111 /// Give the text from one position to another a mark, or take it off.
112 /// The two may be in different paragraphs, `from` first in document
113 /// order; no paragraph is joined or split.
114 Format {
115 /// Where the formatted text begins.
116 from: Position<P>,
117 /// Where it ends.
118 to: Position<P>,
119 /// Which mark.
120 mark: Mark,
121 /// Given, or taken off.
122 on: bool,
123 },
124}
125
126/// Formatting a range of text is given or has taken off. The set is fixed, so
127/// that neither the editor nor the model needs a style system to agree on.
128#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
129pub enum Mark {
130 /// Bold.
131 Bold,
132 /// Italic.
133 Italic,
134 /// Underlined.
135 Underline,
136 /// Struck through.
137 Strike,
138}
139
140impl Mark {
141 /// Every mark, in the order a toolbar shows them.
142 pub const ALL: [Self; 4] = [Self::Bold, Self::Italic, Self::Underline, Self::Strike];
143}
144
145/// The document, as the editor sees it. The application implements this over
146/// whatever it keeps its document in.
147pub trait Model {
148 /// Names a paragraph. An edit may change what any paragraph is called, so
149 /// the editor holds only the positions the model hands back.
150 type Paragraph: Clone + Eq + Hash + Debug;
151
152 /// The paragraph's text, with a line break as `'\n'` and a tab as `'\t'`.
153 /// `None` when the model has no such paragraph.
154 fn text(&self, paragraph: &Self::Paragraph) -> Option<String>;
155
156 /// The editable paragraph after this one in document order.
157 fn next(&self, paragraph: &Self::Paragraph) -> Option<Self::Paragraph>;
158
159 /// The editable paragraph before this one in document order.
160 fn previous(&self, paragraph: &Self::Paragraph) -> Option<Self::Paragraph>;
161
162 /// The first editable paragraph, `None` when there is none.
163 fn first(&self) -> Option<Self::Paragraph>;
164
165 /// The last editable paragraph, `None` when there is none.
166 fn last(&self) -> Option<Self::Paragraph>;
167
168 /// Whether the text from one position to another carries a mark: `Some`
169 /// when all of it says the same, `None` when it differs. Two positions
170 /// that are the same answer for the text typed there would take its
171 /// formatting from. A model that keeps no formatting leaves this as it
172 /// is, which says nothing is marked, and refuses [`Edit::Format`].
173 fn marked(
174 &self,
175 from: &Position<Self::Paragraph>,
176 to: &Position<Self::Paragraph>,
177 mark: Mark,
178 ) -> Option<bool> {
179 let _ = (from, to, mark);
180 Some(false)
181 }
182
183 /// Make an edit, and answer where the caret stands after it: after the
184 /// replacing text, at the start of the second half of a split, or for a
185 /// format at the end of the formatted text. `None` refuses the edit and
186 /// leaves the document as it was.
187 ///
188 /// `new_step` says whether the edit begins a new undo step or continues
189 /// the one before it: the editor groups a run of typing into one step. An
190 /// application that keeps undo as snapshots takes one when `new_step` is
191 /// true and the edit succeeds.
192 fn apply(
193 &mut self,
194 edit: Edit<'_, Self::Paragraph>,
195 new_step: bool,
196 ) -> Option<Position<Self::Paragraph>>;
197}