Parsa
areweguiyet.com
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));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}"));
});
})
}
cargo init
cargo add eframeuse 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));
}
//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),
}
}
//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;
}
}
}
//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);
}
Loading
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))
}),
)
})
}// 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.
// 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_lotArc 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) {
//...
}// 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));
}
// ...
}App to our render functionstask_start_session and task_leave_session both require an App argument// 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),
}
}
render_ui functionmatch 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();
}
}
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.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() }
)
);
}
egui to repaintreplace_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();
}
}
https://iroh.computer
https://docs.iroh.computer/examples/chat
#[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.
// 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(())
}
// 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(),
});
}
}
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.
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.
Every iroh user (endpoint) will have an Endpoint ID, uniquely identifying them.
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;
}
}
}
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(),
});
}
}
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,
})
}Name: Parsa
Age:
Name: Bob
Age:
Name: Parsa
Age:
Name: Bob
Age:
Parsa
Bob
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
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
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
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
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 💯
Written in Rust
Compiles to WASM
First-class TS/JS support
cargo add loroParsa
Bob
Name: Parsa
Age:
Name: Bob
Age:
Name: Parsa
Age:
Name: Bob
Age:
= Loro Document
= Loro Update
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
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)?;
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(())
}
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();
}
}
🤙
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
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
Awareness struct
update_awareness_cache()
AwarenessCache
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
handle_gossip_message()
UI: render_session()
Parsa
Bob
T=0
T=1
Type "Hello! "
Parsa
Bob
T=0
T=1
Type "Hello! "
#[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(())
}
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);
}
}
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
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
gpui is cool