Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,33 @@ the shape, so a label's paragraphs take clicks alone and the caret goes down
on the click; Shift with the arrows selects. The arrows and the page keys step
through the slides only while nothing has the keyboard.

**Text is made bold, italic, underlined or struck through**, and nothing else
is formatting a person can apply. Ctrl+B, Ctrl+I and Ctrl+U, or the row of
buttons over the page in edit mode, give the selection the mark or take it off
where all of it already has it; strikethrough has a button and no key. With a
caret and nothing selected, the mark goes to what is typed next at the caret,
and moving the caret forgets it. A button is lit where the whole selection has
its mark, and for a caret where the text typed there would. Each is one undo
step, and a mark put on with typing is the same step as the typing. A
spreadsheet cell is not formatted.

**A mark changes spans and automatic styles and nothing else.** The range's
ends are cut into the nodes they fall inside. A span the range holds whole has
its style changed; any other run of what the range holds is wrapped in a new
span inside whatever holds it, a link included, so no link or span the
document had is taken apart. A property is written only where it changes what
is drawn: a span inside the range whose own style says otherwise stops saying
it, a span left saying nothing gives way to its contents, and taking a mark off
text it was put on gives back the paragraph it was. Bold and italic are written
for Asian and complex scripts too. A span's style is an automatic style in
`content.xml`, one the document already holds wherever one says the same, and
otherwise a new one named `T` and the first number no text style has; `Styles`
learns of each as it is written and forgets none, so an undo never frees a name
for a different style. A document with no automatic styles is given the
container before its body. `crates/odox-core/src/edit/format.rs` is the
mechanism; `tests/format.rs` formats a range of every paragraph in the corpus
and holds everything outside it equal.

**A paragraph is edited through its flat text**, built from the tree and not
from the renderer's layout: a `text:s` is its spaces, a `text:tab` a tab, a
`text:line-break` a newline, a span's or a link's contents the paragraph's own
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ ODF is a document they are meant to open, whichever application wrote it.

Editing means changing what is there, not authoring. You can type and delete
text, split a paragraph and join two, enter a value in a cell, move a shape on a
slide and resize it, undo any of that, and save. You cannot apply formatting,
insert a table or a picture, edit a formula, or find and replace. What you did
slide and resize it, make text bold, italic, underlined or struck through,
undo any of that, and save. You cannot apply other formatting, insert a table or
a picture, edit a formula, or find and replace. What you did
not touch is written back as it was read, element for element, including the
parts these applications have no opinion about: the document you save is the
document you opened, with your change in it.
Expand Down Expand Up @@ -96,7 +97,10 @@ as it is drawn, formatting and all. Enter starts a new paragraph, Shift+Enter
breaks the line, Backspace at the start of a paragraph joins it to the one
before, and the arrows, Page Up and Page Down, Ctrl+Home and Ctrl+End, Ctrl+A,
Shift and the clipboard work across paragraphs the way they do in any word
processor. Undo puts the caret back where the edit was.
processor. Ctrl+B, Ctrl+I and Ctrl+U, or the buttons over the page, make the
selection bold, italic or underlined, or what you type next when nothing is
selected; strikethrough is a button. Undo puts the caret back where the edit
was.

`xods` draws the sheet as a grid with the document's own column widths and cell
styles, one tab per sheet, and shows the formula behind whichever cell you pick.
Expand Down
2 changes: 2 additions & 0 deletions crates/egui_richedit/examples/notes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ impl Model for Notes {
self.paragraphs.insert(at.paragraph + 1, second);
Some(Position::new(at.paragraph + 1, 0))
}
// Plain notes keep no formatting.
Edit::Format { .. } => None,
}
}
}
Expand Down
131 changes: 127 additions & 4 deletions crates/egui_richedit/src/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use egui::{
Response, Sense, Ui, vec2,
};

use crate::{Edit, Model, OffsetMap, Position, Selection};
use crate::{Edit, Mark, Model, OffsetMap, Position, Selection};

/// Keys the editor keeps while it has the focus, rather than letting egui
/// move the focus to another widget with them. Escape is not kept: it is how
Expand Down Expand Up @@ -79,6 +79,16 @@ pub struct RichEdit<P> {
/// When the caret last moved or the text last changed, which is when its
/// blink restarts.
last_interaction: f64,
/// Marks given or taken off at a caret, for the text typed there next.
pending: Option<Pending<P>>,
}

/// Marks given or taken off with a caret and nothing selected. The next text
/// typed at that caret takes them; once the caret is anywhere else, or the
/// document has changed, they are forgotten.
struct Pending<P> {
at: Position<P>,
marks: Vec<(Mark, bool)>,
}

/// What the caret is owed by the next frame that draws it.
Expand Down Expand Up @@ -131,6 +141,7 @@ impl<P: Clone + Eq + std::hash::Hash + std::fmt::Debug> RichEdit<P> {
page_height: 0.0,
dragging: false,
last_interaction: 0.0,
pending: None,
}
}

Expand All @@ -145,6 +156,7 @@ impl<P: Clone + Eq + std::hash::Hash + std::fmt::Debug> RichEdit<P> {
self.selection = Some(selection);
self.group = None;
self.column = None;
self.pending = None;
self.owed = Owed::FocusAndReveal;
}

Expand All @@ -154,6 +166,7 @@ impl<P: Clone + Eq + std::hash::Hash + std::fmt::Debug> RichEdit<P> {
self.group = None;
self.column = None;
self.dragging = false;
self.pending = None;
}

/// The document was changed or replaced by something other than this
Expand All @@ -162,6 +175,63 @@ impl<P: Clone + Eq + std::hash::Hash + std::fmt::Debug> RichEdit<P> {
pub fn document_replaced(&mut self) {
self.group = None;
self.column = None;
self.pending = None;
}

/// Whether the selection carries a mark, as a toolbar shows it: `None`
/// where part of it does, and for a caret what text typed there would
/// take, a mark given or taken off at the caret included. `None` too when
/// there is no caret.
pub fn marked<M: Model<Paragraph = P>>(&self, model: &M, mark: Mark) -> Option<bool> {
let selection = self.selection.as_ref()?;
if let Some(on) = self.pending_mark(mark) {
return Some(on);
}
let (from, to) = self.ordered(selection);
model.marked(&from, &to, mark)
}

/// Give the selection a mark, or take it off where all of it has it, in
/// one undo step; with a caret and nothing selected, give it to or take it
/// off what is typed next at the caret. Answers whether the document
/// changed. The editor takes the keyboard back from whatever asked, as a
/// toolbar's button does.
pub fn toggle<M: Model<Paragraph = P>>(&mut self, model: &mut M, mark: Mark) -> bool {
let Some(selection) = self.selection.clone() else {
return false;
};
self.owed = Owed::FocusAndReveal;
let on = self.marked(model, mark) != Some(true);
if selection.is_caret() {
let at = selection.focus;
let mut marks = match self.pending.take() {
Some(pending) if pending.at == at => pending.marks,
_ => Vec::new(),
};
marks.retain(|(m, _)| *m != mark);
if model.marked(&at, &at, mark) != Some(on) {
marks.push((mark, on));
}
self.pending = (!marks.is_empty()).then_some(Pending { at, marks });
return false;
}
let (from, to) = self.ordered(&selection);
self.group = None;
let changed = self.format(model, from, to, mark, on, Group::Other);
self.group = None;
changed
}

/// What a mark given or taken off at the caret says, while the caret is
/// where it was then.
fn pending_mark(&self, mark: Mark) -> Option<bool> {
let selection = self.selection.as_ref().filter(|s| s.is_caret())?;
let pending = self.pending.as_ref().filter(|p| p.at == selection.focus)?;
pending
.marks
.iter()
.find(|(m, _)| *m == mark)
.map(|(_, on)| *on)
}

/// Take this frame's events and apply them to the model. Called once a
Expand Down Expand Up @@ -322,7 +392,10 @@ impl<P: Clone + Eq + std::hash::Hash + std::fmt::Debug> RichEdit<P> {
pressed: true,
modifiers,
..
} => self.key(model, *key, *modifiers),
} => match shortcut(*key).filter(|_| modifiers.command) {
Some(mark) => (false, self.toggle(model, mark)),
None => self.key(model, *key, *modifiers),
},
_ => (false, false),
}
}
Expand Down Expand Up @@ -447,13 +520,31 @@ impl<P: Clone + Eq + std::hash::Hash + std::fmt::Debug> RichEdit<P> {
}
}

/// Replace the selection with text, as typing does.
/// Replace the selection with text, as typing does, and give what was
/// typed the marks given or taken off at the caret.
fn type_text<M: Model<Paragraph = P>>(&mut self, model: &mut M, text: &str) -> bool {
let Some(selection) = &self.selection else {
return false;
};
let (from, to) = self.ordered(selection);
self.apply(model, Edit::Replace { from, to, text }, Group::Typing)
let marks: Vec<(Mark, bool)> = Mark::ALL
.into_iter()
.filter_map(|mark| self.pending_mark(mark).map(|on| (mark, on)))
.collect();
let edit = Edit::Replace {
from: from.clone(),
to,
text,
};
if !self.apply(model, edit, Group::Typing) {
return false;
}
if let Some(end) = self.selection.as_ref().map(|s| s.focus.clone()) {
for (mark, on) in marks {
self.format(model, from.clone(), end.clone(), mark, on, Group::Typing);
}
}
true
}

/// Paste text, each of its lines after the first a paragraph of its own,
Expand Down Expand Up @@ -518,6 +609,27 @@ impl<P: Clone + Eq + std::hash::Hash + std::fmt::Debug> RichEdit<P> {
self.selection = Some(Selection::caret(at));
self.next_row = false;
self.column = None;
self.pending = None;
true
}

/// Hand a format to the model, as [`Self::apply`] does an edit, leaving
/// the selection where it is.
fn format<M: Model<Paragraph = P>>(
&mut self,
model: &mut M,
from: Position<P>,
to: Position<P>,
mark: Mark,
on: bool,
group: Group,
) -> bool {
let new_step = self.group != Some(group);
let edit = Edit::Format { from, to, mark, on };
if model.apply(edit, new_step).is_none() {
return false;
}
self.group = Some(group);
true
}

Expand Down Expand Up @@ -817,6 +929,17 @@ impl<P: Clone + Eq + std::hash::Hash + std::fmt::Debug> RichEdit<P> {
}
}

/// The mark a key toggles with Ctrl, or Command on a Mac. Strikethrough has
/// no shortcut that people share, so it has none.
fn shortcut(key: Key) -> Option<Mark> {
match key {
Key::B => Some(Mark::Bold),
Key::I => Some(Mark::Italic),
Key::U => Some(Mark::Underline),
_ => None,
}
}

/// How many characters a paragraph holds, or `None` when it is gone.
fn len<M: Model>(model: &M, paragraph: &M::Paragraph) -> Option<usize> {
model.text(paragraph).map(|text| text.chars().count())
Expand Down
52 changes: 50 additions & 2 deletions crates/egui_richedit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,38 @@ pub enum Edit<'a, P> {
/// Where the paragraph is split.
at: Position<P>,
},
/// Give the text from one position to another a mark, or take it off.
/// The two may be in different paragraphs, `from` first in document
/// order; no paragraph is joined or split.
Format {
/// Where the formatted text begins.
from: Position<P>,
/// Where it ends.
to: Position<P>,
/// Which mark.
mark: Mark,
/// Given, or taken off.
on: bool,
},
}

/// Formatting a range of text is given or has taken off. The set is fixed, so
/// that neither the editor nor the model needs a style system to agree on.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Mark {
/// Bold.
Bold,
/// Italic.
Italic,
/// Underlined.
Underline,
/// Struck through.
Strike,
}

impl Mark {
/// Every mark, in the order a toolbar shows them.
pub const ALL: [Self; 4] = [Self::Bold, Self::Italic, Self::Underline, Self::Strike];
}

/// The document, as the editor sees it. The application implements this over
Expand All @@ -133,9 +165,25 @@ pub trait Model {
/// The last editable paragraph, `None` when there is none.
fn last(&self) -> Option<Self::Paragraph>;

/// Whether the text from one position to another carries a mark: `Some`
/// when all of it says the same, `None` when it differs. Two positions
/// that are the same answer for the text typed there would take its
/// formatting from. A model that keeps no formatting leaves this as it
/// is, which says nothing is marked, and refuses [`Edit::Format`].
fn marked(
&self,
from: &Position<Self::Paragraph>,
to: &Position<Self::Paragraph>,
mark: Mark,
) -> Option<bool> {
let _ = (from, to, mark);
Some(false)
}

/// Make an edit, and answer where the caret stands after it: after the
/// replacing text, or at the start of the second half of a split. `None`
/// refuses the edit and leaves the document as it was.
/// replacing text, at the start of the second half of a split, or for a
/// format at the end of the formatted text. `None` refuses the edit and
/// leaves the document as it was.
///
/// `new_step` says whether the edit begins a new undo step or continues
/// the one before it: the editor groups a run of typing into one step. An
Expand Down
Loading
Loading