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 eframeegui: 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
&mutread-write access
-
-
Appnow becomes a shareable reference to the data. -
Multiple
Appreferences to the same underlying state can exist at the same time -
AppisClone-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_sessionandtask_leave_sessionboth require anAppargument - 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
- Lock the mutex (make it our turn) and extract the state.
-
matchthe 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
-
eguidoesn't render the UI unless an event (like button click) happens. -
eguidoesn't know our task has changed the state, and doesn't render the UI again. - Solution:
- explicitly request
eguito render (repaint) the UI again, using.request_repaint()
- explicitly request
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
eguito repaint - Wrap the above in a block (so the mutex unlocks)
- This is tedious.
- We write a method
replace_stateto 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 loroSolution: 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
deck
By Parsa Shamaeezadeh
deck
- 20