Something went wrong. Try again.
POC of simple editor based on Automerge
Something went wrong. Try again.
19 kB · 520 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521//! Draw remote carets over Iced's regular text editor.//!//! Iced 0.14 does not expose the text editor's glyph positions. We therefore keep//! a measurement editor using the SAME text engine, font, bounds, and actions.//! This handles wrapping, Unicode, and scrolling without guessing character widths.//! The wrapper forwards input to Iced; the colored labels never intercept clicks.use iced::advanced::{ Clipboard, Layout, Shell, Widget, graphics, layout, mouse, renderer, text, widget,};use iced::advanced::{ Renderer as _, text::{Editor as _, Renderer as _},};use iced::widget::text_editor;use iced::{Color, Element, Event, Font, Length, Point, Rectangle, Renderer, Size, Theme};use std::cell::RefCell;
pub const TEXT_SIZE: f32 = 17.0;pub const PADDING: f32 = 20.0;
pub struct EditorContent { native: text_editor::Content, measurement: RefCell<graphics::text::Editor>,}
impl EditorContent { pub fn new() -> Self { Self::with_text("") } pub fn with_text(value: &str) -> Self { Self { native: text_editor::Content::with_text(value), measurement: RefCell::new(graphics::text::Editor::with_text(value)), } } pub fn native(&self) -> &text_editor::Content { &self.native } pub fn text(&self) -> String { self.native.text() } pub fn cursor(&self) -> text_editor::Cursor { self.native.cursor() } pub fn perform(&mut self, action: text_editor::Action) { self.native.perform(action.clone()); self.measurement.get_mut().perform(action); // Measurement layout can be deferred, so clicks and page motions must use // the visible editor's cursor rather than its stale geometry. self.measurement.get_mut().move_to(self.native.cursor()); } pub fn move_to(&mut self, cursor: text_editor::Cursor) { self.native.move_to(cursor); self.measurement.get_mut().move_to(cursor); } fn layout(&self, size: Size) { self.measurement.borrow_mut().update( size, Font::MONOSPACE, TEXT_SIZE.into(), text::LineHeight::default(), text::Wrapping::default(), &mut text::highlighter::PlainText, ); }
/// Each visible wrapped line contributes its own highlight rectangle. /// Sort the endpoints for painting while keeping the caret at the active end. fn selection_bounds( &self, caret: text_editor::Position, anchor: text_editor::Position, ) -> Vec<Rectangle> { use graphics::text::cosmic_text::Cursor; if caret == anchor { return Vec::new(); } let mut start = Cursor::new(caret.line, caret.column); let mut end = Cursor::new(anchor.line, anchor.column); if (start.line, start.index) > (end.line, end.index) { std::mem::swap(&mut start, &mut end); } self.measurement .borrow() .buffer() .layout_runs() .filter_map(|run| { let (x, width) = run.highlight(start, end)?; Some(Rectangle { x, y: run.line_top, // Give a selected empty line/newline a visible sliver. width: width.max(2.0), height: run.line_height, }) }) .collect() }
/// Coordinates are relative to the visible, padded text area, including its scroll. fn caret_bounds(&self, position: text_editor::Position) -> Option<Rectangle> { caret_bounds(&self.measurement.borrow(), position) }}
fn caret_bounds( measurement: &graphics::text::Editor, position: text_editor::Position,) -> Option<Rectangle> { use graphics::text::cosmic_text::{Affinity, Cursor}; for run in measurement.buffer().layout_runs() { if run.line_i != position.line { continue; } let x = if run.glyphs.is_empty() && position.column == 0 { 0.0 } else { let before = Cursor::new_with_affinity(position.line, position.column, Affinity::Before); let after = Cursor::new_with_affinity(position.line, position.column, Affinity::After); let Some((x, _)) = run.highlight(before, after) else { continue; }; x }; return Some(Rectangle { x, y: run.line_top, width: 2.0, height: run.line_height, }); } None // A cursor in a scrolled-out line should not be painted at the viewport edge.}
pub(crate) fn reveal_bounds( measurement: &mut graphics::text::Editor, bounds: Rectangle,) -> Option<Rectangle> { measurement.update( bounds.size(), Font::MONOSPACE, TEXT_SIZE.into(), text::LineHeight::default(), text::Wrapping::default(), &mut text::highlighter::PlainText, ); caret_bounds(measurement, measurement.cursor().position) .map(|caret| caret + (bounds.position() - Point::ORIGIN))}
pub struct RemoteCaret { pub name: String, pub position: text_editor::Position, pub selection: Option<text_editor::Position>, pub color: Color,}
pub fn with_cursors<'a, Message: 'a>( editor: impl Into<Element<'a, Message>>, content: &'a EditorContent, cursors: Vec<RemoteCaret>,) -> Element<'a, Message> { Element::new(CursorLayer { editor: editor.into(), content, cursors, })}
struct CursorLayer<'a, Message> { editor: Element<'a, Message>, content: &'a EditorContent, cursors: Vec<RemoteCaret>,}
impl<Message> Widget<Message, Theme, Renderer> for CursorLayer<'_, Message> { // Reuse the child's widget state so focus, selection, IME, and key bindings stay native. fn tag(&self) -> widget::tree::Tag { self.editor.as_widget().tag() } fn state(&self) -> widget::tree::State { self.editor.as_widget().state() } fn children(&self) -> Vec<widget::Tree> { self.editor.as_widget().children() } fn diff(&self, tree: &mut widget::Tree) { self.editor.as_widget().diff(tree); } fn size(&self) -> Size<Length> { self.editor.as_widget().size() } fn layout( &mut self, tree: &mut widget::Tree, renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { let node = self.editor.as_widget_mut().layout(tree, renderer, limits); // A second full-document layout is only needed to draw remote markers. // Caret-reveal operations request their geometry separately, on demand. if !self.cursors.is_empty() { self.content.layout(Size::new( (node.size().width - PADDING * 2.0).max(0.0), (node.size().height - PADDING * 2.0).max(0.0), )); } node } fn update( &mut self, tree: &mut widget::Tree, event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, ) { // The enclosing viewport owns wheel events, including fractional pixels. if matches!(event, Event::Mouse(mouse::Event::WheelScrolled { .. })) { return; } self.editor.as_widget_mut().update( tree, event, layout, cursor, renderer, clipboard, shell, viewport, ); } fn operate( &mut self, tree: &mut widget::Tree, layout: Layout<'_>, renderer: &Renderer, operation: &mut dyn widget::Operation, ) { self.editor .as_widget_mut() .operate(tree, layout, renderer, operation); operation.custom( Some(&widget::Id::new("editor-caret")), layout.bounds().shrink(PADDING), &mut *self.content.measurement.borrow_mut(), ); } fn mouse_interaction( &self, tree: &widget::Tree, layout: Layout<'_>, cursor: mouse::Cursor, viewport: &Rectangle, renderer: &Renderer, ) -> mouse::Interaction { self.editor .as_widget() .mouse_interaction(tree, layout, cursor, viewport, renderer) } fn draw( &self, tree: &widget::Tree, renderer: &mut Renderer, theme: &Theme, style: &renderer::Style, layout: Layout<'_>, cursor: mouse::Cursor, viewport: &Rectangle, ) { self.editor .as_widget() .draw(tree, renderer, theme, style, layout, cursor, viewport); let bounds = layout.bounds(); let text_bounds = bounds.shrink(PADDING); let Some(clip) = bounds.intersection(viewport) else { return; }; renderer.with_layer(clip, |renderer| { // Paint translucent selections first, so all carets and labels remain crisp. for remote in &self.cursors { if let Some(anchor) = remote.selection { for highlight in self.content.selection_bounds(remote.position, anchor) { let highlight = highlight + (text_bounds.position() - Point::ORIGIN); if let Some(highlight) = highlight.intersection(&text_bounds) { renderer.fill_quad( renderer::Quad { bounds: highlight, ..Default::default() }, Color { a: 0.22, ..remote.color }, ); } } } } for remote in &self.cursors { let Some(caret) = self.content.caret_bounds(remote.position) else { continue; }; let caret = caret + (text_bounds.position() - Point::ORIGIN); let Some(caret) = caret.intersection(&text_bounds) else { continue; }; renderer.fill_quad( renderer::Quad { bounds: caret, ..Default::default() }, remote.color, ); let label_width = (remote.name.chars().count() as f32 * 8.0 + 10.0).min(bounds.width); let label = Rectangle { x: caret.x.min(bounds.x + bounds.width - label_width), y: (caret.y - 17.0).max(bounds.y), width: label_width, height: 17.0, }; renderer.fill_quad( renderer::Quad { bounds: label, ..Default::default() }, remote.color, ); renderer.fill_text( text::Text { content: remote.name.clone(), bounds: label.size(), size: 12.0.into(), line_height: text::LineHeight::default(), font: Font::MONOSPACE, align_x: text::Alignment::Default, align_y: iced::alignment::Vertical::Top, shaping: text::Shaping::Advanced, wrapping: text::Wrapping::None, }, label.position() + iced::Vector::new(4.0, 1.0), crate::flexoki::colors(theme).accent_text, label, ); } }); }}
#[cfg(test)]mod tests { use super::*;
#[test] fn deferred_measurement_uses_visible_cursor_for_clicks_and_edits() { let mut content = EditorContent::with_text("hello 🦀 a long line that wraps over several rows\nПривет"); // Lay out only the visible editor, as happens when no remote peers are shown. { let mut editor: Element<'_, text_editor::Action> = text_editor(content.native()) .font(Font::MONOSPACE) .size(TEXT_SIZE) .padding(0) .on_action(|action| action) .into(); let mut tree = widget::Tree::new(editor.as_widget()); let renderer = Renderer::Secondary(iced_tiny_skia::Renderer::new( Font::MONOSPACE, TEXT_SIZE.into(), )); let size = Size::new(140.0, 300.0); editor .as_widget_mut() .layout(&mut tree, &renderer, &layout::Limits::new(size, size)); } for action in [ text_editor::Action::Click(Point::new(25.0, 35.0)), text_editor::Action::Drag(Point::new(65.0, 65.0)), text_editor::Action::Edit(text_editor::Edit::Insert('X')), ] { content.perform(action); let measurement = content.measurement.borrow(); assert_eq!(measurement.cursor(), content.cursor()); for index in 0..content.native.line_count() { assert_eq!( measurement.line(index).unwrap().text, content.native.line(index).unwrap().text ); } } assert!( reveal_bounds( &mut content.measurement.borrow_mut(), Rectangle::with_size(Size::new(140.0, 300.0)) ) .is_some() ); }
#[test] fn selections_cover_wrapped_unicode_lines_in_both_directions() { let mut content = EditorContent::with_text("hello 🦀 a long line that wraps over several rows\n\nПривет"); content.layout(Size::new(140.0, 300.0)); let start = text_editor::Position { line: 0, column: 6 }; let end = text_editor::Position { line: 2, column: "Привет".len(), }; let forward = content.selection_bounds(end, start); assert!(forward.len() >= 4, "Must include wrapped and empty lines"); assert_eq!(forward, content.selection_bounds(start, end)); assert!(content.selection_bounds(start, start).is_empty()); assert!( forward .iter() .all(|rect| rect.width > 0.0 && rect.height > 0.0) ); // Scrolling must move the highlights, not leave them stuck on screen. content.layout(Size::new(140.0, 45.0)); content.perform(text_editor::Action::Scroll { lines: 3 }); content.layout(Size::new(140.0, 45.0)); let scrolled = content.selection_bounds(end, start); assert_ne!(forward, scrolled); }
#[test] fn markers_exist_at_every_line_boundary() { for value in [ "hello", "Привет 🦀", "hello\n", "\nhello", "", "one long line that wraps across several rows", ] { let content = EditorContent::with_text(value); content.layout(Size::new(140.0, 300.0)); for (line, value) in value.split('\n').enumerate() { for column in [0, value.len()] { let position = text_editor::Position { line, column }; assert!( content.caret_bounds(position).is_some(), "Missing marker at {position:?} in {value:?}" ); } } } }
#[test] fn marker_matches_native_caret_with_unicode_and_wrapping() { let content = EditorContent::with_text("hello 🦀 there is a long line that wraps\nПривет"); content.layout(Size::new(140.0, 300.0)); for position in [ text_editor::Position { line: 0, column: 0 }, text_editor::Position { line: 0, column: "hello 🦀".len(), }, text_editor::Position { line: 0, column: "hello 🦀 there is a long line".len(), }, text_editor::Position { line: 1, column: "Привет".len(), }, ] { let expected = { let mut measurement = content.measurement.borrow_mut(); measurement.move_to(text_editor::Cursor { position, selection: None, }); match measurement.selection() { text::editor::Selection::Caret(point) => point, _ => panic!("Expected a caret"), } }; let marker = content.caret_bounds(position).expect("Visible caret"); assert!( (marker.x - expected.x).abs() < 1.0, "{position:?}: {marker:?} vs {expected:?}" ); assert!((marker.y - expected.y).abs() < 1.0); } }
#[test] fn markers_follow_resize_and_scroll_and_empty_notes() { let mut content = EditorContent::with_text( "a long line that must wrap when the editor becomes narrow\nlast line", ); let end = text_editor::Position { line: 0, column: 54, }; content.layout(Size::new(600.0, 300.0)); let wide = content.caret_bounds(end).unwrap(); content.layout(Size::new(140.0, 300.0)); let narrow = content.caret_bounds(end).unwrap(); assert!(narrow.y > wide.y);
content.layout(Size::new(140.0, 45.0)); content.perform(text_editor::Action::Scroll { lines: 4 }); content.layout(Size::new(140.0, 45.0)); assert!( content .caret_bounds(text_editor::Position { line: 0, column: 0 }) .is_none() );
let empty = EditorContent::new(); empty.layout(Size::new(100.0, 100.0)); let caret = empty .caret_bounds(text_editor::Position { line: 0, column: 0 }) .unwrap(); assert_eq!(caret.x, 0.0); assert_eq!(caret.y, 0.0); }}