Something went wrong. Try again.
POC of simple editor based on Automerge
Something went wrong. Try again.
30 kB · 787 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788//! The application, expressed in Iced's state -> message -> update -> view cycle.//!//! Iced's Content holds the visible text and selection. Replica holds the durable//! Automerge history. Only update() changes either of them, so a local keystroke//! and a remote sync message cannot mutate a document at the same time.use crate::collaborative_editor::{self, EditorContent};use crate::{config::Options, cursor::SelectionBookmark, network};use iced::widget::{button, column, container, row, scrollable, text, text_editor, text_input};use iced::{Element, Fill, Font, Subscription, Task};use simple_editor::presence::Presence;use simple_editor::{protocol::WireMessage, replica::Replica};use std::time::Instant;use tokio::sync::mpsc;
pub struct Editor { // None means startup could not load storage. We show the error in the window. replica: Option<Replica>, active: Option<String>, content: EditorContent, presence: Option<Presence>, name_input: String, theme: iced::Theme, scale_factor: f32, server_url: String, // "online" is the user's preference; sender exists only while actually connected. online: bool, generation: u64, server_peer: Option<String>, sender: Option<mpsc::Sender<WireMessage>>, connection_status: String, error: Option<String>,}
/// Widgets and background subscriptions send these values to update().#[derive(Debug, Clone)]pub enum Message { ThemeChanged(iced::theme::Mode), WindowEvent(iced::window::Id, iced::window::Event), ScaleFactorChanged(f32), New, Delete(String), Select(String), VisitUser(String), Edit(text_editor::Action), Undo, Redo, Network((u64, network::Event)), ToggleConnection, NameChanged(String), SaveName, RetryMissing, PresenceTick,}
impl Editor { pub fn new(options: Options) -> (Self, Task<Message>) { let (replica, error) = match options.open_replica() { Ok(replica) => (Some(replica), None), Err(error) => (None, Some(error)), }; let presence = replica .as_ref() .map(|r| Presence::new(r.peer_id.clone(), r.display_name.clone(), r.catalog.clone())); let name_input = replica .as_ref() .map(|r| r.display_name.clone()) .unwrap_or_default(); let mut editor = Self { theme: crate::flexoki::theme(iced::theme::Mode::Light), scale_factor: 1.0, name_input, presence, replica, active: None, content: EditorContent::new(), server_url: options.server, online: true, generation: 0, server_peer: None, sender: None, connection_status: "Connecting…".into(), error, }; if let Some(id) = editor.first_note() { editor.select_note(id); } (editor, iced::system::theme().map(Message::ThemeChanged)) }
pub fn theme(&self) -> iced::Theme { self.theme.clone() }
pub fn title(&self) -> String { let name = self .replica .as_ref() .map(|replica| replica.display_name.as_str()) .unwrap_or("unavailable"); format!("Simple Editor · {name}") }
/// A Subscription produces messages over time, unlike a one-off Task. pub fn subscription(&self) -> Subscription<Message> { let appearance = Subscription::batch([ iced::system::theme_changes().map(Message::ThemeChanged), iced::event::listen_with(|event, _, id| match event { iced::Event::Window(event @ iced::window::Event::Opened { .. }) | iced::Event::Window(event @ iced::window::Event::Rescaled(_)) => { Some(Message::WindowEvent(id, event)) } _ => None, }), ]); let Some(replica) = &self.replica else { return appearance; }; if !self.online { return appearance; }
Subscription::batch([ appearance, // Iced keeps this stream alive while Config stays equal. Changing generation // cancels the old connection and starts another one with fresh sync state. Subscription::run_with( network::Config { generation: self.generation, peer: replica.peer_id.clone(), url: self.server_url.clone(), }, network::events, ) .map(Message::Network), iced::time::every(std::time::Duration::from_secs(3)).map(|_| Message::RetryMissing), iced::time::every(std::time::Duration::from_millis(50)).map(|_| Message::PresenceTick), ]) }
/// Read this match first: it is the list of everything the application can do. pub fn update(&mut self, message: Message) -> Task<Message> { match message { Message::ThemeChanged(mode) => self.theme = crate::flexoki::theme(mode), Message::WindowEvent(id, iced::window::Event::Opened { .. }) => { return iced::window::scale_factor(id).map(Message::ScaleFactorChanged); } Message::WindowEvent(_, iced::window::Event::Rescaled(scale)) | Message::ScaleFactorChanged(scale) => self.scale_factor = scale, Message::WindowEvent(_, _) => {} Message::New => { self.create_note(); return iced::widget::operation::focus("editor"); } Message::Delete(id) => { self.delete_note(&id); return iced::widget::operation::focus("editor"); } Message::VisitUser(peer) => { let document = self .presence .as_ref() .and_then(|presence| presence.document_for(&peer)) .map(str::to_owned); if let (Some(id), Some(replica)) = (document, &self.replica) { // Presence can arrive before catalog sync, or refer to a deleted note. if replica.note_ids().contains(&id) && self.active.as_ref() != Some(&id) { self.select_note(id); self.send_presence(); return iced::widget::operation::focus("editor"); } } } Message::Select(id) => { self.select_note(id); return iced::widget::operation::focus("editor"); } Message::Edit(action) => { let reveal = !matches!(action, text_editor::Action::Scroll { .. }); self.edit_note(action); if reveal { return crate::editor_scroll::reveal_caret(); } } Message::Undo | Message::Redo => { self.undo_redo(matches!(message, Message::Redo)); return crate::editor_scroll::reveal_caret(); } Message::ToggleConnection => self.toggle_connection(), Message::NameChanged(value) => self.name_input = value, Message::SaveName => { if let Some(replica) = &mut self.replica { match replica.set_display_name(&self.name_input) { Ok(()) => { self.name_input = replica.display_name.clone(); if let Some(presence) = &mut self.presence { presence.set_name(replica.display_name.clone()); } self.error = None; } Err(error) => self.error = Some(error), } } } Message::RetryMissing => self.retry_missing_notes(), Message::PresenceTick => { if let Some(presence) = &mut self.presence { presence.expire(Instant::now()); } self.send_presence(); } Message::Network((generation, event)) => { // A cancelled subscription may already have queued an event. Ignore it. if generation == self.generation && self.online { self.handle_network_event(event); } } } Task::none() }
fn first_note(&self) -> Option<String> { self.replica.as_ref()?.note_ids().first().cloned() }
fn create_note(&mut self) { let Some(replica) = &mut self.replica else { return; }; match replica.create_note() { Ok(id) => { self.active = Some(id); self.content = EditorContent::new(); self.error = None; } Err(error) => self.error = Some(error), } self.send_pending_sync(); }
fn delete_note(&mut self, id: &str) { let Some(replica) = &mut self.replica else { return; }; match replica.delete_note(id) { Ok(()) => { self.error = None; if self.active.as_deref() == Some(id) { self.select_first_note(); } self.send_pending_sync(); self.send_presence(); } Err(error) => self.error = Some(format!("Could not delete document: {error}")), } }
fn select_first_note(&mut self) { self.active = None; self.content = EditorContent::new(); if let Some(id) = self.first_note() { self.select_note(id); } }
fn select_note(&mut self, id: String) { let Some(replica) = &mut self.replica else { return; }; if let Some(previous) = &self.active { replica.break_undo_group(previous); } replica.break_undo_group(&id); let text = replica.text(&id).unwrap_or_default(); self.content = EditorContent::with_text(&text); self.active = Some(id); }
fn edit_note(&mut self, action: text_editor::Action) { let (Some(replica), Some(id)) = (&mut self.replica, &self.active) else { return; }; if replica.text(id).is_none() { return; }
// Selection replacement and paste are isolated undo steps. use simple_editor::replica::EditKind; let kind = if self.content.cursor().selection.is_some() { None } else { match &action { text_editor::Action::Edit(text_editor::Edit::Insert(ch)) if !ch.is_whitespace() => { Some(EditKind::Typing) } text_editor::Action::Edit(text_editor::Edit::Backspace) => { Some(EditKind::Backspace) } text_editor::Action::Edit(text_editor::Edit::Delete) => Some(EditKind::Delete), _ => None, } }; let changes_text = action.is_edit(); if !changes_text && !matches!(action, text_editor::Action::Scroll { .. }) { replica.break_undo_group(id); } self.content.perform(action); if !changes_text { return; }
// Save the CRDT edit before adding it to the local undo history. match replica.edit_undoable(id, &self.content.text(), kind) { Ok(()) => self.error = None, Err(error) => { self.error = Some(format!("Edit could not be saved: {error}")); self.content = EditorContent::with_text(&replica.text(id).unwrap_or_default()); } } self.send_pending_sync(); }
fn undo_redo(&mut self, redo: bool) { let (Some(replica), Some(id)) = (&mut self.replica, &self.active) else { return; }; match replica.undo_redo(id, redo) { Ok(Some(offset)) => { let text = replica.text(id).unwrap_or_default(); self.content = EditorContent::with_text(&text); self.content.move_to(text_editor::Cursor { position: crate::cursor::position_at(&text, offset), selection: None, }); self.error = None; self.send_pending_sync(); self.send_presence(); } Ok(None) => {} Err(error) => { self.error = Some(format!( "{} could not be saved: {error}", if redo { "Redo" } else { "Undo" } )) } } }
fn handle_network_event(&mut self, event: network::Event) { match event { network::Event::Connected(peer, sender) => { self.clear_connection(); self.server_peer = Some(peer); self.sender = Some(sender); self.connection_status = format!("Connected · {}", self.server_url); self.send_pending_sync(); } network::Event::Disconnected(error) => { self.clear_connection(); self.connection_status = format!("Offline · retrying ({error})"); } network::Event::Received(message) => self.receive_sync(*message), } }
fn receive_sync(&mut self, message: WireMessage) { if message.kind == "ephemeral" { if let Some(presence) = &mut self.presence { presence.receive(&message, Instant::now()); } return; // Presence must never enter Automerge's persisted change history. } if message.kind == "error" { self.error = Some(message.message.unwrap_or("Sync server error".into())); return; } let bookmark = match (&self.replica, &self.active) { (Some(replica), Some(id)) => SelectionBookmark::capture(replica, id, &self.content), _ => None, }; let Some(replica) = &mut self.replica else { return; }; if let Err(error) = replica.receive(&message) { self.error = Some(format!("Could not apply sync: {error}")); // The candidate was not published. Reconnect so the server can resend it. self.generation += 1; self.clear_connection(); return; }
let active_was_deleted = self .active .as_ref() .is_some_and(|id| !self.replica.as_ref().unwrap().note_ids().contains(id)); if active_was_deleted { // A remote deletion also closes the note here. Its old selection must // not be restored into the next document. self.select_first_note(); self.send_presence(); } else { if self.active.is_none() { self.active = self.first_note(); } self.refresh_active_note(bookmark); } // Receiving a sync message can require a reply even if no visible text changed. self.send_pending_sync(); }
fn refresh_active_note(&mut self, bookmark: Option<SelectionBookmark>) { let (Some(replica), Some(id)) = (&self.replica, &self.active) else { return; }; let text = replica.text(id).unwrap_or_default(); if self.content.text() == text { return; }
self.content = EditorContent::with_text(&text); if let Some(bookmark) = bookmark { bookmark.restore(replica, id, &mut self.content); } }
fn toggle_connection(&mut self) { self.online = !self.online; self.generation += 1; self.clear_connection(); self.connection_status = if self.online { "Connecting…" } else { "Offline" } .into(); }
fn clear_connection(&mut self) { if let Some(presence) = &mut self.presence { presence.disconnected(); } self.sender = None; self.server_peer = None; if let Some(replica) = &mut self.replica { replica.reset_connection(); } }
fn retry_missing_notes(&mut self) { if self.sender.is_none() { return; } if let Some(replica) = &mut self.replica { replica.retry_missing(); } self.send_pending_sync(); }
fn send_pending_sync(&mut self) { let (Some(replica), Some(target), Some(sender)) = (&mut self.replica, &self.server_peer, &self.sender) else { return; }; for message in replica.outgoing(target) { // Never wait for network I/O on the UI thread. If its queue is full or closed, // reconnect and let Automerge work out what still needs to be sent. if sender.try_send(message).is_err() { self.generation += 1; self.clear_connection(); self.connection_status = "Reconnecting…".into(); break; } } }
/// Send at most 20 cursor updates per second; stationary cursors get a heartbeat. fn send_presence(&mut self) { let (Some(replica), Some(presence), Some(target), Some(sender)) = ( &self.replica, &mut self.presence, &self.server_peer, &self.sender, ) else { return; }; let open_note = self.active.as_ref().and_then(|id| { let text = self.content.text(); let cursor = self.content.cursor(); let caret = replica.anchor(id, crate::cursor::offset(&text, cursor.position))?; let selection = cursor .selection .and_then(|position| replica.anchor(id, crate::cursor::offset(&text, position))); Some((id.as_str(), caret, selection)) }); if let Some(message) = presence.outgoing(target, open_note, Instant::now()) { // Dropping an ephemeral update is safe: the next heartbeat supersedes it. let _ = sender.try_send(message); } }
fn remote_cursors(&self) -> Vec<collaborative_editor::RemoteCaret> { let (Some(replica), Some(presence), Some(id)) = (&self.replica, &self.presence, &self.active) else { return Vec::new(); }; let text = self.content.text(); presence .in_document(id) .filter_map(|peer| { // Presence can arrive before the corresponding text change. Hide its marker // until the anchor resolves, instead of displaying it at a guessed offset. let offset = replica.resolve(id, &peer.caret)?; Some(collaborative_editor::RemoteCaret { name: peer.name.clone(), position: crate::cursor::position_at(&text, offset), selection: peer .selection .as_ref() .and_then(|anchor| replica.resolve(id, anchor)) .map(|offset| crate::cursor::position_at(&text, offset)), color: crate::flexoki::user_color(&peer.name, &self.theme), }) }) .collect() }
/// view() describes widgets; it does not save files or mutate documents. pub fn view(&self) -> Element<'_, Message> { let divider = container(iced::widget::space().width(1).height(Fill)).style(|theme| { container::Style { background: Some(crate::flexoki::colors(theme).border.into()), ..Default::default() } }); row![ self.sidebar(), divider, iced::widget::keyed_column([( { use std::hash::{Hash, Hasher}; let mut hash = std::hash::DefaultHasher::new(); self.active.hash(&mut hash); hash.finish() }, self.note_pane() )]) .height(Fill) .width(Fill) ] .height(Fill) .into() }
fn sidebar(&self) -> Element<'_, Message> { let mut notes = column![].spacing(5); if let Some(replica) = &self.replica { for id in replica.note_ids() { let selected = self.active.as_ref() == Some(&id); // The full row owns its highlight. The hover layer puts deletion // inside that row without nesting one clickable button in another. let note = button(text(replica.title(&id)).size(15)) .width(Fill) .padding(iced::Padding { top: 10.0, right: 42.0, bottom: 10.0, left: 12.0, }) .style(move |theme, status| { let c = crate::flexoki::colors(theme); let mut style = crate::flexoki::text_button(theme, status); style.text_color = c.text; style.background = if selected { Some(c.selected.into()) } else if matches!(status, button::Status::Hovered) { Some(c.row_hover.into()) } else { None }; style }) .on_press(Message::Select(id.clone())); let delete = iced::widget::tooltip( button(text("🗑︎").size(16)) .padding(6) .style(crate::flexoki::text_button) .on_press(Message::Delete(id)), text("Delete document").size(13), iced::widget::tooltip::Position::Right, ) .gap(8) .padding(8) .style(crate::flexoki::tooltip); notes = notes.push(iced::widget::hover( note, container(delete) .width(Fill) .height(Fill) .align_x(iced::Right) .align_y(iced::Center) .padding([0, 4]), )); } } let mut connected = column![].spacing(6); if let Some(presence) = &self.presence { let notes = self .replica .as_ref() .map(|r| r.note_ids()) .unwrap_or_default(); for (peer, user) in presence.users() { let document = user.cursor.as_ref().map(|cursor| &cursor.document); let available = document.is_some_and(|id| notes.contains(id)); let description = match (document, &self.replica) { (Some(id), Some(replica)) if available => replica.title(id), (Some(_), _) => "Document unavailable".into(), _ => "No document open".into(), }; connected = connected.push( iced::widget::tooltip( button( text(format!("● {}", user.name)) .size(13) .color(crate::flexoki::user_color(&user.name, &self.theme)), ) .width(Fill) .padding([4, 6]) .style(crate::flexoki::text_button) .on_press_maybe(available.then(|| Message::VisitUser(peer.into()))), text(description), iced::widget::tooltip::Position::Top, ) .padding(8) .gap(6) .style(crate::flexoki::tooltip), ); } } let mut errors = column![]; if let Some(error) = &self.error { errors = errors.push( text(error) .size(12) .color(crate::flexoki::colors(&self.theme).error), ); } container( column![ text("AutoType").size(22), button("+ New document") .style(crate::flexoki::primary_button) .width(Fill) .on_press_maybe(self.replica.is_some().then_some(Message::New)), scrollable(notes).height(Fill), connected, column![ text("Your name").size(13), row![ text_input("Display name", &self.name_input) .width(Fill) .on_input(Message::NameChanged) .on_submit(Message::SaveName) .style(|theme, status| { let c = crate::flexoki::colors(theme); let mut style = text_input::default(theme, status); style.border.radius = crate::flexoki::CONTROL_RADIUS.into(); style.background = c.input.into(); style.value = c.text; style.placeholder = c.muted; style.selection = c.selection; style }), button("Save") .style(crate::flexoki::primary_button) .on_press_maybe( self.replica .as_ref() .filter(|r| self.name_input != r.display_name) .map(|_| Message::SaveName) ), ] .spacing(6) .align_y(iced::Center), ] .spacing(6), button(if self.online { "Disconnect" } else { "Reconnect" }) .style(crate::flexoki::primary_button) .on_press(Message::ToggleConnection), text(&self.connection_status).size(12), errors, ] .spacing(16), ) .width(240) .height(Fill) .padding(iced::Padding { // The native macOS traffic lights sit over the top of the sidebar. top: if cfg!(target_os = "macos") { 44.0 } else { 20.0 }, right: 20.0, bottom: 20.0, left: 20.0, }) .style(|theme| { let c = crate::flexoki::colors(theme); container::Style { background: Some(c.sidebar.into()), text_color: Some(c.text), ..Default::default() } }) .into() }
fn note_pane(&self) -> Element<'_, Message> { let ready = match (&self.replica, &self.active) { (Some(replica), Some(id)) => replica.text(id).is_some(), _ => false, }; let placeholder = if self.active.is_some() { "Loading document…" } else { "Create a document to start writing" }; iced::widget::responsive(move |size| { let mut editor = text_editor(self.content.native()) .id("editor") .key_binding(editor_key_binding) .font(Font::MONOSPACE) .size(collaborative_editor::TEXT_SIZE) .padding(collaborative_editor::PADDING) .height(iced::Length::Shrink) .placeholder(placeholder) .style(|theme, status| { let mut style = text_editor::default(theme, status); // The editor fills the main pane, with no input-box outline. let c = crate::flexoki::colors(theme); style.value = c.text; style.placeholder = c.muted; style.selection = c.selection; style.border = iced::Border::default(); style.background = iced::Color::TRANSPARENT.into(); style }); if ready { editor = editor.on_action(Message::Edit); } crate::editor_scroll::viewport( collaborative_editor::with_cursors( editor.min_height((size.height - collaborative_editor::PADDING * 2.0).max(0.0)), &self.content, self.remote_cursors(), ), self.scale_factor, ) }) .into() }}
fn editor_key_binding(press: text_editor::KeyPress) -> Option<text_editor::Binding<Message>> { if matches!(press.status, text_editor::Status::Focused { .. }) && press.modifiers.command() && !press.modifiers.alt() && press.key.to_latin(press.physical_key) == Some('z') { Some(text_editor::Binding::Custom(if press.modifiers.shift() { Message::Redo } else { Message::Undo })) } else { text_editor::Binding::from_key_press(press) }}
#[cfg(test)]mod tests;