Created
March 31, 2023 10:33
-
-
Save pragmatrix/0c7b8be33d9f54982fd47789fffd543a to your computer and use it in GitHub Desktop.
skia-safe based paragraph editor
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
use crate::{ | |
entities::{colors, DynId}, | |
geometry::{scalar, Point}, | |
input::{ | |
self, | |
tracker::{movement, Movement}, | |
}, | |
renderer::paragraph::{self, Paragraph}, | |
Jump, UnicodeHelper, | |
}; | |
use input::Clipboard; | |
use skia_safe::{paint, textlayout::FontCollection, Color, Paint}; | |
use std::{ | |
cmp::{max, min}, | |
convert::TryInto, | |
ops::Range, | |
}; | |
use winit::event::{ElementState, MouseButton}; | |
/// - We need to pass the location to `map_to_paragraph` and render, because `ConnectorLabel` does | |
/// not actually know its own location. | |
pub trait Editable { | |
fn paragraph(&self, font_collection: &FontCollection) -> Paragraph; | |
/// Converts a entity relative position to a position that is paragraph relative. | |
/// TODO: this should probably pass the paragraph computed. | |
fn map_to_paragraph( | |
&self, | |
location: Point, | |
pos: Point, | |
font_collection: &FontCollection, | |
) -> Point; | |
} | |
#[derive(Debug)] | |
pub struct ParagraphEditor { | |
state: State, | |
// Hit testing identifier. | |
pub entity_id: DynId, | |
location: Point, | |
original_text: String, | |
pub text: String, | |
// Recreating the paragraph needs `FontCollection`. | |
font_collection: FontCollection, | |
selection: Selection, | |
cursor_x_pos_affinity: Option<f64>, | |
changes: EditorChanges, | |
} | |
#[derive(Debug)] | |
pub enum State { | |
/// Regular editing mode. | |
Editing, | |
/// Mouse based text selection. | |
Selecting(Movement), | |
} | |
#[derive(Debug)] | |
pub enum Result { | |
// TODO: may indicate using a flag when the text has been changed. | |
Continue, | |
Commit(Option<input::Event>), | |
Cancel, | |
} | |
#[derive(Clone, PartialEq, Eq, Debug, Default)] | |
pub struct Selection { | |
pub start: usize, | |
pub cursor: usize, | |
} | |
impl Selection { | |
fn at_cursor(cursor: usize) -> Self { | |
Self { | |
start: cursor, | |
cursor, | |
} | |
} | |
fn from_range(range: Range<usize>) -> Self { | |
assert!(range.end >= range.start); | |
Self { | |
start: range.start, | |
cursor: range.end, | |
} | |
} | |
pub fn cursor(&self) -> usize { | |
self.cursor | |
} | |
pub fn has_selection(&self) -> bool { | |
self.start != self.cursor | |
} | |
/// The selection. Returns the start of the selection, and the cursor position. | |
fn selection(&self) -> (usize, usize) { | |
(self.start, self.cursor) | |
} | |
/// Moves the cursor, keeps or adds a selection if `selected` is `true`. Otherwise removes it. | |
fn move_cursor(&mut self, cursor: usize, selected: bool) { | |
self.cursor = cursor; | |
if !selected { | |
self.start = cursor | |
} | |
} | |
pub fn to_range(&self) -> Range<usize> { | |
let (start, cursor) = (self.start, self.cursor); | |
Range { | |
start: min(start, cursor), | |
end: max(start, cursor), | |
} | |
} | |
} | |
pub const CURSOR_COLOR: Color = colors::CURSOR; | |
pub const CURSOR_WIDTH: scalar = 2.0; | |
impl ParagraphEditor { | |
/// - `location` of the entity. Note that some entities do not store their own location, so we | |
/// thread it through to [`Editable::map_to_paragraph`] | |
pub fn begin( | |
entity_id: DynId, | |
location: Point, | |
text: &str, | |
font_collection: &FontCollection, | |
) -> Self { | |
let selection = Selection::from_range(Range { | |
start: 0, | |
end: text.len(), | |
}); | |
Self { | |
state: State::Editing, | |
// TODO: this puts a 'static requirement on `E`. | |
entity_id, | |
location, | |
original_text: text.to_owned(), | |
text: text.to_owned(), | |
font_collection: font_collection.clone(), | |
selection, | |
cursor_x_pos_affinity: None, | |
changes: Default::default(), | |
} | |
} | |
pub fn process( | |
&mut self, | |
editable: &dyn Editable, | |
event: input::Event, | |
context: &mut input::Context, | |
) -> Result { | |
match self.state { | |
State::Editing => self.editing(editable, event, context), | |
State::Selecting(ref mut movement) => { | |
let from = movement.from; | |
let r = movement.track(&event); | |
self.selecting(editable, from, r); | |
Result::Continue | |
} | |
} | |
} | |
fn editing( | |
&mut self, | |
editable: &dyn Editable, | |
event: input::Event, | |
context: &mut input::Context, | |
) -> Result { | |
use input::MouseGesture::*; | |
match event.detect_mouse_gesture(MouseButton::Left) { | |
Click(pos) => { | |
if !self.hits_us(pos, context) { | |
// Single click outside commits the text and ends the editor | |
return Result::Commit(Some(event)); | |
}; | |
// Single click inside entity results into positioning the cursor. | |
let cursor = self.cursor_at(editable, pos); | |
self.move_cursor(cursor, false, None); | |
return Result::Continue; | |
} | |
DoubleClick(pos) => { | |
if !self.hits_us(pos, context) { | |
// Single click outside commits the text and ends the editor | |
return Result::Commit(Some(event)); | |
} | |
let cursor = self.cursor_at(editable, pos); | |
self.select_word_at(cursor); | |
return Result::Continue; | |
} | |
Movement(movement) => { | |
let pos = movement.from; | |
if !self.hits_us(pos, context) { | |
// Movement outside, commits the text and also redelivers the event. | |
// TODO: This result is a pattern. Similar to detect_click and detect_double_click. | |
// TODO: The combined detection of clicks, double_clicks and movements is a pattern. | |
return Result::Commit(Some(event)); | |
}; | |
let cursor = self.cursor_at(editable, pos); | |
self.move_cursor(cursor, false, None); | |
self.state = State::Selecting(movement); | |
return Result::Continue; | |
} | |
_ => {} | |
} | |
let (shift, ctrl) = (event.states().is_shift(), event.states().is_ctrl()); | |
let jump = if ctrl { | |
Jump::WordStart | |
} else { | |
Jump::Grapheme | |
}; | |
let clipboard = &mut context.clipboard; | |
use winit::event::WindowEvent::*; | |
match event.window_event() { | |
Some(KeyboardInput { input, .. }) if input.state == ElementState::Pressed => { | |
if let Some(code) = input.virtual_keycode { | |
use winit::event::VirtualKeyCode::*; | |
match code { | |
Escape => { | |
self.text = self.original_text.clone(); | |
return Result::Cancel; | |
} | |
Insert => { | |
if shift { | |
self.paste(clipboard) | |
} | |
} | |
Home => { | |
if ctrl { | |
self.begin_of_document(shift); | |
} else { | |
self.begin_of_line(shift); | |
} | |
} | |
Delete => {} | |
End => { | |
if ctrl { | |
self.end_of_document(shift); | |
} else { | |
self.end_of_line(shift); | |
} | |
} | |
PageDown => self.end_of_document(shift), | |
PageUp => self.begin_of_document(shift), | |
Left => { | |
if shift { | |
self.select_left(jump) | |
} else { | |
self.cursor_left(jump) | |
} | |
} | |
Up => self.cursor_up(editable, shift), | |
Right => { | |
if shift { | |
self.select_right(jump) | |
} else { | |
self.cursor_right(jump) | |
} | |
} | |
Down => self.cursor_down(editable, shift), | |
Back => { | |
// can't use \u{0008}, because ctrl + Back is translated as a Delete | |
self.backspace(jump) | |
} | |
// TODO: this could be interesting. Should probably locate to undo selection | |
// points? | |
NavigateForward => {} | |
NavigateBackward => {} | |
Sysrq => {} | |
Tab => {} | |
// Copy, Paste, Cut don't work on Windows. | |
Copy => {} | |
Paste => {} | |
Cut => {} | |
_ => {} | |
} | |
} | |
} | |
Some(ReceivedCharacter(c)) => { | |
if !c.is_control() { | |
self.insert(c.to_string(), None); | |
return Result::Continue; | |
} | |
match c { | |
'\r' if shift => self.insert("\n", None), | |
'\r' => return Result::Commit(None), | |
'\u{007f}' => { | |
if shift { | |
self.copy(clipboard); | |
} | |
self.delete(jump); | |
} | |
'\u{0001}' => self.select_all(), | |
'\u{0003}' => self.copy(clipboard), | |
'\u{0016}' => self.paste(clipboard), | |
'\u{0018}' => self.cut(clipboard), | |
'\u{001a}' => self.undo(), | |
'\u{0019}' => self.redo(), | |
_ => {} | |
} | |
} | |
_ => {} | |
} | |
Result::Continue | |
} | |
fn selecting(&mut self, editable: &dyn Editable, from: Point, movement: movement::Result) { | |
use movement::Result::*; | |
match movement { | |
Move(d) => { | |
// TODO: should probably be a function in movement. And why is delta even in | |
// Move? | |
let pos = from + d; | |
let cursor = self.cursor_at(editable, pos); | |
self.move_cursor(cursor, true, None); | |
} | |
Commit(_) => self.state = State::Editing, | |
Cancel => { | |
// TODO: Add a function `clear_selection()`. | |
self.move_cursor(self.cursor(), false, None); | |
} | |
Continue => {} | |
} | |
} | |
/// Deletes the current selection or if no selection the character to the left of the cursor. | |
fn backspace(&mut self, jump: Jump) { | |
let selection_before = self.selection.clone(); | |
if !self.has_selection() { | |
self.select_left(jump); | |
} | |
self.insert("", selection_before); | |
} | |
fn delete(&mut self, jump: Jump) { | |
let selection_before = self.selection.clone(); | |
if !self.has_selection() { | |
self.select_right(jump); | |
} | |
self.insert("", selection_before) | |
} | |
fn cursor_left(&mut self, jump: Jump) { | |
if jump == Jump::Grapheme && self.has_selection() { | |
let (start, cursor) = self.selection.selection(); | |
self.move_cursor(min(start, cursor), false, None); | |
return; | |
} | |
let cursor = self.cursor(); | |
let cursor = self.text().prev(cursor, jump).unwrap_or(0); | |
self.move_cursor(cursor, false, None); | |
} | |
fn cursor_right(&mut self, jump: Jump) { | |
if jump == Jump::Grapheme && self.has_selection() { | |
let (start, cursor) = self.selection.selection(); | |
self.move_cursor(max(start, cursor), false, None); | |
return; | |
} | |
let cursor = self.cursor(); | |
let cursor = self | |
.text() | |
.next(cursor, jump) | |
.unwrap_or_else(|| self.text().len()); | |
self.move_cursor(cursor, false, None); | |
} | |
fn select_left(&mut self, jump: Jump) { | |
let cursor = self.text().prev(self.cursor(), jump).unwrap_or(0); | |
self.move_cursor(cursor, true, None) | |
} | |
fn select_right(&mut self, jump: Jump) { | |
let cursor = self | |
.text() | |
.next(self.cursor(), jump) | |
.unwrap_or_else(|| self.text().len()); | |
self.move_cursor(cursor, true, None) | |
} | |
fn select_all(&mut self) { | |
self.move_cursor(0, false, None); | |
self.move_cursor(self.text().len(), true, None); | |
} | |
fn select_word_at(&mut self, cursor: usize) { | |
let before = self.text().prev_word_start(cursor).unwrap_or(0); | |
let after = self | |
.text() | |
.next_word_start(cursor) | |
.unwrap_or_else(|| self.text().len()); | |
// TODO: this should be one function | |
// TODO: this deserves a bit more sophistication. Take a look at how VSCode word selection | |
// works. | |
self.move_cursor(before, false, None); | |
self.move_cursor(after, true, None); | |
} | |
fn begin_of_document(&mut self, select: bool) { | |
self.move_cursor(0, select, None); | |
self.cursor_x_pos_affinity = None; | |
} | |
fn end_of_document(&mut self, select: bool) { | |
self.move_cursor(self.text().len(), select, None); | |
} | |
fn begin_of_line(&mut self, select: bool) { | |
let begin = self.text()[..self.cursor()] | |
.char_indices() | |
.rev() | |
.find_map(|(i, c)| (c == '\n').then(|| i + 1)) | |
.unwrap_or(0); | |
self.move_cursor(begin, select, None); | |
} | |
fn end_of_line(&mut self, select: bool) { | |
let cursor = self.cursor(); | |
let end = self.text()[cursor..] | |
.char_indices() | |
.find_map(|(i, c)| (c == '\n').then(|| cursor + i)) | |
.unwrap_or_else(|| self.text().len()); | |
self.move_cursor(end, select, None); | |
} | |
fn cursor_up(&mut self, editable: &dyn Editable, select: bool) { | |
let paragraph = &self.paragraph(editable); | |
let line_index = Self::line_index(paragraph, self.cursor()); | |
if line_index == 0 { | |
return self.begin_of_document(select); | |
} | |
self.move_to_line(paragraph, line_index - 1, select) | |
} | |
fn cursor_down(&mut self, editable: &dyn Editable, select: bool) { | |
let paragraph = &self.paragraph(editable); | |
let line_index = Self::line_index(paragraph, self.cursor()); | |
if line_index + 1 == paragraph.line_metrics().len() { | |
return self.end_of_document(select); | |
} | |
self.move_to_line(paragraph, line_index + 1, select) | |
} | |
fn move_to_line(&mut self, paragraph: &Paragraph, line_index: usize, select: bool) { | |
let x = self.resolve_cursor_x_pos_affinity(paragraph, self.cursor()); | |
let pos = paragraph.position_in_line(line_index, x); | |
let cursor = self | |
.text() | |
.utf16_index_to_byte_index(pos.position.try_into().unwrap()); | |
self.move_cursor(cursor, select, x); | |
} | |
/// Returns the cursor position at the untranslated coordinate. | |
fn cursor_at(&self, editable: &dyn Editable, p: impl Into<Point>) -> usize { | |
let pos = editable.map_to_paragraph(self.location, p.into(), &self.font_collection); | |
let pos = self.paragraph(editable).position(pos); | |
self.text() | |
.utf16_index_to_byte_index(pos.position.try_into().unwrap()) | |
} | |
fn resolve_cursor_x_pos_affinity(&mut self, paragraph: &Paragraph, cursor: usize) -> scalar { | |
if let Some(affinity) = self.cursor_x_pos_affinity { | |
return affinity; | |
} | |
if let Some((Point { x, .. }, _)) = paragraph.cursor_pos_and_height(cursor) { | |
return x; | |
} | |
0.0 | |
} | |
fn line_index(paragraph: &Paragraph, cursor: usize) -> usize { | |
paragraph | |
.line_metrics() | |
.iter() | |
.enumerate() | |
.rev() | |
.find_map(|(i, lm)| (lm.start_index <= cursor).then(|| i)) | |
.unwrap_or(0) | |
} | |
fn copy(&self, clipboard: &mut Clipboard) { | |
if self.has_selection() { | |
let text = &self.text()[self.selection.to_range()]; | |
// TODO: log if that fails! | |
let _ = clipboard.write(text.to_string()); | |
} | |
} | |
fn paste(&mut self, clipboard: &Clipboard) { | |
if let Ok(str) = clipboard.read() { | |
self.insert(&str, None) | |
} | |
} | |
fn cut(&mut self, clipboard: &mut Clipboard) { | |
self.copy(clipboard); | |
self.insert("", None); | |
} | |
/// Replaces the selection with `str`, sets the cursor to the right of the inserted string and | |
/// adds an [`EditorChange`] record. | |
/// | |
/// - `selection_before` Can be set to modify the undo record. Useful for ignoring the current | |
/// selection when it is used temporarily to the define the range to be deleted. | |
fn insert(&mut self, str: impl AsRef<str>, selection_before: impl Into<Option<Selection>>) { | |
let str = str.as_ref(); | |
let range = self.selection.to_range(); | |
let selection_before = selection_before | |
.into() | |
.unwrap_or_else(|| self.selection.clone()); | |
let removed = self.text()[range.clone()].to_string(); | |
let new_cursor_pos = range.start + str.len(); | |
self.move_cursor(new_cursor_pos, false, None); | |
self.replace_text(range.clone(), str); | |
let change = EditorChange { | |
selection_before, | |
text: TextChange { | |
pos: range.start, | |
removed, | |
inserted: str.to_string(), | |
}, | |
selection_after: self.selection.clone(), | |
}; | |
self.changes.push(change); | |
} | |
fn undo(&mut self) { | |
if let Some(change) = self.changes.undo() { | |
// TODO: need to clone change here, otherwise we self would be borrowed more than once. | |
let change = change.clone(); | |
let text = &change.text; | |
self.replace_text( | |
Range { | |
start: text.pos, | |
end: text.pos + text.inserted.len(), | |
}, | |
&text.removed, | |
); | |
self.selection = change.selection_before.clone(); | |
} | |
} | |
fn redo(&mut self) { | |
if let Some(change) = self.changes.redo() { | |
let change = change.clone(); | |
let text = &change.text; | |
self.replace_text( | |
Range { | |
start: text.pos, | |
end: text.pos + text.removed.len(), | |
}, | |
&text.inserted, | |
); | |
self.selection = change.selection_after.clone(); | |
} | |
} | |
fn replace_text(&mut self, range: Range<usize>, text: &str) { | |
self.text.replace_range(range, text); | |
} | |
// Moves the cursor and clears the x pos affinity. | |
fn move_cursor( | |
&mut self, | |
cursor: usize, | |
select: bool, | |
x_pos_affinity: impl Into<Option<scalar>>, | |
) { | |
self.selection.move_cursor(cursor, select); | |
self.cursor_x_pos_affinity = x_pos_affinity.into(); | |
} | |
// TODO: old artifact, may remove. | |
fn text(&self) -> &str { | |
&self.text | |
} | |
fn has_selection(&self) -> bool { | |
self.selection.has_selection() | |
} | |
/// Returns the cursor index, if the cursor is visible (there is no selection). | |
fn cursor(&self) -> usize { | |
self.selection.cursor() | |
} | |
/// Returns `true` if the given entity relative point hits the editor. | |
fn hits_us(&self, p: impl Into<Point>, context: &mut input::Context) -> bool { | |
let first_hit = context.hit_test(p.into()).next(); | |
first_hit == Some(self.entity_id) | |
} | |
pub fn paragraph_style(&self) -> paragraph::Style { | |
let selection = &self.selection; | |
let selection_range = selection.has_selection().then(|| { | |
let selection_paint = Paint::default() | |
// TODO: this selection color is Node specific (should be retrieved from the Entity trait). | |
.set_color(colors::SELECTED) | |
.clone(); | |
(selection.to_range(), selection_paint) | |
}); | |
let cursor = { | |
let paint = Paint::default() | |
.set_color(CURSOR_COLOR) | |
.set_stroke_width(CURSOR_WIDTH as skia_safe::scalar) | |
.set_style(paint::Style::Stroke) | |
.clone(); | |
Some((selection.cursor, paint)) | |
}; | |
paragraph::Style { | |
selection: selection_range, | |
cursor, | |
} | |
} | |
fn paragraph(&self, editable: &dyn Editable) -> Paragraph { | |
// TODO: this parameterization might completely be externalized (since now we pass &dyn | |
// Editable, we could construct it based on the entity's text and the context's | |
// font_collection) | |
editable.paragraph(&self.font_collection) | |
} | |
} | |
#[derive(Default, Debug)] | |
struct EditorChanges { | |
current: usize, | |
changes: Vec<EditorChange>, | |
} | |
#[derive(Clone, Debug)] | |
struct EditorChange { | |
selection_before: Selection, | |
text: TextChange, | |
selection_after: Selection, | |
} | |
#[derive(Clone, Debug)] | |
struct TextChange { | |
pos: usize, | |
removed: String, | |
inserted: String, | |
} | |
impl EditorChanges { | |
fn push(&mut self, change: EditorChange) { | |
// No more redo. | |
self.changes.truncate(self.current); | |
// If possible t combine with previous, do it. | |
if let Some(previous) = self.changes.last() { | |
if let Some(combined) = previous.try_combine_with(&change) { | |
self.changes[self.current - 1] = combined; | |
return; | |
} | |
} | |
self.changes.push(change); | |
self.current = self.changes.len(); | |
} | |
fn undo(&mut self) -> Option<&EditorChange> { | |
if self.current == 0 { | |
return None; | |
} | |
self.current -= 1; | |
Some(&self.changes[self.current]) | |
} | |
fn redo(&mut self) -> Option<&EditorChange> { | |
if self.current == self.changes.len() { | |
return None; | |
} | |
self.current += 1; | |
Some(&self.changes[self.current - 1]) | |
} | |
} | |
impl EditorChange { | |
fn try_combine_with(&self, other: &Self) -> Option<Self> { | |
let cursor_after_self = Selection::at_cursor(self.text.pos + self.text.inserted.len()); | |
let cursor_after_other = Selection::at_cursor(other.text.pos + other.text.inserted.len()); | |
// Position must be at the cursor | |
(self.selection_after == cursor_after_self | |
&& !self.text.inserted.ends_with('\n') | |
&& other.selection_before == cursor_after_self | |
&& other.text.pos == cursor_after_self.cursor() | |
&& other.text.removed.is_empty() | |
&& other.selection_after == cursor_after_other) | |
.then(|| EditorChange { | |
selection_before: self.selection_before.clone(), | |
text: TextChange { | |
pos: self.text.pos, | |
removed: self.text.removed.clone(), | |
inserted: self.text.inserted.clone() + other.text.inserted.as_str(), | |
}, | |
selection_after: other.selection_after.clone(), | |
}) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment