Parsa

Building Collaborative Apps with Rust

What we're building

Chapter 1: GUI

Are we GUI yet? 

areweguiyet.com

Top downloaded GUI crates

ui.heading("Rusty Collab");
ui.horizontal(|ui| {
    ui.label("Your name: ");
    ui.text_edit_singleline(&mut name);
});
ui.add(egui::Slider::new(&mut age, 0..=120).text("age"));
if ui.button("Increment").clicked() {
    age += 1;
}
ui.label(format!("Hello '{}', age {}", name, age));

egui

use eframe::egui;

fn main() -> eframe::Result {
    let mut name = String::new();
    let mut age = 0;

    let options = eframe::NativeOptions::default();
    eframe::run_simple_native("My egui App", options, move |ctx, _| {
        egui::CentralPanel::default().show(ctx, |ui| {
            ui.heading("My egui Application");
            ui.horizontal(|ui| {
                ui.label("Your name: ");
                ui.text_edit_singleline(&mut name);
            });
            ui.add(egui::Slider::new(&mut age, 0..=120).text("age"));
            if ui.button("Increment").clicked() {
                age += 1;
            }
            ui.label(format!("Hello '{name}', age {age}"));  
        });
    })
}

egui: Easy to get started

cargo init
cargo add eframe

egui: Little boilerplate

use eframe::egui;

struct App {
    name: String,
    age: u32,
}

fn main() -> eframe::Result {
    let options = eframe::NativeOptions::default();
    eframe::run_native(
        "Rusty Collab",
        options,
        Box::new(|_cc| {
            let app = App {
                name: String::new(),
                age: 0,
            };
            Ok(Box::new(app))
        }),
    )
}

impl eframe::App for App {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        egui::CentralPanel::default().show(ctx, |ui| {
            render_ui(ui, self);
        });
    }
}

fn render_ui(ui: &mut egui::Ui, app: &mut App) {
    ui.heading("Rusty Collab");
    ui.horizontal(|ui| {
        ui.label("Your name: ");
        ui.text_edit_singleline(&mut app.name);
    });
    ui.add(egui::Slider::new(&mut app.age, 0..=120).text("age"));
    if ui.button("Increment").clicked() {
        app.age += 1;
    }
    ui.label(format!("Hello '{}', age {}", app.name, app.age));
}




Chapter 2: Multiple Screens

Our desired UI

Code

//main.rs

struct App {
    state: State,
}

pub enum State {
    Lobby {
        join_existing: bool,
        name_input: String,
    },
    Session {
        doc: String,
    },
}

fn render_ui(ui: &mut egui::Ui, app: &mut App) {
    match app.state {
        State::Lobby { .. } => render_lobby(ui, &mut app.state),
        State::Session { .. } => render_session(ui, &mut app.state),
    }
}

Code

//screen_lobby.rs

pub fn render_lobby(ui: &mut Ui, state: &mut State) {
    let State::Lobby {
        join_existing,
        name_input,
    } = state
    else {
        return;
    };

    ui.horizontal(|ui| {
        ui.selectable_value(join_existing, false, "Create new session");
        ui.selectable_value(join_existing, true, "Join existing session");
    });
    ui.heading("Rusty Collab");
    ui.horizontal(|ui| {
        ui.label("Your name: ");
        ui.text_edit_singleline(name_input);
    });

    if !*join_existing {
        if ui.button("Create New Session").clicked() {
            *state = State::Session { doc: String::new() };
            return;
        }
    } else {
        if ui.button("Join Existing Session").clicked() {
            *state = State::Session { doc: String::new() };
            return;
        }
    }
}

Code

//screen_session.rs

pub fn render_session(ui: &mut Ui, state: &mut State) {
    let State::Session { doc } = state else {
        return;
    };

    if ui.button("Leave Session").clicked() {
        *state = State::Lobby {
            join_existing: false,
            name_input: String::new(),
        };
        return;
    }

    TextEdit::multiline(doc).show(ui);
}

Chapter 3: Async Tasks

Our desired UI

Loading

Async Rust needs a runtime

cargo add tokio --features full
// main.rs

#[tokio::main]
async fn main() -> eframe::Result {
    tokio::task::block_in_place(|| {
        let options = eframe::NativeOptions::default();
        eframe::run_native(
            "Rusty Collab",
            options,
            Box::new(|_cc| {
                // Setup
                let app = App {
                    state: Arc::new(Mutex::new(State::Lobby {
                        join_existing: false,
                        name_input: String::new(),
                    })),
                };
                Ok(Box::new(app))
            }),
        )
    })
}

What we want

// task_start_session.rs

pub async fn task_start_session(state: &mut State) {
	*state = State::Loading;
    
    sleep(Duration::from_millis(1000)).await;
    
    *state = State::Session { doc: String::new() }
}
// screen_lobby.rs

pub fn render_lobby(ui: &mut Ui, state: &mut State) {

    //...

    if ui.button("Create New Session").clicked() {
        tokio::spawn(task_start_session(state));
    }

    //...

}

This doesn't work ❌

Rust doesn’t allow simultaneous access to
“&mut State” from different places.

Solution: Mutexes!

// main.rs

#[derive(Clone)]
struct App {
    state: Arc<Mutex<State>>,
}

pub enum State {
    Lobby {
        join_existing: bool,
        name_input: String,
    },
    Loading,
    Session {
        doc: String,
    },
}
// task_start_session.rs

pub async fn task_start_session(app: App) {
    {
        let mut state = app.state.lock();
        *state = State::Loading;
    }

    sleep(Duration::from_millis(100)).await;

    {
        let mut state = app.state.lock();
        *state = State::Session { doc: String::new() }
    }
}

cargo add parking_lot
  • Arc lets many threads own the same data.

    • but by itself only allowing read-only & access

  • Mutex lets only one thread use the data at a time.

    • allowing full &mut read-write access

  • App now becomes a shareable reference to the data.

  • Multiple App references to the same underlying state can exist at the same time

  • App is Clone-able

// main.rs

#[derive(Clone)]
struct App {
    state: Arc<Mutex<State>>,
}

pub enum State {
    Lobby(LobbyState),
    Loading,
    Session(SessionState),
}
// screen_session.rs

pub struct SessionState {
    pub doc: String,
}

pub fn render_session(ui: &mut Ui, state: &mut SessionState) {
    if ui.button("Leave Session").clicked() {
        return;
    }

    TextEdit::multiline(&mut state.doc).show(ui);
}
// screen_lobby.rs

pub struct LobbyState {
    pub join_existing: bool,
    pub name_input: String,
}

pub fn render_lobby(ui: &mut Ui, state: &mut LobbyState) {
	//...
}

Refactoring State

  • Break State into smaller pieces
    • LobbyState
    • SessionState
// screen_session.rs

pub fn render_session(ui: &mut Ui, app: App, state: &mut SessionState) {
    if ui.button("Leave Session").clicked() {
        tokio::spawn(task_leave_session(app));
    }

    TextEdit::multiline(&mut state.doc).show(ui);
}
// screen_lobby.rs

pub fn render_lobby(ui: &mut Ui, app: App, state: &mut LobbyState) {
	// ...
	if ui.button("Create New Session").clicked() {
        tokio::spawn(task_start_session(app));
    }
	// ...
}

Pass App to our render functions

  • task_start_session and task_leave_session both require an App argument
  • This way our UI code can spawn new tasks (e.g. on button click)
// main.rs

fn render_ui(ui: &mut egui::Ui, app: &mut App) {
    let mut state = app.state.lock();

    match &mut *state {
        State::Lobby(state) => render_lobby(ui, app.clone(), state),
        State::Loading => {
            ui.spinner();
        }
        State::Session(state) => render_session(ui, app.clone(), state),
    }
}

Top render_ui function

  1. Lock the mutex (make it our turn) and extract the state.
  2. match the state and call the corresponding screen's render function
// main.rs

#[derive(Clone)]
struct App {
    state: Arc<Mutex<State>>,
    egui_ctx: egui::Context,
}

// task_start_session.rs

pub async fn task_start_session(app: App) {
    {
        let mut state = app.state.lock();
        *state = State::Loading;
        app.egui_ctx.request_repaint();
    }

    sleep(Duration::from_millis(100)).await;

    {
        let mut state = app.state.lock();
        *state = State::Session(SessionState { doc: String::new() });
        app.egui_ctx.request_repaint();
    }
}

Another problem

  • egui doesn't render the UI unless an event (like button click) happens.
  • egui doesn't know our task has changed the state, and doesn't render the UI again.
  • Solution:
    • explicitly request egui to render (repaint) the UI again, using .request_repaint()
impl App {
    pub fn replace_state(&self, state: State) -> State {
        let mut state_lock = self.state.lock();
        let old_state = std::mem::replace(&mut *state_lock, state);
        self.egui_ctx.request_repaint();

        old_state
    }
}
// task_start_session.rs

pub async fn task_start_session(app: App) {
    app.replace_state(State::Loading);

    sleep(Duration::from_millis(100)).await;

    app.replace_state(
    	State::Session(
        	SessionState { doc: String::new() }
        )
    );
}

A little helper

  • Every time we want to change the state we need to:
    • Lock the mutex
    • Change the state
    • Ask egui to repaint
    • Wrap the above in a block (so the mutex unlocks)
  • This is tedious. 
  • We write a method replace_state to help us.
// task_start_session.rs

pub async fn task_start_session(app: App) {
    {
        let mut state = app.state.lock();
        *state = State::Loading;
        app.egui_ctx.request_repaint();
    }

    sleep(Duration::from_millis(100)).await;

    {
        let mut state = app.state.lock();
        *state = State::Session(
        	State::Session(
        		SessionState { doc: String::new() }
        	)
        );
        app.egui_ctx.request_repaint();
    }
}

Chapter 4: Networking!

Centralized or Decentralized

iroh

https://iroh.computer

iroh: iroh-gossip

https://docs.iroh.computer/examples/chat

What are we gossiping?

#[derive(SchemaRead, SchemaWrite)]
pub enum GossipMessage {
    RequestData,
    Update { new_doc: String },
}

RequestData:

"Hey everyone! I'm new here, please send me the full document."

Update:

"I just made an edit, check this out!"

 

No separate response to RequestData because Update currently sends the entirety of the updated document, sufficient as a response.

What are we loading?

// task_start_session.rs

pub async fn task_start_session(app: App) {
    app.replace_state(State::Loading);

    sleep(Duration::from_millis(100)).await;

    app.replace_state(State::Session(SessionState { doc: String::new() }));
}
// task_start_session.rs

pub async fn task_start_session(app: App) {
    app.replace_state(State::Loading);

    let session_state = setup(&app).await.unwrap();

    app.replace_state(State::Session(SessionState { doc: String::new() }));
}

Loading

// task_start_session.rs

pub async fn task_start_session(app: App) {
    app.replace_state(State::Loading);

    let session_state = setup(&app).await.unwrap();

    app.replace_state(State::Session(session_state));
}
pub struct SessionState {
    pub doc: String,
    pub iroh_endpoint: Endpoint,
    pub iroh_gossip: Gossip,
    pub iroh_router: Router,
    pub outbound_queue: OutboundQueue,
    pub main_loop_handle: JoinHandle<Result<()>>,
}

type OutboundQueue = UnboundedSender<GossipMessage>;

async fn setup(app: &App) -> Result<SessionState> {
    const GOSSIP_MAX_MESSAGE_SIZE: usize = 2 * 1024 * 1024;
    const TOPIC_ID_BYTES: [u8; 32] = [23u8; 32];

    let iroh_endpoint = Endpoint::bind().await?;
    let iroh_gossip = Gossip::builder()
        .max_message_size(GOSSIP_MAX_MESSAGE_SIZE)
        .spawn(iroh_endpoint.clone());
    let iroh_router = Router::builder(iroh_endpoint.clone())
        .accept(iroh_gossip::ALPN, iroh_gossip.clone())
        .spawn();
    let mut gossip_topic = iroh_gossip
        .subscribe(TopicId::from_bytes(TOPIC_ID_BYTES), vec![])
        .await?;
    let (outbound_queue, mut outbound_queue_rx) = mpsc::unbounded_channel::<GossipMessage>();

    let main_loop_handle: JoinHandle<Result<()>> = tokio::spawn({
        let mut app = app.clone();
        let outbound_queue = outbound_queue.clone();
        async move {
            loop {
                select! {
                    Some(event) = gossip_topic.next() => {
                        if let Ok(Event::Received(message)) = event {
                            let (_nonce, gossip_message): (u128, GossipMessage) = wincode::deserialize(&message.content)?;
                            handle_gossip_message(gossip_message, &mut app, &outbound_queue)?;
                        }
                    }
                    Some(message) = outbound_queue_rx.recv() => {
                        let bytes = wincode::serialize(&(rand::random::<u128>(), &message))?;
                        gossip_topic.broadcast(bytes.into()).await?;
                    }
                }
            }
        }
    });

    outbound_queue.send(GossipMessage::RequestData)?;

    Ok(SessionState {
        doc: String::new(),
        iroh_endpoint,
        iroh_gossip,
        iroh_router,
        outbound_queue: outbound_queue,
        main_loop_handle,
    })
}

#[derive(SchemaRead, SchemaWrite)]
pub enum GossipMessage {
    RequestData,
    Update { new_doc: String },
}

fn handle_gossip_message(
    message: GossipMessage,
    app: &mut App,
    outbound_queue: &OutboundQueue,
) -> Result<()> {
    let mut state = app.state.lock();
    let State::Session(session_state) = &mut *state else {
        bail!("Expected Session state");
    };

    match message {
        GossipMessage::RequestData => {
            outbound_queue.send(GossipMessage::Update {
                new_doc: session_state.doc.clone(),
            })?;
        }
        GossipMessage::Update { new_doc } => {
            session_state.doc = new_doc;
            app.egui_ctx.request_repaint();
        }
    }

    Ok(())
}

Code: task_start_session.rs

// screen_session.rs

pub fn render_session(ui: &mut Ui, app: App, state: &mut SessionState) {
    if ui.button("Leave Session").clicked() {
        tokio::spawn(task_leave_session(app));
    }

    let text_edit = TextEdit::multiline(&mut state.doc).show(ui);

    if text_edit.response.changed() {
        let _ = state.outbound_queue.send(GossipMessage::Update {
            new_doc: state.doc.clone(),
        });
    }
}

Code: screen_session.rs

(With demo!)

Chapter 5: Joining Existing Session

On the off chance a p2p connection was not possible (NAT issues, etc...) relays relay the data between peers.

 

All traffic is still end-to-end encrypted.

Relay

iroh works with different types of Discovery services.

 

A Discovery service allows the conversion:
Endpoint ID -> p2p dialing details

 

This allows users to connect to each other.

 

By default, we use 

 dns.iroh.link

as a discovery service.

Discovery

Every iroh user (endpoint) will have an Endpoint ID, uniquely identifying them.

Endpoint ID

Iroh

pub fn render_lobby(ui: &mut Ui, app: App, state: &mut LobbyState) {
    ui.horizontal(|ui| {
        ui.selectable_value(&mut state.join_existing, false, "Create new session");
        ui.selectable_value(&mut state.join_existing, true, "Join existing session");
    });
    ui.heading("Rusty Collab");
    ui.horizontal(|ui| {
        ui.label("Your name: ");
        ui.text_edit_singleline(&mut state.name_input);
    });

    if state.join_existing {
        ui.horizontal(|ui| {
            ui.label("Existing peer ID: ");
            ui.text_edit_singleline(&mut state.existing_peer_input);
        });
    }

    if !state.join_existing {
        if ui.button("Create New Session").clicked() {
            tokio::spawn(task_start_session(
            	app,
                state.name_input.clone(),
                None
            ));
        }
    } else {
        if ui.button("Join Existing Session").clicked() {
            tokio::spawn(task_start_session(
                app,
                state.name_input.clone(),
                Some(state.existing_peer_input.clone()),
            ));
            return;
        }
    }
}

Code: screen_lobby.rs

Code: screen_session.rs

pub fn render_session(ui: &mut Ui, app: App, state: &mut SessionState) {
    if ui.button("Leave Session").clicked() {
        tokio::spawn(task_leave_session(app));
    }

    ui.horizontal(|ui| {
        ui.label("My ID:");
        let id = state.iroh_endpoint.id().to_string();
        ui.label(&id);
        if ui.button("📋").clicked() {
            ui.ctx().copy_text(id);
        }
    });

    let text_edit = TextEdit::multiline(&mut state.doc).show(ui);

    if text_edit.response.changed() {
        let _ = state.outbound_queue.send(GossipMessage::Update {
            new_doc: state.doc.clone(),
        });
    }
}

Code: task_start_session.rs

pub async fn task_start_session(
	app: App,
    name: String,
    existing_peer: Option<String>
) {
    let old_state = app.replace_state(State::Loading);

    let Ok(session_state) = setup(&app, name, existing_peer).await else {
        app.replace_state(old_state);
        return;
    };

    app.replace_state(State::Session(session_state));
}

async fn setup(
	app: &App,
	name: String,
    existing_peer: Option<String>
) -> Result<SessionState> {
    const GOSSIP_MAX_MESSAGE_SIZE: usize = 2 * 1024 * 1024;
    const TOPIC_ID_BYTES: [u8; 32] = [23u8; 32];

    let iroh_endpoint = Endpoint::bind().await?;
    let iroh_gossip = Gossip::builder()
        .max_message_size(GOSSIP_MAX_MESSAGE_SIZE)
        .spawn(iroh_endpoint.clone());
    let iroh_router = Router::builder(iroh_endpoint.clone())
        .accept(iroh_gossip::ALPN, iroh_gossip.clone())
        .spawn();

    let bootstrap_nodes = if let Some(existing_peer) = &existing_peer {
        vec![existing_peer.parse()?]
    } else {
        vec![]
    };

    let mut gossip_topic = iroh_gossip
        .subscribe(TopicId::from_bytes(TOPIC_ID_BYTES), bootstrap_nodes)
        .await?;

    if existing_peer.is_some() {
        gossip_topic.joined().await?;
    }

    let (outbound_queue, mut outbound_queue_rx) = mpsc::unbounded_channel::<GossipMessage>();

    let main_loop_handle: JoinHandle<Result<()>> = tokio::spawn({
        let mut app = app.clone();
        let outbound_queue = outbound_queue.clone();
        async move {
            loop {
                select! {
                    Some(event) = gossip_topic.next() => {
                        if let Ok(Event::Received(message)) = event {
                            let (_nonce, gossip_message): (u128, GossipMessage) = wincode::deserialize(&message.content)?;
                            handle_gossip_message(gossip_message, &mut app, &outbound_queue)?;
                        }
                    }
                    Some(message) = outbound_queue_rx.recv() => {
                        let bytes = wincode::serialize(&(rand::random::<u128>(), &message))?;
                        gossip_topic.broadcast(bytes.into()).await?;
                    }
                }
            }
        }
    });

    outbound_queue.send(GossipMessage::RequestData)?;

    Ok(SessionState {
        doc: String::new(),
        iroh_endpoint,
        iroh_gossip,
        iroh_router,
        outbound_queue: outbound_queue,
        main_loop_handle,
    })
}

Demo!

Chapter 6: CRDTs

Good scenario

Name: Parsa

Age: 

 

Name: Bob

Age:

Name: Parsa

Age: 

 

Name: Bob

Age:

Parsa

Bob

Good scenario

Name: Parsa

Age: 

 

Name: Bob

Age:

Name: Parsa

Age: 24

 

Name: Bob

Age:

Name: Parsa

Age: 24 

 

Name: Bob

Age:

Name: Parsa

Age: 24 

 

Name: Bob

Age:

 Step 1 - Write "24"

Name: Parsa

Age: 24 

 

Name: Bob

Age:

 Step 2 - Send Update 

 Step 1 - Write "24"

 Step 3 - Overwrite Doc 

Parsa

Bob

Good scenario

Name: Parsa

Age: 

 

Name: Bob

Age:

Name: Parsa

Age: 24

 

Name: Bob

Age: 25

Name: Parsa

Age: 24 

 

Name: Bob

Age:

Name: Parsa

Age: 24

 

Name: Bob

Age: 25

 Step 5 - Send Update 

 Step 4 - Write "25"

 Step 6 - Overwrite Doc 

Parsa

Bob

Name: Parsa

Age: 24 

 

Name: Bob

Age: 25

Bad Scenario

Name: Parsa

Age: 

 

Name: Bob

Age:

Name: Parsa

Age:

 

Name: Bob

Age:

Name: Parsa

Age: 24 

 

Name: Bob

Age:

Name: Parsa

Age:

 

Name: Bob

Age:

Parsa

Bob

Bad Scenario

Simuletanous Updates

Parsa

Bob

Name: Parsa

Age:

 

Name: Bob

Age: 25

Name: Parsa

Age: 24 

 

Name: Bob

Age:

Name: Parsa

Age: 24

 

Name: Bob

Age:

Name: Parsa

Age:

 

Name: Bob

Age: 25

Wrote "24" but got overwritten

Wrote "25" but got overwritten

Simuletanous Updates

Solution: CRDTs

Gemini: "Conflict-free Replicated Data Types (CRDTs) are specialized data structures that enable multiple users to update shared data (e.g., text, counters) across different, disconnected, or distributed computers simultaneously. They automatically merge updates to reach a consistent state, eliminating conflict resolution. "

Superb Article 💯

CRDTs in Rust

Written in Rust

Compiles to WASM

First-class TS/JS support

cargo add loro

Solution: CRDTs

Parsa

Bob

Name: Parsa

Age:

 

Name: Bob

Age: 

Name: Parsa

Age:

 

Name: Bob

Age: 

= Loro Document

= Loro Update

Solution: CRDTs

Parsa

Bob

Name: Parsa

Age: 24

 

Name: Bob

Age: 25

Name: Parsa

Age: 24

 

Name: Bob

Age: 25

= Loro Document

Simuletanous Updates

Delta Update: "25" at position XXX

Simuletanous Updates

Delta Update: "24" at position YYY

= Loro Update

Solution: CRDTs

Parsa

Name: Parsa

Age: 24

 

Name: Bob

Age:

= Loro Document

Loro -> Delta Update: "24" at position YYY

= Loro Update

Loro -> Full Export: Full Document with full history

loro_doc.import(&update)?;

Code: task_start_session.rs

pub async fn task_start_session(app: App, name: String, existing_peer: Option<String>) {
    let old_state = app.replace_state(State::Loading);

    let Ok(session_state) = setup(&app, name, existing_peer).await else {
        app.replace_state(old_state);
        return;
    };

    app.replace_state(State::Session(session_state));
}
pub struct SessionState {
    pub loro_doc: LoroDoc,
    pub loro_sub: loro::Subscription,
    pub iroh_endpoint: Endpoint,
    pub iroh_gossip: Gossip,
    pub iroh_router: Router,
    pub outbound_queue: OutboundQueue,
    pub main_loop_handle: JoinHandle<Result<()>>,
}

type OutboundQueue = UnboundedSender<GossipMessage>;

async fn setup(app: &App, name: String, existing_peer: Option<String>) -> Result<SessionState> {
    const GOSSIP_MAX_MESSAGE_SIZE: usize = 2 * 1024 * 1024;
    const TOPIC_ID_BYTES: [u8; 32] = [23u8; 32];

    let iroh_endpoint = Endpoint::bind().await?;
    let iroh_gossip = Gossip::builder()
        .max_message_size(GOSSIP_MAX_MESSAGE_SIZE)
        .spawn(iroh_endpoint.clone());
    let iroh_router = Router::builder(iroh_endpoint.clone())
        .accept(iroh_gossip::ALPN, iroh_gossip.clone())
        .spawn();

    let bootstrap_nodes = if let Some(existing_peer) = &existing_peer {
        vec![existing_peer.parse()?]
    } else {
        vec![]
    };

    let mut gossip_topic = iroh_gossip
        .subscribe(TopicId::from_bytes(TOPIC_ID_BYTES), bootstrap_nodes)
        .await?;

    if existing_peer.is_some() {
        gossip_topic.joined().await?;
    }

    let (outbound_queue, mut outbound_queue_rx) = mpsc::unbounded_channel::<GossipMessage>();

    let loro_doc = LoroDoc::new();
    let loro_sub = {
        let outbound_queue = outbound_queue.clone();
        loro_doc.subscribe_local_update(Box::new(move |bytes| {
            let _ = outbound_queue.send(GossipMessage::Update {
                data: bytes.to_vec(),
            });
            true
        }))
    };

    let main_loop_handle: JoinHandle<Result<()>> = tokio::spawn({
        let mut app = app.clone();
        let outbound_queue = outbound_queue.clone();
        let loro_doc = loro_doc.clone();
        async move {
            loop {
                select! {
                    Some(event) = gossip_topic.next() => {
                        if let Ok(Event::Received(message)) = event {
                            let (_nonce, gossip_message): (u128, GossipMessage) = wincode::deserialize(&message.content)?;
                            handle_gossip_message(gossip_message, &mut app, &loro_doc, &outbound_queue)?;
                        }
                    }
                    Some(message) = outbound_queue_rx.recv() => {
                        let bytes = wincode::serialize(&(rand::random::<u128>(), &message))?;
                        gossip_topic.broadcast(bytes.into()).await?;
                    }
                }
            }
        }
    });

    outbound_queue.send(GossipMessage::RequestData)?;

    Ok(SessionState {
        loro_doc,
        loro_sub,
        iroh_endpoint,
        iroh_gossip,
        iroh_router,
        outbound_queue: outbound_queue,
        main_loop_handle,
    })
}

#[derive(SchemaRead, SchemaWrite)]
pub enum GossipMessage {
    RequestData,
    Update { data: Vec<u8> },
}

fn handle_gossip_message(
    message: GossipMessage,
    app: &mut App,
    loro_doc: &LoroDoc,
    outbound_queue: &OutboundQueue,
) -> Result<()> {
    let mut state = app.state.lock();
    let State::Session(session_state) = &mut *state else {
        bail!("Expected Session state");
    };

    match message {
        GossipMessage::RequestData => {
            let snapshot = loro_doc.export(loro::ExportMode::Snapshot)?;
            let _ = outbound_queue.send(GossipMessage::Update { data: snapshot });
        }
        GossipMessage::Update { data } => {
            loro_doc.import(&data)?;
            app.egui_ctx.request_repaint();
        }
    }

    Ok(())
}

Code: screen_session.rs

pub fn render_session(ui: &mut Ui, app: App, state: &mut SessionState) {
    if ui.button("Leave Session").clicked() {
        tokio::spawn(task_leave_session(app));
    }

    ui.horizontal(|ui| {
        ui.label("My ID:");
        let id = state.iroh_endpoint.id().to_string();
        ui.label(&id);
        if ui.button("📋").clicked() {
            ui.ctx().copy_text(id);
        }
    });

    let doc_text = state.loro_doc.get_text("text");
    let mut text_content = doc_text.to_string();

    let text_edit = TextEdit::multiline(&mut text_content).show(ui);

    if text_edit.response.changed() {
        let _ = doc_text.update(&text_content, Default::default());
        state.loro_doc.commit();
    }
}

Code: That's it.

🤙

Chapter 7: Awareness (Presence)

Awareness

Awareness

Parsa

Bob

I'm here!

I'm here!

I'm here!

I'm here!

I'm here!

I'm here!

I'm here!

I'm here!

I'm here!

I'm here!

I'm here!

I'm here!

Keep yelling every 500 millisecond

Awareness

const CACHE_TTL: Duration = Duration::from_secs(5);

pub type IdBytes = [u8; 32];
pub type AwarenessCache = HashMap<IdBytes, (Awareness, Instant)>;

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Awareness {
    pub endpoint_id: IdBytes,
    pub name: String,
    pub timestamp_ms: u64,
}

pub fn awareness_refresh(app: &App) -> Result<()> {
    let mut state = app.state.lock();
    let crate::State::Session(session_state) = &mut *state else {
        bail!("Expected Session state");
    };

    let timestamp_now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis() as u64;
    session_state
        .outbound_queue
        .send(GossipMessage::Awareness(Awareness {
            endpoint_id: session_state.own_id,
            name: session_state.own_name.clone(),
            timestamp_ms: timestamp_now,
        }))?;

    let instant_now = Instant::now();
    session_state
        .awareness_cache
        .retain(|_, (_, received_at)| 
        	instant_now.duration_since(*received_at) < CACHE_TTL);

    app.egui_ctx.request_repaint();

    Ok(())
}

pub fn update_awareness_cache(state: &mut SessionState, awareness: Awareness) {
    if awareness.endpoint_id == state.own_id {
        return;
    }

    let old_entry = state.awareness_cache.get(&awareness.endpoint_id);
    let should_update = if let Some((existing, _)) = old_entry {
        awareness.timestamp_ms > existing.timestamp_ms
    } else {
        true
    };

    if should_update {
        state
            .awareness_cache
            .insert(awareness.endpoint_id, (awareness, Instant::now()));
    }
}

awareness_refresh() every 500ms

  • Send our Awareness to everyone
  • Clear stale entries from others

Awareness struct

  • Our Id
  • Our name
  • Awareness timestamp
  • (Chapter 9) Our cursors

update_awareness_cache()

  • Receive awareness from peers

AwarenessCache

  • Keeps all of our active peers' Awareness

Code

async fn setup(app: &App, name: String, existing_peer: Option<String>) -> Result<SessionState> {
    const GOSSIP_MAX_MESSAGE_SIZE: usize = 2 * 1024 * 1024;
    const TOPIC_ID_BYTES: [u8; 32] = [23u8; 32];

    let iroh_endpoint = Endpoint::bind().await?;
    let iroh_gossip = Gossip::builder()
        .max_message_size(GOSSIP_MAX_MESSAGE_SIZE)
        .spawn(iroh_endpoint.clone());
    let iroh_router = Router::builder(iroh_endpoint.clone())
        .accept(iroh_gossip::ALPN, iroh_gossip.clone())
        .spawn();

    let bootstrap_nodes = if let Some(existing_peer) = &existing_peer {
        vec![existing_peer.parse()?]
    } else {
        vec![]
    };

    let mut gossip_topic = iroh_gossip
        .subscribe(TopicId::from_bytes(TOPIC_ID_BYTES), bootstrap_nodes)
        .await?;

    if existing_peer.is_some() {
        gossip_topic.joined().await?;
    }

    let (outbound_queue, mut outbound_queue_rx) = mpsc::unbounded_channel::<GossipMessage>();

    let loro_doc = LoroDoc::new();
    let loro_sub = {
        let outbound_queue = outbound_queue.clone();
        loro_doc.subscribe_local_update(Box::new(move |bytes| {
            let _ = outbound_queue.send(GossipMessage::Update {
                data: bytes.to_vec(),
            });
            true
        }))
    };

    let main_loop_handle: JoinHandle<Result<()>> = tokio::spawn({
        let mut app = app.clone();
        let outbound_queue = outbound_queue.clone();
        let loro_doc = loro_doc.clone();
        let mut awareness_interval = interval(Duration::from_millis(500));
        async move {
            loop {
                select! {
                    Some(event) = gossip_topic.next() => {
                        if let Ok(Event::Received(message)) = event {
                            let (_nonce, gossip_message): (u128, GossipMessage) = from_bytes(&message.content)?;
                            handle_gossip_message(gossip_message, &mut app, &loro_doc, &outbound_queue)?;
                        }
                    }
                    Some(message) = outbound_queue_rx.recv() => {
                        let bytes = to_bytes(&(rand::random::<u128>(), &message))?;
                        gossip_topic.broadcast(bytes.into()).await?;
                    }
                    _ = awareness_interval.tick() => {
                        awareness_refresh(&app)?;
                    }
                }
            }
        }
    });

    outbound_queue.send(GossipMessage::RequestData)?;

    Ok(SessionState {
        own_id: iroh_endpoint.id().as_bytes().to_owned(),
        own_name: name,
        loro_doc,
        loro_sub,
        iroh_endpoint,
        iroh_gossip,
        iroh_router,
        awareness_cache: HashMap::new(),
        outbound_queue: outbound_queue,
        main_loop_handle,
    })
}

#[derive(Serialize, Deserialize)]
pub enum GossipMessage {
    RequestData,
    Update { data: Vec<u8> },
    Awareness(Awareness),
}

pub fn handle_gossip_message(
    message: GossipMessage,
    app: &mut App,
    loro_doc: &LoroDoc,
    outbound_queue: &OutboundQueue,
) -> Result<()> {
    let mut state = app.state.lock();
    let State::Session(session_state) = &mut *state else {
        bail!("Expected Session state");
    };

    match message {
        GossipMessage::RequestData => {
            let snapshot = loro_doc.export(loro::ExportMode::Snapshot)?;
            let _ = outbound_queue.send(GossipMessage::Update { data: snapshot });
        }
        GossipMessage::Update { data } => {
            loro_doc.import(&data)?;
            app.egui_ctx.request_repaint();
        }
        GossipMessage::Awareness(awareness) => {
            awareness::update_awareness_cache(session_state, awareness);
            app.egui_ctx.request_repaint();
        }
    }

    Ok(())
}

pub fn render_session(ui: &mut Ui, app: App, state: &mut SessionState) {
    if ui.button("Leave Session").clicked() {
        tokio::spawn(task_leave_session(app));
    }

    ui.horizontal(|ui| {
        ui.label("My ID:");
        let id = state.iroh_endpoint.id().to_string();
        ui.label(&id);
        if ui.button("📋").clicked() {
            ui.ctx().copy_text(id);
        }
    });

    ui.horizontal(|ui| {
        let _ = ui.button(format!("Me: {}", state.own_name));

        state
            .awareness_cache
            .iter()
            .for_each(|(_, (awareness, _))| {
                let _ = ui.button(format!("{}", awareness.name));
            });
    });

    let doc_text = state.loro_doc.get_text("text");
    let mut text_content = doc_text.to_string();

    let text_edit = TextEdit::multiline(&mut text_content).show(ui);

    if text_edit.response.changed() {
        let _ = doc_text.update(&text_content, Default::default());
        state.loro_doc.commit();
    }
}

Main Loop

  • Call awareness_refresh() every 500ms

handle_gossip_message()

  • Call update_awareness_cache()

UI: render_session()

  • Render a "badge" for every awareness cache entry

Chapter 8: Fixing Cursors

Problem: Cursors keep "sliding" 

Parsa

Bob

T=0

T=1

Type "Hello! "

Solution: Use Loro Cursors

  • Loro Cursors point to meaningful selections, such as "Parsa" not mere indexes
  • No "sliding" if inserted before

 

  • Two functions:
    • update_egui_from_loro_cursors()
    • get_loro_cursors_from_egui()
  • We store Loro cursors in SessionState and keep them updated
  • If we receive an update, we update egui's cursors based on our stored Loro cursors

Solution: Use Loro Cursors

Parsa

Bob

T=0

T=1

Type "Hello! "

AI coding in 2026

Chapter 9

Chapter 10

Chapter 9: Awareness (Cursors)

What we want

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Awareness {
    pub endpoint_id: IdBytes,
    pub name: String,
    pub loro_cursors: Option<(Cursor, Cursor)>,
    pub timestamp_ms: u64,
}

pub fn awareness_refresh(app: &App) -> Result<()> {
    let mut state = app.state.lock();
    let crate::State::Session(session_state) = &mut *state else {
        bail!("Expected Session state");
    };

    broadcast_awareness(session_state)?;

    let instant_now = Instant::now();
    session_state
        .awareness_cache
        .retain(|_, (_, received_at)| instant_now.duration_since(*received_at) < CACHE_TTL);

    app.egui_ctx.request_repaint();

    Ok(())
}

pub fn broadcast_awareness(session_state: &mut SessionState) -> Result<()> {
    let timestamp_now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis() as u64;
    session_state
        .outbound_queue
        .send(GossipMessage::Awareness(Awareness {
            endpoint_id: session_state.own_id,
            name: session_state.own_name.clone(),
            loro_cursors: session_state.cursors.clone(),
            timestamp_ms: timestamp_now,
        }))?;

    Ok(())
}

Awareness Code


pub fn render_session(ui: &mut Ui, app: App, state: &mut SessionState) {
    if ui.button("Leave Session").clicked() {
        tokio::spawn(task_leave_session(app));
    }

    ui.horizontal(|ui| {
        ui.label("My ID:");
        let id = state.iroh_endpoint.id().to_string();
        ui.label(&id);
        if ui.button("📋").clicked() {
            ui.ctx().copy_text(id);
        }
    });

    ui.horizontal(|ui| {
        let text = RichText::new(&state.own_name).color(generate_peer_color(&state.own_id));
        let _ = ui.button(text);

        state
            .awareness_cache
            .iter()
            .for_each(|(_, (awareness, _))| {
                let text = RichText::new(&awareness.name)
                    .color(generate_peer_color(&awareness.endpoint_id));
                let _ = ui.button(text);
            });
    });

    let doc_text = state.loro_doc.get_text("text");
    let mut text_content = doc_text.to_string();

    let text_edit = {
        let front_layer_id = LayerId::new(ui.layer_id().order, ui.id().with("front"));
        ui.ctx().set_sublayer(ui.layer_id(), front_layer_id);

        ui.scope_builder(UiBuilder::new().layer_id(front_layer_id), |ui| {
            TextEdit::multiline(&mut text_content)
                .background_color(Color32::TRANSPARENT)
                .show(ui)
        })
        .inner
    };

    render_peer_cursors(ui, &text_edit, &state.awareness_cache, &state.loro_doc);

    if state.egui_cursors_needs_update {
        state.egui_cursors_needs_update = false;
        update_egui_from_loro_cursors(ui, text_edit.response.id, &state.loro_doc, &state.cursors);
    } else {
        let new_cursors = get_loro_cursors_from_egui(&text_edit, &doc_text);
        if new_cursors != state.cursors {
            state.cursors = new_cursors;
            let _ = broadcast_awareness(state);
        }
    }

    if text_edit.response.changed() {
        let _ = doc_text.update(&text_content, Default::default());
        state.loro_doc.commit();
    }
}

fn update_egui_from_loro_cursors(
    ui: &mut Ui,
    text_edit_id: egui::Id,
    loro_doc: &loro::LoroDoc,
    cursors: &LoroCursors,
) {
    // Based on https://github.com/emilk/egui/blob/main/crates/egui_demo_lib/src/demo/text_edit.rs

    let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) else {
        return;
    };

    let egui_cursor_range = if let Some((primary, secondary)) = cursors.as_ref()
        && let Ok(primary_pos) = loro_doc.get_cursor_pos(primary)
        && let Ok(secondary_pos) = loro_doc.get_cursor_pos(secondary)
    {
        Some(egui::text::CCursorRange::two(
            CCursor::new(secondary_pos.current.pos),
            CCursor::new(primary_pos.current.pos),
        ))
    } else {
        None
    };

    state.cursor.set_char_range(egui_cursor_range);
    state.store(ui.ctx(), text_edit_id);
    ui.memory_mut(|mem| mem.request_focus(text_edit_id));
}

fn get_loro_cursors_from_egui(
    output: &egui::text_edit::TextEditOutput,
    doc_text: &loro::LoroText,
) -> LoroCursors {
    let Some(cursor_range) = output.cursor_range else {
        return None;
    };

    let primary_idx = cursor_range.primary.index;
    let secondary_idx = cursor_range.secondary.index;

    let Some(primary) = doc_text.get_cursor(primary_idx, loro::cursor::Side::Left) else {
        return None;
    };
    let Some(secondary) = doc_text.get_cursor(secondary_idx, loro::cursor::Side::Left) else {
        return None;
    };

    Some((primary.clone(), secondary.clone()))
}

fn render_peer_cursors(
    ui: &mut egui::Ui,
    text_edit_output: &egui::text_edit::TextEditOutput,
    awareness_cache: &crate::awareness::AwarenessCache,
    loro_doc: &loro::LoroDoc,
) {
    let painter = ui.painter_at(text_edit_output.text_clip_rect);
    let galley = &text_edit_output.galley;
    let galley_pos = text_edit_output.galley_pos;

    for (endpoint_id, (awareness, _)) in awareness_cache.iter() {
        ui.horizontal(|ui| {
            if let Some((cursor_primary, cursor_secondary)) = &awareness.loro_cursors {
                ui.label(format!("{:?}", loro_doc.get_cursor_pos(cursor_primary)));
                ui.label(format!("{:?}", loro_doc.get_cursor_pos(cursor_secondary)));
            } else {
                ui.label("No cursors");
            }
        });

        if let Some((cursor_primary, cursor_secondary)) = &awareness.loro_cursors
            && let Ok(primary) = loro_doc.get_cursor_pos(cursor_primary)
            && let Ok(secondary) = loro_doc.get_cursor_pos(cursor_secondary)
        {
            let color = generate_peer_color(endpoint_id);
            let selection_color =
                Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 80);

            paint_awareness_selection(
                &painter,
                galley,
                galley_pos,
                primary.current.pos,
                secondary.current.pos,
                selection_color,
            );

            let cursor_rect = galley
                .pos_from_cursor(CCursor::new(primary.current.pos))
                .translate(galley_pos.to_vec2());

            paint_awareness_cursor(&painter, cursor_rect, color);
        }
    }
}

fn generate_peer_color(endpoint_id: &[u8; 32]) -> Color32 {
    fn hsl_to_rgb(h: f32, s: f32, l: f32) -> Color32 {
        let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
        let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
        let m = l - c / 2.0;

        let (r, g, b) = match h as i32 {
            0..=59 => (c, x, 0.0),
            60..=119 => (x, c, 0.0),
            120..=179 => (0.0, c, x),
            180..=239 => (0.0, x, c),
            240..=299 => (x, 0.0, c),
            _ => (c, 0.0, x),
        };

        Color32::from_rgb(
            ((r + m) * 255.0) as u8,
            ((g + m) * 255.0) as u8,
            ((b + m) * 255.0) as u8,
        )
    }
    // Generate hue from hash of full endpoint_id for better distribution
    let mut hash: u32 = 2166136261;
    for byte in endpoint_id {
        hash ^= *byte as u32;
        hash = hash.wrapping_mul(16777619);
    }

    let hue = (hash % 360) as f32; // 0-360 degrees
    let saturation = 0.85; // High saturation for vibrant colors
    let lightness = 0.35; // Dark enough for white background (0-0.5 range)

    hsl_to_rgb(hue, saturation, lightness)
}

fn paint_awareness_cursor(painter: &egui::Painter, cursor_rect: egui::Rect, color: Color32) {
    let top = cursor_rect.center_top();
    let bottom = cursor_rect.center_bottom();

    painter.line_segment([top, bottom], (2.0, color));

    // Half circle pointing right, positioned at the top of the bar
    let center = top + egui::vec2(0.0, 4.0);
    let radius = 4.0;

    // Clip to only draw the right half of the circle
    let clip_rect = egui::Rect::from_min_max(
        egui::pos2(center.x, center.y - radius - 1.0),
        egui::pos2(center.x + radius + 1.0, center.y + radius + 1.0),
    );
    let clipped_painter = painter.with_clip_rect(clip_rect);
    clipped_painter.circle_filled(center, radius, color);
}

fn paint_awareness_selection(
    painter: &egui::Painter,
    galley: &Arc<egui::Galley>,
    galley_pos: egui::Pos2,
    primary: usize,
    secondary: usize,
    color: Color32,
) {
    if primary == secondary {
        return;
    }

    let (min_idx, max_idx) = (primary.min(secondary), primary.max(secondary));
    let min_layout = galley.layout_from_cursor(CCursor::new(min_idx));
    let max_layout = galley.layout_from_cursor(CCursor::new(max_idx));

    for row_idx in min_layout.row..=max_layout.row {
        let placed_row = &galley.rows[row_idx];
        let row = &placed_row.row;

        let left = if row_idx == min_layout.row {
            row.x_offset(min_layout.column)
        } else {
            0.0
        };

        let right = if row_idx == max_layout.row {
            row.x_offset(max_layout.column)
        } else {
            let newline_bonus = if placed_row.ends_with_newline {
                row.height() / 2.0
            } else {
                0.0
            };
            row.size.x + newline_bonus
        };

        let rect = egui::Rect::from_min_max(
            egui::pos2(left, placed_row.pos.y),
            egui::pos2(right, placed_row.pos.y + row.size.y),
        );
        painter.rect_filled(rect.translate(galley_pos.to_vec2()), 0.0, color);
    }
}

Rendering Code

Kimi 2.5

we're gonna do rendering for awareness

read and learn both paint_cursor_end and paint_text_selection from egui's source code

we don't need a lot of the complexity, for cursor bar we just need what's in paint_cursor_end but with our own color + a little half circle up top same color

for the selection, we just want rectangles behind the text, but with our color. there's a lot of logic in paint_text_selection that I don't quite know the reason for. 

make our two functions in @src/render_running.rs , then use them to display the awareness, don't remove the debug label output yet

make sure you call the functions where the three squares are being run now. remove those squares btw

 

there's something wrong in paint_awareness_selection, if the selection is from any line after the first line, the rectangle displayed is still at the top

in paint_awareness_cursor the dot circle

first, it's too high, can you bring it lower, at the top of the bar not on top of it

then, can you make it a half circle instead? pointing to the right

i think drawing transparent on it isn't gonna work...

use painter.with_clip_rect

Demo!

Chapter 10: Make it Prettier

Kimi 2.5

fn generate_peer_color(endpoint_id: &[u8; 32]) -> Color32 {
    let (r, g, b) = (endpoint_id[0], endpoint_id[1], endpoint_id[2]);
    let brightness = (r as u16 + g as u16 + b as u16) / 3;

    if brightness >= 128 {
        return Color32::from_rgb(r, g, b);
    }

    Color32::from_rgb(
        (r as u16 * 2).min(255) as u8,
        (g as u16 * 2).min(255) as u8,
        (b as u16 * 2).min(255) as u8,
    )
}

I need to display this on white, sometimes it gives like yellow and it's not legible, write sth better

it all works... but it's not pretty, make it not look like egui!

make the pages centered, white background, larger text in the text area in session page, buttons should be aligned and full width or centered or...

just make it all pretty

There is no margin on the page, 

also, remove the built-in rectangle around the textedit when you hover over it

in the lobby screen the two create session and join session buttons are too small and also not horizontall aligned

they should be the same height and bigness as the big bottom button, but half the width each (with margin in between)

research online first before you start coding

on the session screen the red button's text isn't really readable

also "active users:" and the badges are not vertically centered

same with "You're peer id" and the button on the right of it

use a fixed height on the container

make the active users badges more compact, less padding

the vertical padding should be near zero

it seems like it's expanding to the container height, stop that

in the session screen, remove the debug label stuff from the bottom

in main, make the initial window a bit longer so that the textarea is fully visible in the session screen later

Final Demo!

  • Collaborative Cursors
    • Already shown
  • Multiplayer Undo/Redo
    • You can't just have one Undo/Redo stack for everyone
    • Loro is great for this
  • Time Travel
    • Similar to Figma/Google Docs "Version History", ability to move back in time and see how document looked like
    • Loro makes this easy as pie

Notes: Loro is cool

Notes: egui is cool

Notes: gpui is cool

Thank you

Made with Slides.com