Skip to main content

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}