use kurbo::{Affine, Rect};
use parley::PositionedLayoutItem;
use peniko::{Color, Fill};
use vello::Scene;
use crate::clipboard::Clipboard;
use crate::events::{Key, KeyInput};
use crate::text::{Fonts, LayoutBrush, ResolvedText};
const BLINK_HALF_PERIOD: f64 = 0.53;
const CARET_WIDTH: f64 = 1.5;
pub(crate) struct EditorState {
pub editor: parley::PlainEditor<LayoutBrush>,
pub scroll_x: f64,
pub last_activity: f64,
pub seen: u64,
pub multiline: bool,
pub undo: UndoStack,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EditRun {
Insert,
Delete,
}
#[derive(Debug, Clone)]
struct Snapshot {
text: String,
selection: (usize, usize),
}
#[derive(Debug, Default)]
pub(crate) struct UndoStack {
undos: Vec<Snapshot>,
redos: Vec<Snapshot>,
run: Option<EditRun>,
}
const UNDO_LIMIT: usize = 100;
fn snapshot(editor: &parley::PlainEditor<LayoutBrush>) -> Snapshot {
let range = editor.raw_selection().text_range();
Snapshot {
text: editor.raw_text().to_owned(),
selection: (range.start, range.end),
}
}
impl UndoStack {
fn begin(&mut self, editor: &parley::PlainEditor<LayoutBrush>, kind: EditRun) {
if self.run != Some(kind) {
self.undos.push(snapshot(editor));
if self.undos.len() > UNDO_LIMIT {
self.undos.remove(0);
}
self.redos.clear();
self.run = Some(kind);
}
}
pub(crate) fn break_run(&mut self) {
self.run = None;
}
}
impl EditorState {
pub(crate) fn new(style: &ResolvedText, now: f64, multiline: bool) -> Self {
let mut editor = parley::PlainEditor::new(style.px);
apply_style(&mut editor, style);
editor.set_width(None); Self {
editor,
scroll_x: 0.0,
last_activity: now,
seen: 0,
multiline,
undo: UndoStack::default(),
}
}
pub(crate) fn sync(&mut self, value: &str, style: &ResolvedText) {
if self.editor.raw_text() != value {
let fresh = self.editor.raw_text().is_empty()
&& self.undo.undos.is_empty()
&& self.undo.redos.is_empty();
if fresh {
self.editor.set_text(value);
apply_style(&mut self.editor, style);
return;
}
self.undo.undos.push(snapshot(&self.editor));
if self.undo.undos.len() > UNDO_LIMIT {
self.undo.undos.remove(0);
}
self.undo.redos.clear();
self.undo.break_run();
self.editor.set_text(value);
}
apply_style(&mut self.editor, style);
}
}
fn apply_style(editor: &mut parley::PlainEditor<LayoutBrush>, style: &ResolvedText) {
use parley::{FontFamily, FontFeatures, FontWeight, GenericFamily, LineHeight, StyleProperty};
let styles = editor.edit_styles();
styles.insert(StyleProperty::FontSize(style.px));
styles.insert(StyleProperty::FontWeight(FontWeight::new(style.weight)));
styles.insert(StyleProperty::LineHeight(LineHeight::FontSizeRelative(
style.line_height,
)));
styles.insert(StyleProperty::LetterSpacing(style.letter_spacing));
if style.tabular_nums {
styles.insert(StyleProperty::FontFeatures(FontFeatures::from(
"\"tnum\" 1",
)));
}
styles.insert(StyleProperty::FontFamily(match style.family {
crate::tokens::FamilyRole::Sans => FontFamily::named("Inter"),
crate::tokens::FamilyRole::Mono => FontFamily::Single(GenericFamily::Monospace.into()),
crate::tokens::FamilyRole::Display | crate::tokens::FamilyRole::Serif => {
FontFamily::named("Inter")
}
}));
}
pub(crate) struct EditOutcome {
pub changed: bool,
pub consumed: bool,
}
const HANDLED: EditOutcome = EditOutcome {
changed: true,
consumed: true,
};
const MOVED: EditOutcome = EditOutcome {
changed: false,
consumed: true,
};
const IGNORED: EditOutcome = EditOutcome {
changed: false,
consumed: false,
};
pub(crate) fn handle_key(
state: &mut EditorState,
fonts: &mut Fonts,
clipboard: &mut dyn Clipboard,
key: &KeyInput,
) -> EditOutcome {
let outcome = handle_key_inner(state, fonts, clipboard, key);
if !outcome.changed {
state.undo.break_run();
}
outcome
}
fn handle_key_inner(
state: &mut EditorState,
fonts: &mut Fonts,
clipboard: &mut dyn Clipboard,
key: &KeyInput,
) -> EditOutcome {
let multiline = state.multiline;
let (font_cx, layout_cx) = fonts.editor_contexts();
let mut drv = state.editor.driver(font_cx, layout_cx);
let shortcut = key.meta || key.ctrl;
let word = key.alt || (key.ctrl && !key.meta);
match key.key {
Key::Char(c) if shortcut => match c.to_ascii_lowercase() {
'a' => {
drv.select_all();
MOVED
}
'c' => {
if let Some(text) = drv.editor.selected_text() {
clipboard.set(text.to_owned());
}
MOVED
}
'x' => {
if let Some(text) = drv.editor.selected_text() {
clipboard.set(text.to_owned());
state.undo.break_run();
state.undo.begin(&state.editor, EditRun::Delete);
let (font_cx, layout_cx) = fonts.editor_contexts();
let mut drv = state.editor.driver(font_cx, layout_cx);
drv.delete_selection();
state.undo.break_run();
return HANDLED;
}
MOVED
}
'v' => {
if let Some(text) = clipboard.get() {
state.undo.break_run();
state.undo.begin(&state.editor, EditRun::Insert);
let (font_cx, layout_cx) = fonts.editor_contexts();
let mut drv = state.editor.driver(font_cx, layout_cx);
drv.insert_or_replace_selection(&sanitize(&text, multiline));
state.undo.break_run();
return HANDLED;
}
MOVED
}
'z' => {
let applied = if key.shift {
redo(state, fonts)
} else {
undo(state, fonts)
};
if applied { HANDLED } else { MOVED }
}
'y' if key.ctrl => {
if redo(state, fonts) {
HANDLED
} else {
MOVED
}
}
_ => IGNORED,
},
Key::Char(c) if c.is_control() => IGNORED,
Key::Char(c) if !key.ctrl && !key.meta => {
state.undo.begin(&state.editor, EditRun::Insert);
let (font_cx, layout_cx) = fonts.editor_contexts();
let mut drv = state.editor.driver(font_cx, layout_cx);
drv.insert_or_replace_selection(&c.to_string());
HANDLED
}
Key::ArrowLeft => {
match (key.shift, word, key.meta) {
(true, _, true) => drv.select_to_line_start(),
(false, _, true) => drv.move_to_line_start(),
(true, true, _) => drv.select_word_left(),
(false, true, _) => drv.move_word_left(),
(true, false, _) => drv.select_left(),
(false, false, _) => drv.move_left(),
}
MOVED
}
Key::ArrowRight => {
match (key.shift, word, key.meta) {
(true, _, true) => drv.select_to_line_end(),
(false, _, true) => drv.move_to_line_end(),
(true, true, _) => drv.select_word_right(),
(false, true, _) => drv.move_word_right(),
(true, false, _) => drv.select_right(),
(false, false, _) => drv.move_right(),
}
MOVED
}
Key::Home => {
if key.shift {
drv.select_to_line_start();
} else {
drv.move_to_line_start();
}
MOVED
}
Key::End => {
if key.shift {
drv.select_to_line_end();
} else {
drv.move_to_line_end();
}
MOVED
}
Key::Enter if multiline => {
state.undo.begin(&state.editor, EditRun::Insert);
let (font_cx, layout_cx) = fonts.editor_contexts();
let mut drv = state.editor.driver(font_cx, layout_cx);
drv.insert_or_replace_selection("\n");
HANDLED
}
Key::ArrowUp if multiline => {
if key.shift {
drv.select_up();
} else {
drv.move_up();
}
MOVED
}
Key::ArrowDown if multiline => {
if key.shift {
drv.select_down();
} else {
drv.move_down();
}
MOVED
}
Key::Backspace => {
state.undo.begin(&state.editor, EditRun::Delete);
let (font_cx, layout_cx) = fonts.editor_contexts();
let mut drv = state.editor.driver(font_cx, layout_cx);
if word {
drv.backdelete_word();
} else {
drv.backdelete();
}
HANDLED
}
Key::Delete => {
state.undo.begin(&state.editor, EditRun::Delete);
let (font_cx, layout_cx) = fonts.editor_contexts();
let mut drv = state.editor.driver(font_cx, layout_cx);
if word {
drv.delete_word();
} else {
drv.delete();
}
HANDLED
}
_ => IGNORED,
}
}
fn sanitize(text: &str, multiline: bool) -> String {
if multiline {
text.replace("\r\n", "\n")
.chars()
.map(|c| if c == '\r' { '\n' } else { c })
.filter(|c| *c == '\n' || !c.is_control())
.collect()
} else {
text.chars().filter(|c| !c.is_control()).collect()
}
}
pub(crate) fn undo(state: &mut EditorState, fonts: &mut Fonts) -> bool {
let Some(snap) = state.undo.undos.pop() else {
return false;
};
state.undo.redos.push(snapshot(&state.editor));
apply_snapshot(state, fonts, &snap);
true
}
pub(crate) fn redo(state: &mut EditorState, fonts: &mut Fonts) -> bool {
let Some(snap) = state.undo.redos.pop() else {
return false;
};
state.undo.undos.push(snapshot(&state.editor));
apply_snapshot(state, fonts, &snap);
true
}
fn apply_snapshot(state: &mut EditorState, fonts: &mut Fonts, snap: &Snapshot) {
state.editor.set_text(&snap.text);
let (font_cx, layout_cx) = fonts.editor_contexts();
let mut drv = state.editor.driver(font_cx, layout_cx);
drv.select_byte_range(snap.selection.0, snap.selection.1);
state.undo.break_run();
}
pub(crate) fn handle_text(state: &mut EditorState, fonts: &mut Fonts, text: &str) -> EditOutcome {
state.undo.begin(&state.editor, EditRun::Insert);
let sanitized = sanitize(text, state.multiline);
if sanitized.is_empty() {
return IGNORED;
}
let (font_cx, layout_cx) = fonts.editor_contexts();
let mut drv = state.editor.driver(font_cx, layout_cx);
drv.insert_or_replace_selection(&sanitized);
HANDLED
}
pub(crate) fn handle_preedit(
state: &mut EditorState,
fonts: &mut Fonts,
text: &str,
cursor: Option<(usize, usize)>,
) {
let (font_cx, layout_cx) = fonts.editor_contexts();
let mut drv = state.editor.driver(font_cx, layout_cx);
if text.is_empty() {
drv.clear_compose();
} else {
let cursor = cursor.map(|(a, b)| (a.min(text.len()), b.min(text.len())));
drv.set_compose(text, cursor);
}
}
pub(crate) fn pointer_down(
state: &mut EditorState,
fonts: &mut Fonts,
x: f64,
y: f64,
shift: bool,
) {
state.undo.break_run();
let (font_cx, layout_cx) = fonts.editor_contexts();
let mut drv = state.editor.driver(font_cx, layout_cx);
#[expect(clippy::cast_possible_truncation, reason = "text coords fit in f32")]
if shift {
drv.shift_click_extension(x as f32, y as f32);
} else {
drv.move_to_point(x as f32, y as f32);
}
}
pub(crate) fn select_word_at(state: &mut EditorState, fonts: &mut Fonts, x: f64, y: f64) {
state.undo.break_run();
let (font_cx, layout_cx) = fonts.editor_contexts();
let mut drv = state.editor.driver(font_cx, layout_cx);
#[expect(clippy::cast_possible_truncation, reason = "text coords fit in f32")]
drv.select_word_at_point(x as f32, y as f32);
}
pub(crate) fn select_line_at(state: &mut EditorState, fonts: &mut Fonts, x: f64, y: f64) {
state.undo.break_run();
let (font_cx, layout_cx) = fonts.editor_contexts();
let mut drv = state.editor.driver(font_cx, layout_cx);
#[expect(clippy::cast_possible_truncation, reason = "text coords fit in f32")]
drv.select_line_at_point(x as f32, y as f32);
}
pub(crate) fn pointer_drag(state: &mut EditorState, fonts: &mut Fonts, x: f64, y: f64) {
state.undo.break_run();
let (font_cx, layout_cx) = fonts.editor_contexts();
let mut drv = state.editor.driver(font_cx, layout_cx);
#[expect(clippy::cast_possible_truncation, reason = "text coords fit in f32")]
drv.extend_selection_to_point(x as f32, y as f32);
}
pub(crate) struct InputPaint {
pub placeholder: String,
pub style: ResolvedText,
pub placeholder_color: Color,
pub caret_color: Color,
pub selection_color: Color,
pub focused: bool,
pub pad_x: f64,
pub pad_y: f64,
pub multiline: bool,
}
pub(crate) fn paint(
scene: &mut Scene,
fonts: &mut Fonts,
state: &mut EditorState,
data: &InputPaint,
rect: Rect,
now: f64,
reduced_motion: bool,
) -> Option<Rect> {
let content = Rect::new(rect.x0 + data.pad_x, rect.y0, rect.x1 - data.pad_x, rect.y1);
if data.multiline {
#[expect(clippy::cast_possible_truncation, reason = "widths fit in f32")]
state
.editor
.set_width(Some(content.width().max(0.0) as f32));
}
let (font_cx, layout_cx) = fonts.editor_contexts();
let layout_height = f64::from(state.editor.layout(font_cx, layout_cx).height());
let text_y = if data.multiline {
rect.y0 + data.pad_y
} else {
rect.y0 + (rect.height() - layout_height) * 0.5
};
if data.multiline {
state.scroll_x = 0.0;
} else if let Some(caret) = state.editor.cursor_geometry(1.0) {
let caret_x = caret.x0; let visible_w = content.width().max(0.0);
if caret_x - state.scroll_x > visible_w {
state.scroll_x = caret_x - visible_w;
}
if caret_x - state.scroll_x < 0.0 {
state.scroll_x = caret_x;
}
let max_scroll = (f64::from(
state
.editor
.try_layout()
.map_or(0.0, parley::Layout::full_width),
) - visible_w)
.max(0.0);
state.scroll_x = state.scroll_x.clamp(0.0, max_scroll + CARET_WIDTH);
} else {
state.scroll_x = 0.0;
}
let text_origin = (content.x0 - state.scroll_x, text_y);
scene.push_clip_layer(Fill::NonZero, Affine::IDENTITY, &rect);
if data.focused {
for (bb, _) in state.editor.selection_geometry() {
let sel = Rect::new(
text_origin.0 + bb.x0,
text_origin.1 + bb.y0,
text_origin.0 + bb.x1,
text_origin.1 + bb.y1,
);
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
data.selection_color,
None,
&sel,
);
}
}
if state.editor.raw_text().is_empty() {
if !data.placeholder.is_empty() {
let mut placeholder_style = data.style;
placeholder_style.color = data.placeholder_color;
let ph_bottom = if data.multiline {
rect.y1 - data.pad_y
} else {
text_y + layout_height
};
let ph_rect = Rect::new(content.x0, text_y, content.x1, ph_bottom);
fonts.paint(scene, &data.placeholder, &placeholder_style, ph_rect, None);
}
} else {
let transform = Affine::translate(text_origin);
let color = data.style.color;
let (font_cx, layout_cx) = fonts.editor_contexts();
let layout = state.editor.layout(font_cx, layout_cx);
for line in layout.lines() {
for item in line.items() {
let PositionedLayoutItem::GlyphRun(glyph_run) = item else {
continue;
};
let mut x = glyph_run.offset();
let y = glyph_run.baseline();
let run = glyph_run.run();
scene
.draw_glyphs(run.font())
.brush(color)
.hint(true)
.transform(transform)
.font_size(run.font_size())
.normalized_coords(run.normalized_coords())
.draw(
Fill::NonZero,
glyph_run.glyphs().map(|glyph| {
let gx = x + glyph.x;
let gy = y + glyph.y;
x += glyph.advance;
vello::Glyph {
id: glyph.id,
x: gx,
y: gy,
}
}),
);
}
}
}
let mut caret_rect = None;
if data.focused {
let phase = ((now - state.last_activity).max(0.0) / BLINK_HALF_PERIOD) as u64;
let visible = reduced_motion || phase.is_multiple_of(2);
if let Some(bb) = state.editor.cursor_geometry(1.0) {
let caret = Rect::new(
(text_origin.0 + bb.x0 - CARET_WIDTH * 0.5).max(rect.x0),
text_origin.1 + bb.y0 + 1.0,
(text_origin.0 + bb.x0 + CARET_WIDTH * 0.5).max(rect.x0 + CARET_WIDTH),
text_origin.1 + bb.y1 - 1.0,
);
caret_rect = Some(caret);
if visible {
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
data.caret_color,
None,
&caret,
);
}
}
}
scene.pop_layer();
caret_rect
}