Skip to content

Instantly share code, notes, and snippets.

@pragmatrix
Created March 31, 2023 10:33
Show Gist options
  • Save pragmatrix/0c7b8be33d9f54982fd47789fffd543a to your computer and use it in GitHub Desktop.
Save pragmatrix/0c7b8be33d9f54982fd47789fffd543a to your computer and use it in GitHub Desktop.
skia-safe based paragraph editor
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