commit ff7601873acf80631a3e162fe13c3b6874e97cc2
parent 14b295c077173c9acea2fd0fe293ef7732943c57
Author: William Casarin <jb55@jb55.com>
Date: Mon, 23 Feb 2026 11:54:20 -0800
Merge branches 'columns', 'dave' and 'nostrverse'
Diffstat:
33 files changed, 3785 insertions(+), 262 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -4024,7 +4024,7 @@ dependencies = [
[[package]]
name = "nostrdb"
version = "0.10.0"
-source = "git+https://github.com/damus-io/nostrdb-rs?rev=8c1af378d1c296abfccb9a11ce5a3ae48f857be6#8c1af378d1c296abfccb9a11ce5a3ae48f857be6"
+source = "git+https://github.com/damus-io/nostrdb-rs?rev=59a1900#59a19008973a6f5eb0dd2089964f60e095e25477"
dependencies = [
"bindgen 0.69.5",
"cc",
@@ -4299,6 +4299,7 @@ dependencies = [
"glam",
"nostrdb",
"notedeck",
+ "protoverse",
"renderbud",
"tracing",
]
@@ -5251,6 +5252,10 @@ dependencies = [
]
[[package]]
+name = "protoverse"
+version = "0.1.0"
+
+[[package]]
name = "puffin"
version = "0.19.1"
source = "git+https://github.com/jb55/puffin?rev=c6a6242adaf90b6292c0f462d2acd34d96d224d2#c6a6242adaf90b6292c0f462d2acd34d96d224d2"
diff --git a/Cargo.toml b/Cargo.toml
@@ -15,6 +15,7 @@ members = [
"crates/md-stream",
"crates/tokenator",
"crates/enostr",
+ "crates/protoverse",
]
[workspace.dependencies]
@@ -65,7 +66,7 @@ md5 = "0.7.0"
nostr = { version = "0.37.0", default-features = false, features = ["std", "nip44", "nip49"] }
nwc = "0.39.0"
mio = { version = "1.0.3", features = ["os-poll", "net"] }
-nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "8c1af378d1c296abfccb9a11ce5a3ae48f857be6" }
+nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "59a1900" }
#nostrdb = "0.6.1"
notedeck = { path = "crates/notedeck" }
notedeck_chrome = { path = "crates/notedeck_chrome" }
diff --git a/crates/notedeck/src/app.rs b/crates/notedeck/src/app.rs
@@ -200,10 +200,7 @@ impl Notedeck {
.clone()
.unwrap_or(data_path.as_ref().to_str().expect("db path ok").to_string());
let path = DataPath::new(&data_path);
- let dbpath_str = parsed_args
- .dbpath
- .clone()
- .unwrap_or_else(|| path.path(DataPathType::Db).to_str().unwrap().to_string());
+ let dbpath_str = parsed_args.db_path(&path).to_str().unwrap().to_string();
let _ = std::fs::create_dir_all(&dbpath_str);
@@ -249,6 +246,7 @@ impl Notedeck {
}
let mut unknown_ids = UnknownIds::default();
+ try_swap_compacted_db(&dbpath_str);
let mut ndb = Ndb::new(&dbpath_str, &config).expect("ndb");
let txn = Transaction::new(&ndb).expect("txn");
@@ -550,3 +548,51 @@ fn process_message_core(ctx: &mut AppContext<'_>, relay: &str, msg: &RelayMessag
}
}
}
+
+/// If a compacted database exists at `{dbpath}/compact/`, swap it into place
+/// before opening ndb. This replaces the main data.mdb with the compacted one.
+fn try_swap_compacted_db(dbpath: &str) {
+ let dbpath = Path::new(dbpath);
+ let compact_path = dbpath.join("compact");
+ let compact_data = compact_path.join("data.mdb");
+
+ info!(
+ "compact swap: checking for compacted db at '{}'",
+ compact_data.display()
+ );
+
+ if !compact_data.exists() {
+ info!("compact swap: no compacted db found, skipping");
+ return;
+ }
+
+ let compact_size = std::fs::metadata(&compact_data)
+ .map(|m| m.len())
+ .unwrap_or(0);
+ info!("compact swap: found compacted db ({compact_size} bytes)");
+
+ let db_data = dbpath.join("data.mdb");
+ let db_old = dbpath.join("data.mdb.old");
+
+ let old_size = std::fs::metadata(&db_data).map(|m| m.len()).unwrap_or(0);
+ info!(
+ "compact swap: current db at '{}' ({old_size} bytes)",
+ db_data.display()
+ );
+
+ if let Err(e) = std::fs::rename(&db_data, &db_old) {
+ error!("compact swap: failed to rename old db: {e}");
+ return;
+ }
+
+ if let Err(e) = std::fs::rename(&compact_data, &db_data) {
+ error!("compact swap: failed to move compacted db: {e}");
+ // Try to restore the original
+ let _ = std::fs::rename(&db_old, &db_data);
+ return;
+ }
+
+ let _ = std::fs::remove_file(&db_old);
+ let _ = std::fs::remove_dir_all(&compact_path);
+ info!("compact swap: success! {old_size} -> {compact_size} bytes");
+}
diff --git a/crates/notedeck/src/args.rs b/crates/notedeck/src/args.rs
@@ -1,6 +1,7 @@
use std::collections::BTreeSet;
+use std::path::PathBuf;
-use crate::NotedeckOptions;
+use crate::{DataPath, DataPathType, NotedeckOptions};
use enostr::{Keypair, Pubkey, SecretKey};
use tracing::error;
use unic_langid::{LanguageIdentifier, LanguageIdentifierError};
@@ -15,6 +16,19 @@ pub struct Args {
}
impl Args {
+ /// Resolve the effective database path, respecting --dbpath override.
+ pub fn db_path(&self, data_path: &DataPath) -> PathBuf {
+ self.dbpath
+ .as_ref()
+ .map(PathBuf::from)
+ .unwrap_or_else(|| data_path.path(DataPathType::Db))
+ }
+
+ /// Resolve the compact output path inside the db folder.
+ pub fn db_compact_path(&self, data_path: &DataPath) -> PathBuf {
+ self.db_path(data_path).join("compact")
+ }
+
// parse arguments, return set of unrecognized args
pub fn parse(args: &[String]) -> (Self, BTreeSet<String>) {
let mut unrecognized_args = BTreeSet::new();
diff --git a/crates/notedeck/src/compact.rs b/crates/notedeck/src/compact.rs
@@ -0,0 +1,76 @@
+use std::path::Path;
+use tokio::sync::oneshot;
+
+pub struct CompactResult {
+ pub old_size: u64,
+ pub new_size: u64,
+}
+
+#[derive(Default)]
+pub enum CompactStatus {
+ #[default]
+ Idle,
+ Running(oneshot::Receiver<Result<CompactResult, String>>),
+ Done(CompactResult),
+ Error(String),
+}
+
+impl CompactStatus {
+ /// Poll a running compaction job. Returns true if the status changed.
+ pub fn poll(&mut self) -> bool {
+ let receiver = match self {
+ CompactStatus::Running(rx) => rx,
+ _ => return false,
+ };
+
+ match receiver.try_recv() {
+ Ok(Ok(result)) => {
+ *self = CompactStatus::Done(result);
+ true
+ }
+ Ok(Err(e)) => {
+ *self = CompactStatus::Error(e);
+ true
+ }
+ Err(oneshot::error::TryRecvError::Empty) => false,
+ Err(oneshot::error::TryRecvError::Closed) => {
+ *self = CompactStatus::Error("Compaction job was dropped".to_string());
+ true
+ }
+ }
+ }
+}
+
+/// Tracks compaction status and cached database size.
+pub struct CompactState {
+ pub status: CompactStatus,
+ pub cached_db_size: Option<u64>,
+}
+
+impl Default for CompactState {
+ fn default() -> Self {
+ Self {
+ status: CompactStatus::Idle,
+ cached_db_size: None,
+ }
+ }
+}
+
+impl CompactState {
+ /// Get the database size, reading from cache or refreshing from disk.
+ pub fn db_size(&mut self, db_path: &Path) -> u64 {
+ if let Some(size) = self.cached_db_size {
+ return size;
+ }
+ let size = std::fs::metadata(db_path.join("data.mdb"))
+ .map(|m| m.len())
+ .unwrap_or(0);
+ self.cached_db_size = Some(size);
+ size
+ }
+
+ /// Invalidate the cached size so it gets re-read next time.
+ pub fn invalidate_size(&mut self) {
+ self.cached_db_size = None;
+ }
+}
diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs
@@ -4,6 +4,7 @@ pub mod abbrev;
mod account;
mod app;
mod args;
+pub mod compact;
pub mod contacts;
mod context;
pub mod debouncer;
diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs
@@ -604,14 +604,9 @@ fn process_render_nav_action(
.process_relay_action(ui.ctx(), ctx.pool, action);
None
}
- RenderNavAction::SettingsAction(action) => action.process_settings_action(
- app,
- ctx.settings,
- ctx.i18n,
- ctx.img_cache,
- ui.ctx(),
- ctx.accounts,
- ),
+ RenderNavAction::SettingsAction(action) => {
+ action.process_settings_action(app, ctx, ui.ctx())
+ }
RenderNavAction::RepostAction(action) => {
action.process(ctx.ndb, &ctx.accounts.get_selected_account().key, ctx.pool)
}
@@ -726,13 +721,18 @@ fn render_nav_body(
.ui(ui)
.map_output(RenderNavAction::RelayAction),
- Route::Settings => SettingsView::new(
- ctx.settings.get_settings_mut(),
- &mut note_context,
- &mut app.note_options,
- )
- .ui(ui)
- .map_output(RenderNavAction::SettingsAction),
+ Route::Settings => {
+ let db_path = ctx.args.db_path(ctx.path);
+ SettingsView::new(
+ ctx.settings.get_settings_mut(),
+ &mut note_context,
+ &mut app.note_options,
+ &db_path,
+ &mut app.view_state.compact,
+ )
+ .ui(ui)
+ .map_output(RenderNavAction::SettingsAction)
+ }
Route::Reply(id) => {
let txn = if let Ok(txn) = Transaction::new(ctx.ndb) {
diff --git a/crates/notedeck_columns/src/timeline/mod.rs b/crates/notedeck_columns/src/timeline/mod.rs
@@ -189,7 +189,9 @@ impl TimelineTab {
match merge_kind {
// TODO: update egui_virtual_list to support spliced inserts
MergeKind::Spliced => {
- debug!("spliced when inserting {num_refs} new notes, resetting virtual list",);
+ tracing::trace!(
+ "spliced when inserting {num_refs} new notes, resetting virtual list",
+ );
list.reset();
}
MergeKind::FrontInsert => 's: {
diff --git a/crates/notedeck_columns/src/ui/settings.rs b/crates/notedeck_columns/src/ui/settings.rs
@@ -6,9 +6,8 @@ use egui_extras::{Size, StripBuilder};
use enostr::NoteId;
use nostrdb::Transaction;
use notedeck::{
- tr, ui::richtext_small, DragResponse, Images, LanguageIdentifier, Localization, NoteContext,
- NotedeckTextStyle, Settings, SettingsHandler, DEFAULT_MAX_HASHTAGS_PER_NOTE,
- DEFAULT_NOTE_BODY_FONT_SIZE,
+ tr, ui::richtext_small, DragResponse, LanguageIdentifier, NoteContext, NotedeckTextStyle,
+ Settings, DEFAULT_MAX_HASHTAGS_PER_NOTE, DEFAULT_NOTE_BODY_FONT_SIZE,
};
use notedeck_ui::{
app_images::{copy_to_clipboard_dark_image, copy_to_clipboard_image},
@@ -35,17 +34,15 @@ pub enum SettingsAction {
OpenRelays,
OpenCacheFolder,
ClearCacheFolder,
+ CompactDatabase,
}
impl SettingsAction {
- pub fn process_settings_action<'a>(
+ pub fn process_settings_action(
self,
app: &mut Damus,
- settings: &'a mut SettingsHandler,
- i18n: &'a mut Localization,
- img_cache: &mut Images,
- ctx: &egui::Context,
- accounts: &mut notedeck::Accounts,
+ app_ctx: &mut notedeck::AppContext<'_>,
+ egui_ctx: &egui::Context,
) -> Option<RouterAction> {
let mut route_action: Option<RouterAction> = None;
@@ -54,47 +51,80 @@ impl SettingsAction {
route_action = Some(RouterAction::route_to(Route::Relays));
}
Self::SetZoomFactor(zoom_factor) => {
- ctx.set_zoom_factor(zoom_factor);
- settings.set_zoom_factor(zoom_factor);
+ egui_ctx.set_zoom_factor(zoom_factor);
+ app_ctx.settings.set_zoom_factor(zoom_factor);
}
Self::SetTheme(theme) => {
- ctx.set_theme(theme);
- settings.set_theme(theme);
+ egui_ctx.set_theme(theme);
+ app_ctx.settings.set_theme(theme);
}
Self::SetLocale(language) => {
- if i18n.set_locale(language.clone()).is_ok() {
- settings.set_locale(language.to_string());
+ if app_ctx.i18n.set_locale(language.clone()).is_ok() {
+ app_ctx.settings.set_locale(language.to_string());
}
}
Self::SetRepliestNewestFirst(value) => {
app.note_options.set(NoteOptions::RepliesNewestFirst, value);
- settings.set_show_replies_newest_first(value);
+ app_ctx.settings.set_show_replies_newest_first(value);
}
Self::OpenCacheFolder => {
use opener;
- let _ = opener::open(img_cache.base_path.clone());
+ let _ = opener::open(app_ctx.img_cache.base_path.clone());
}
Self::ClearCacheFolder => {
- let _ = img_cache.clear_folder_contents();
+ let _ = app_ctx.img_cache.clear_folder_contents();
}
Self::SetNoteBodyFontSize(size) => {
- let mut style = (*ctx.style()).clone();
+ let mut style = (*egui_ctx.style()).clone();
style.text_styles.insert(
NotedeckTextStyle::NoteBody.text_style(),
FontId::proportional(size),
);
- ctx.set_style(style);
+ egui_ctx.set_style(style);
- settings.set_note_body_font_size(size);
+ app_ctx.settings.set_note_body_font_size(size);
}
Self::SetAnimateNavTransitions(value) => {
- settings.set_animate_nav_transitions(value);
+ app_ctx.settings.set_animate_nav_transitions(value);
}
Self::SetMaxHashtagsPerNote(value) => {
- settings.set_max_hashtags_per_note(value);
- accounts.update_max_hashtags_per_note(value);
+ app_ctx.settings.set_max_hashtags_per_note(value);
+ app_ctx.accounts.update_max_hashtags_per_note(value);
+ }
+ Self::CompactDatabase => {
+ let own_pubkeys: Vec<[u8; 32]> = app_ctx
+ .accounts
+ .cache
+ .accounts()
+ .map(|a| *a.key.pubkey.bytes())
+ .collect();
+
+ let db_path = app_ctx.args.db_path(app_ctx.path);
+ let compact_path = app_ctx.args.db_compact_path(app_ctx.path);
+ let _ = std::fs::create_dir_all(&compact_path);
+
+ let old_size = std::fs::metadata(db_path.join("data.mdb"))
+ .map(|m| m.len())
+ .unwrap_or(0);
+
+ let compact_path_str = compact_path.to_str().unwrap_or("").to_string();
+ let ndb = app_ctx.ndb.clone();
+
+ let receiver = app_ctx.job_pool.schedule_receivable(move || {
+ ndb.compact(&compact_path_str, &own_pubkeys)
+ .map(|()| {
+ let new_size =
+ std::fs::metadata(format!("{compact_path_str}/data.mdb"))
+ .map(|m| m.len())
+ .unwrap_or(0);
+ notedeck::compact::CompactResult { old_size, new_size }
+ })
+ .map_err(|e| format!("{e}"))
+ });
+
+ app.view_state.compact.status = notedeck::compact::CompactStatus::Running(receiver);
}
}
route_action
@@ -105,6 +135,8 @@ pub struct SettingsView<'a> {
settings: &'a mut Settings,
note_context: &'a mut NoteContext<'a>,
note_options: &'a mut NoteOptions,
+ db_path: &'a std::path::Path,
+ compact: &'a mut notedeck::compact::CompactState,
}
fn settings_group<S>(ui: &mut egui::Ui, title: S, contents: impl FnOnce(&mut egui::Ui))
@@ -131,11 +163,15 @@ impl<'a> SettingsView<'a> {
settings: &'a mut Settings,
note_context: &'a mut NoteContext<'a>,
note_options: &'a mut NoteOptions,
+ db_path: &'a std::path::Path,
+ compact: &'a mut notedeck::compact::CompactState,
) -> Self {
Self {
settings,
note_context,
note_options,
+ db_path,
+ compact,
}
}
@@ -440,6 +476,135 @@ impl<'a> SettingsView<'a> {
action
}
+ pub fn database_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> {
+ let id = ui.id();
+ let mut action: Option<SettingsAction> = None;
+
+ // Poll compaction status; invalidate cached size when done
+ if self.compact.status.poll() {
+ self.compact.invalidate_size();
+ }
+
+ let title = tr!(
+ self.note_context.i18n,
+ "Database",
+ "Label for database settings section"
+ );
+ settings_group(ui, title, |ui| {
+ ui.horizontal_wrapped(|ui| {
+ let db_size = self.compact.db_size(self.db_path);
+
+ ui.label(
+ RichText::new(format!(
+ "{} {}",
+ tr!(
+ self.note_context.i18n,
+ "Database size:",
+ "Label for database size in settings"
+ ),
+ format_size(db_size)
+ ))
+ .text_style(NotedeckTextStyle::Small.text_style()),
+ );
+
+ ui.end_row();
+
+ match self.compact.status {
+ notedeck::compact::CompactStatus::Running(_) => {
+ ui.label(
+ richtext_small(tr!(
+ self.note_context.i18n,
+ "Compacting...",
+ "Status label while database compaction is running"
+ )),
+ );
+ }
+ notedeck::compact::CompactStatus::Done(ref result) => {
+ ui.label(richtext_small(format!(
+ "{} {} → {}. {}",
+ tr!(
+ self.note_context.i18n,
+ "Compacted!",
+ "Status label after database compaction completes"
+ ),
+ format_size(result.old_size),
+ format_size(result.new_size),
+ tr!(
+ self.note_context.i18n,
+ "Restart to apply.",
+ "Instruction to restart after compaction"
+ ),
+ )));
+ }
+ notedeck::compact::CompactStatus::Error(ref e) => {
+ ui.label(
+ richtext_small(format!(
+ "{} {e}",
+ tr!(
+ self.note_context.i18n,
+ "Compaction error:",
+ "Status label when database compaction fails"
+ ),
+ ))
+ .color(Color32::LIGHT_RED),
+ );
+ }
+ notedeck::compact::CompactStatus::Idle => {
+ let compact_resp = ui.button(richtext_small(tr!(
+ self.note_context.i18n,
+ "Compact database",
+ "Button to compact the database"
+ )));
+
+ let id_compact = id.with("compact_db");
+ if compact_resp.clicked() {
+ ui.data_mut(|d| d.insert_temp(id_compact, true));
+ }
+
+ if ui.data_mut(|d| *d.get_temp_mut_or_default(id_compact)) {
+ let mut confirm_pressed = false;
+ compact_resp.show_tooltip_ui(|ui| {
+ ui.label(tr!(
+ self.note_context.i18n,
+ "Keeps all profiles and your notes. The smaller database will be used on next restart.",
+ "Confirmation prompt for database compaction"
+ ));
+ let confirm_resp = ui.button(tr!(
+ self.note_context.i18n,
+ "Confirm",
+ "Label for confirm compact database"
+ ));
+ if confirm_resp.clicked() {
+ confirm_pressed = true;
+ }
+
+ if confirm_resp.clicked()
+ || ui
+ .button(tr!(
+ self.note_context.i18n,
+ "Cancel",
+ "Label for cancel compact database"
+ ))
+ .clicked()
+ {
+ ui.data_mut(|d| d.insert_temp(id_compact, false));
+ }
+ });
+
+ if confirm_pressed {
+ action = Some(SettingsAction::CompactDatabase);
+ } else if !confirm_pressed && compact_resp.clicked_elsewhere() {
+ ui.data_mut(|d| d.insert_temp(id_compact, false));
+ }
+ }
+ }
+ }
+ });
+ });
+
+ action
+ }
+
fn other_options_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> {
let mut action = None;
@@ -727,6 +892,12 @@ impl<'a> SettingsView<'a> {
ui.add_space(5.0);
+ if let Some(new_action) = self.database_section(ui) {
+ action = Some(new_action);
+ }
+
+ ui.add_space(5.0);
+
self.keys_section(ui);
ui.add_space(5.0);
diff --git a/crates/notedeck_columns/src/view_state.rs b/crates/notedeck_columns/src/view_state.rs
@@ -1,6 +1,7 @@
use std::collections::HashMap;
use enostr::Pubkey;
+use notedeck::compact::CompactState;
use notedeck::ReportType;
use notedeck_ui::nip51_set::Nip51SetUiCache;
@@ -37,6 +38,9 @@ pub struct ViewState {
/// Report screen selected report type
pub selected_report_type: Option<ReportType>,
+
+ /// Database compaction state
+ pub compact: CompactState,
}
impl ViewState {
diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs
@@ -344,15 +344,11 @@ async fn session_actor(
let (ui_resp_tx, ui_resp_rx) = oneshot::channel();
let cached_plan = if perm_req.tool_name == "ExitPlanMode" {
- perm_req.tool_input.get("plan")
+ perm_req
+ .tool_input
+ .get("plan")
.and_then(|v| v.as_str())
- .map(|plan| {
- let mut parser = md_stream::StreamParser::new();
- parser.push(plan);
- parser.finalize();
- let (elements, source) = parser.into_parts();
- ParsedMarkdown { source, elements }
- })
+ .map(ParsedMarkdown::parse)
} else {
None
};
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -831,6 +831,15 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
if let Some(agentic) = &mut session.agentic {
agentic.is_compacting = false;
agentic.last_compaction = Some(info.clone());
+
+ // Advance compact-and-proceed: compaction done,
+ // proceed message will fire at stream-end.
+ if agentic.compact_and_proceed
+ == crate::session::CompactAndProceedState::WaitingForCompaction
+ {
+ agentic.compact_and_proceed =
+ crate::session::CompactAndProceedState::ReadyToProceed;
+ }
}
session.chat.push(Message::CompactionComplete(info));
}
@@ -876,6 +885,12 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
if session.needs_redispatch_after_stream_end() {
needs_send.insert(session_id);
}
+
+ // After compact & approve: compaction must have
+ // completed (ReadyToProceed) before we send "Proceed".
+ if session.take_compact_and_proceed() {
+ needs_send.insert(session_id);
+ }
}
}
_ => {
@@ -1725,8 +1740,13 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
///
/// For local sessions: only process `role=user` messages arriving from
/// remote clients (phone), collecting them for backend dispatch.
- fn poll_remote_conversation_events(&mut self, ndb: &nostrdb::Ndb) -> Vec<(SessionId, String)> {
+ fn poll_remote_conversation_events(
+ &mut self,
+ ndb: &nostrdb::Ndb,
+ secret_key: Option<&[u8; 32]>,
+ ) -> (Vec<(SessionId, String)>, Vec<session_events::BuiltEvent>) {
let mut remote_user_messages: Vec<(SessionId, String)> = Vec::new();
+ let mut events_to_publish: Vec<session_events::BuiltEvent> = Vec::new();
let session_ids = self.session_manager.session_ids();
for session_id in session_ids {
let Some(session) = self.session_manager.get_mut(session_id) else {
@@ -1853,6 +1873,16 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
.request_note_ids
.insert(perm_id, *note.id());
+ // Parse plan markdown for ExitPlanMode requests
+ let cached_plan = if tool_name == "ExitPlanMode" {
+ tool_input
+ .get("plan")
+ .and_then(|v| v.as_str())
+ .map(crate::messages::ParsedMarkdown::parse)
+ } else {
+ None
+ };
+
session.chat.push(Message::PermissionRequest(
crate::messages::PermissionRequest {
id: perm_id,
@@ -1860,7 +1890,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
tool_input,
response,
answer_summary: None,
- cached_plan: None,
+ cached_plan,
},
));
}
@@ -1892,14 +1922,42 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
let info = crate::messages::CompactionInfo { pre_tokens };
agentic.last_compaction = Some(info.clone());
session.chat.push(Message::CompactionComplete(info));
+
+ // Advance compact-and-proceed: for remote sessions,
+ // there's no stream-end to wait for, so go straight
+ // to ReadyToProceed and consume immediately.
+ if agentic.compact_and_proceed
+ == crate::session::CompactAndProceedState::WaitingForCompaction
+ {
+ agentic.compact_and_proceed =
+ crate::session::CompactAndProceedState::ReadyToProceed;
+ }
}
_ => {
// Skip progress, queue-operation, etc.
}
}
+
+ // Handle proceed after compaction for remote sessions.
+ // Published as a relay event so the desktop backend picks it up.
+ if session.take_compact_and_proceed() {
+ if let Some(sk) = secret_key {
+ if let Some(evt) = ingest_live_event(
+ session,
+ ndb,
+ sk,
+ "Proceed with implementing the plan.",
+ "user",
+ None,
+ None,
+ ) {
+ events_to_publish.push(evt);
+ }
+ }
+ }
}
}
- remote_user_messages
+ (remote_user_messages, events_to_publish)
}
/// Delete a session and clean up backend resources
@@ -2220,7 +2278,6 @@ impl notedeck::App for Dave {
// Local: subscribe in ndb for kind-31988 session state events
let state_filter = nostrdb::Filter::new()
.kinds([session_events::AI_SESSION_STATE_KIND as u64])
- .tags(["ai-session-state"], 't')
.build();
match ctx.ndb.subscribe(&[state_filter]) {
Ok(sub) => {
@@ -2245,7 +2302,10 @@ impl notedeck::App for Dave {
// Only dispatch if the session isn't already streaming a response —
// the message is already in chat, so it will be included when the
// current stream finishes and we re-dispatch.
- let remote_user_msgs = self.poll_remote_conversation_events(ctx.ndb);
+ let sk_bytes = secret_key_bytes(ctx.accounts.get_selected_account().keypair());
+ let (remote_user_msgs, conv_events) =
+ self.poll_remote_conversation_events(ctx.ndb, sk_bytes.as_ref());
+ self.pending_relay_events.extend(conv_events);
for (sid, _msg) in remote_user_msgs {
let should_dispatch = self
.session_manager
diff --git a/crates/notedeck_dave/src/messages.rs b/crates/notedeck_dave/src/messages.rs
@@ -8,6 +8,17 @@ pub struct ParsedMarkdown {
pub source: String,
pub elements: Vec<MdElement>,
}
+
+impl ParsedMarkdown {
+ /// Parse a markdown string into elements.
+ pub fn parse(text: &str) -> Self {
+ let mut parser = StreamParser::new();
+ parser.push(text);
+ parser.finalize();
+ let (elements, source) = parser.into_parts();
+ Self { source, elements }
+ }
+}
use nostrdb::{Ndb, Transaction};
use serde::{Deserialize, Serialize};
use tokio::sync::oneshot;
diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs
@@ -37,6 +37,23 @@ pub struct SessionDetails {
pub home_dir: String,
}
+/// Tracks the "Compact & Approve" lifecycle.
+///
+/// Button click → `WaitingForCompaction` (intent recorded).
+/// CompactionComplete → `ReadyToProceed` (compaction finished, safe to send).
+/// Stream-end (local) or compaction_complete event (remote) → consume and fire.
+#[derive(Default, Clone, Copy, PartialEq)]
+pub enum CompactAndProceedState {
+ /// No compact-and-proceed in progress.
+ #[default]
+ Idle,
+ /// User clicked "Compact & Approve"; waiting for compaction to finish.
+ WaitingForCompaction,
+ /// Compaction finished; send "Proceed" on the next safe opportunity
+ /// (stream-end for local, immediately for remote).
+ ReadyToProceed,
+}
+
/// State for permission response with message
#[derive(Default, Clone, Copy, PartialEq)]
pub enum PermissionMessageState {
@@ -182,6 +199,8 @@ pub struct AgenticSessionData {
/// Prevents duplicate messages when events are loaded during restore
/// and then appear again via the subscription.
pub seen_note_ids: HashSet<[u8; 32]>,
+ /// Tracks the "Compact & Approve" lifecycle.
+ pub compact_and_proceed: CompactAndProceedState,
}
impl AgenticSessionData {
@@ -214,6 +233,7 @@ impl AgenticSessionData {
remote_status_ts: 0,
live_conversation_sub: None,
seen_note_ids: HashSet::new(),
+ compact_and_proceed: CompactAndProceedState::Idle,
}
}
@@ -790,6 +810,28 @@ impl ChatSession {
pub fn needs_redispatch_after_stream_end(&self) -> bool {
!self.is_streaming() && self.has_pending_user_message()
}
+
+ /// If "Compact & Approve" has reached ReadyToProceed, consume the state,
+ /// push a "Proceed" user message, and return true.
+ ///
+ /// Called from:
+ /// - Local sessions: at stream-end in process_events()
+ /// - Remote sessions: on compaction_complete in poll_remote_conversation_events()
+ pub fn take_compact_and_proceed(&mut self) -> bool {
+ let dominated = self
+ .agentic
+ .as_ref()
+ .is_none_or(|a| a.compact_and_proceed != CompactAndProceedState::ReadyToProceed);
+
+ if dominated {
+ return false;
+ }
+
+ self.agentic.as_mut().unwrap().compact_and_proceed = CompactAndProceedState::Idle;
+ self.chat
+ .push(Message::User("Proceed with implementing the plan.".into()));
+ true
+ }
}
#[cfg(test)]
diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs
@@ -1119,6 +1119,7 @@ mod tests {
"/tmp/project",
"working",
"my-laptop",
+ "/home/testuser",
&sk,
)
.unwrap();
diff --git a/crates/notedeck_dave/src/session_loader.rs b/crates/notedeck_dave/src/session_loader.rs
@@ -195,13 +195,23 @@ pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) ->
None
};
+ // Parse plan markdown for ExitPlanMode requests
+ let cached_plan = if tool_name == "ExitPlanMode" {
+ tool_input
+ .get("plan")
+ .and_then(|v| v.as_str())
+ .map(crate::messages::ParsedMarkdown::parse)
+ } else {
+ None
+ };
+
Some(Message::PermissionRequest(PermissionRequest {
id: perm_id,
tool_name,
tool_input,
response,
answer_summary: None,
- cached_plan: None,
+ cached_plan,
}))
} else {
None
@@ -244,10 +254,7 @@ pub struct SessionState {
pub fn load_session_states(ndb: &Ndb, txn: &Transaction) -> Vec<SessionState> {
use crate::session_events::AI_SESSION_STATE_KIND;
- let filter = Filter::new()
- .kinds([AI_SESSION_STATE_KIND as u64])
- .tags(["ai-session-state"], 't')
- .build();
+ let filter = Filter::new().kinds([AI_SESSION_STATE_KIND as u64]).build();
let is_valid = |note: &nostrdb::Note| {
// Skip deleted sessions
diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs
@@ -136,6 +136,10 @@ pub enum DaveAction {
request_id: Uuid,
approved: bool,
},
+ /// User approved plan and wants to compact first
+ CompactAndApprove {
+ request_id: Uuid,
+ },
/// Toggle plan mode (clicked PLAN badge)
TogglePlanMode,
/// Toggle auto-steal focus mode (clicked AUTO badge)
@@ -808,6 +812,21 @@ impl<'a> DaveUi<'a> {
}
}
+ // Compact & Approve button (blue, no keybind)
+ let compact_response = super::badge::ActionButton::new(
+ "Compact & Approve",
+ egui::Color32::from_rgb(59, 130, 246),
+ button_text_color,
+ )
+ .show(ui)
+ .on_hover_text("Compact context then start implementing");
+
+ if compact_response.clicked() {
+ action = Some(DaveAction::CompactAndApprove {
+ request_id: request.id,
+ });
+ }
+
// Reject button (red)
let reject_response = super::badge::ActionButton::new(
"Reject",
diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs
@@ -743,5 +743,25 @@ pub fn handle_ui_action(
UiActionResult::PublishPermissionResponse,
)
}
+ DaveAction::CompactAndApprove { request_id } => {
+ update::exit_plan_mode(session_manager, backend, ctx);
+ let result = update::handle_permission_response(
+ session_manager,
+ request_id,
+ PermissionResponse::Allow {
+ message: Some("/compact".into()),
+ },
+ );
+ if let Some(session) = session_manager.get_active_mut() {
+ if let Some(agentic) = &mut session.agentic {
+ agentic.compact_and_proceed =
+ crate::session::CompactAndProceedState::WaitingForCompaction;
+ }
+ }
+ result.map_or(
+ UiActionResult::Handled,
+ UiActionResult::PublishPermissionResponse,
+ )
+ }
}
}
diff --git a/crates/notedeck_nostrverse/Cargo.toml b/crates/notedeck_nostrverse/Cargo.toml
@@ -10,5 +10,6 @@ egui-wgpu = { workspace = true }
enostr = { workspace = true }
glam = { workspace = true }
nostrdb = { workspace = true }
+protoverse = { path = "../protoverse" }
renderbud = { workspace = true }
tracing = { workspace = true }
diff --git a/crates/notedeck_nostrverse/src/convert.rs b/crates/notedeck_nostrverse/src/convert.rs
@@ -0,0 +1,404 @@
+//! Convert protoverse Space AST to renderer room state.
+
+use crate::room_state::{ObjectLocation, Room, RoomObject, RoomObjectType, RoomShape};
+use glam::Vec3;
+use protoverse::{Attribute, Cell, CellId, CellType, Location, ObjectType, Shape, Space};
+
+/// Convert a parsed protoverse Space into a Room and its objects.
+pub fn convert_space(space: &Space) -> (Room, Vec<RoomObject>) {
+ let room = extract_room(space, space.root);
+ let mut objects = Vec::new();
+ collect_objects(space, space.root, &mut objects);
+ (room, objects)
+}
+
+fn extract_room(space: &Space, id: CellId) -> Room {
+ let name = space.name(id).unwrap_or("Untitled Room").to_string();
+
+ let shape = match space.shape(id) {
+ Some(Shape::Rectangle) | Some(Shape::Square) => RoomShape::Rectangle,
+ Some(Shape::Circle) => RoomShape::Circle,
+ None => RoomShape::Rectangle,
+ };
+
+ let width = space.width(id).unwrap_or(20.0) as f32;
+ let height = space.height(id).unwrap_or(15.0) as f32;
+ let depth = space.depth(id).unwrap_or(10.0) as f32;
+
+ Room {
+ name,
+ shape,
+ width,
+ height,
+ depth,
+ }
+}
+
+fn location_from_protoverse(loc: &Location) -> ObjectLocation {
+ match loc {
+ Location::Center => ObjectLocation::Center,
+ Location::Floor => ObjectLocation::Floor,
+ Location::Ceiling => ObjectLocation::Ceiling,
+ Location::TopOf(id) => ObjectLocation::TopOf(id.clone()),
+ Location::Near(id) => ObjectLocation::Near(id.clone()),
+ Location::Custom(s) => ObjectLocation::Custom(s.clone()),
+ }
+}
+
+fn location_to_protoverse(loc: &ObjectLocation) -> Location {
+ match loc {
+ ObjectLocation::Center => Location::Center,
+ ObjectLocation::Floor => Location::Floor,
+ ObjectLocation::Ceiling => Location::Ceiling,
+ ObjectLocation::TopOf(id) => Location::TopOf(id.clone()),
+ ObjectLocation::Near(id) => Location::Near(id.clone()),
+ ObjectLocation::Custom(s) => Location::Custom(s.clone()),
+ }
+}
+
+fn object_type_from_cell(obj_type: &ObjectType) -> RoomObjectType {
+ match obj_type {
+ ObjectType::Table => RoomObjectType::Table,
+ ObjectType::Chair => RoomObjectType::Chair,
+ ObjectType::Door => RoomObjectType::Door,
+ ObjectType::Light => RoomObjectType::Light,
+ ObjectType::Custom(s) if s == "prop" => RoomObjectType::Prop,
+ ObjectType::Custom(s) => RoomObjectType::Custom(s.clone()),
+ }
+}
+
+fn collect_objects(space: &Space, id: CellId, objects: &mut Vec<RoomObject>) {
+ let cell = space.cell(id);
+
+ if let CellType::Object(ref obj_type) = cell.cell_type {
+ let obj_id = space.id_str(id).unwrap_or_else(|| "").to_string();
+
+ // Generate a fallback id if none specified
+ let obj_id = if obj_id.is_empty() {
+ format!("obj-{}", id.0)
+ } else {
+ obj_id
+ };
+
+ let name = space
+ .name(id)
+ .map(|s| s.to_string())
+ .unwrap_or_else(|| cell.cell_type.to_string());
+
+ let position = space
+ .position(id)
+ .map(|(x, y, z)| Vec3::new(x as f32, y as f32, z as f32))
+ .unwrap_or(Vec3::ZERO);
+
+ let model_url = space.model_url(id).map(|s| s.to_string());
+ let location = space.location(id).map(location_from_protoverse);
+
+ let mut obj = RoomObject::new(obj_id, name, position)
+ .with_object_type(object_type_from_cell(obj_type));
+ if let Some(url) = model_url {
+ obj = obj.with_model_url(url);
+ }
+ if let Some(loc) = location {
+ obj = obj.with_location(loc);
+ }
+ objects.push(obj);
+ }
+
+ // Recurse into children
+ for &child_id in space.children(id) {
+ collect_objects(space, child_id, objects);
+ }
+}
+
+/// Build a protoverse Space from Room and objects (reverse of convert_space).
+///
+/// Produces: (room (name ...) (shape ...) (width ...) (height ...) (depth ...)
+/// (group <objects...>))
+pub fn build_space(room: &Room, objects: &[RoomObject]) -> Space {
+ let mut cells = Vec::new();
+ let mut attributes = Vec::new();
+ let mut child_ids = Vec::new();
+
+ // Room attributes
+ let room_attr_start = attributes.len() as u32;
+ attributes.push(Attribute::Name(room.name.clone()));
+ attributes.push(Attribute::Shape(match room.shape {
+ RoomShape::Rectangle => Shape::Rectangle,
+ RoomShape::Circle => Shape::Circle,
+ RoomShape::Custom => Shape::Rectangle,
+ }));
+ attributes.push(Attribute::Width(room.width as f64));
+ attributes.push(Attribute::Height(room.height as f64));
+ attributes.push(Attribute::Depth(room.depth as f64));
+ let room_attr_count = (attributes.len() as u32 - room_attr_start) as u16;
+
+ // Room cell (index 0), child = group at index 1
+ let room_child_start = child_ids.len() as u32;
+ child_ids.push(CellId(1));
+ cells.push(Cell {
+ cell_type: CellType::Room,
+ first_attr: room_attr_start,
+ attr_count: room_attr_count,
+ first_child: room_child_start,
+ child_count: 1,
+ parent: None,
+ });
+
+ // Group cell (index 1), children = objects at indices 2..
+ let group_child_start = child_ids.len() as u32;
+ for i in 0..objects.len() {
+ child_ids.push(CellId(2 + i as u32));
+ }
+ cells.push(Cell {
+ cell_type: CellType::Group,
+ first_attr: attributes.len() as u32,
+ attr_count: 0,
+ first_child: group_child_start,
+ child_count: objects.len() as u16,
+ parent: Some(CellId(0)),
+ });
+
+ // Object cells (indices 2..)
+ for obj in objects {
+ let obj_attr_start = attributes.len() as u32;
+ attributes.push(Attribute::Id(obj.id.clone()));
+ attributes.push(Attribute::Name(obj.name.clone()));
+ if let Some(url) = &obj.model_url {
+ attributes.push(Attribute::ModelUrl(url.clone()));
+ }
+ if let Some(loc) = &obj.location {
+ attributes.push(Attribute::Location(location_to_protoverse(loc)));
+ }
+ let pos = obj.position;
+ attributes.push(Attribute::Position(
+ pos.x as f64,
+ pos.y as f64,
+ pos.z as f64,
+ ));
+ let obj_attr_count = (attributes.len() as u32 - obj_attr_start) as u16;
+
+ let obj_type = CellType::Object(match &obj.object_type {
+ RoomObjectType::Table => ObjectType::Table,
+ RoomObjectType::Chair => ObjectType::Chair,
+ RoomObjectType::Door => ObjectType::Door,
+ RoomObjectType::Light => ObjectType::Light,
+ RoomObjectType::Prop => ObjectType::Custom("prop".to_string()),
+ RoomObjectType::Custom(s) => ObjectType::Custom(s.clone()),
+ });
+
+ cells.push(Cell {
+ cell_type: obj_type,
+ first_attr: obj_attr_start,
+ attr_count: obj_attr_count,
+ first_child: child_ids.len() as u32,
+ child_count: 0,
+ parent: Some(CellId(1)),
+ });
+ }
+
+ Space {
+ cells,
+ attributes,
+ child_ids,
+ root: CellId(0),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use protoverse::parse;
+
+ #[test]
+ fn test_convert_simple_room() {
+ let space = parse(
+ r#"(room (name "Test Room") (shape rectangle) (width 10) (height 5) (depth 8)
+ (group
+ (table (id desk) (name "My Desk") (position 1 0 2))
+ (chair (id chair1) (name "Office Chair"))))"#,
+ )
+ .unwrap();
+
+ let (room, objects) = convert_space(&space);
+
+ assert_eq!(room.name, "Test Room");
+ assert_eq!(room.shape, RoomShape::Rectangle);
+ assert_eq!(room.width, 10.0);
+ assert_eq!(room.height, 5.0);
+ assert_eq!(room.depth, 8.0);
+
+ assert_eq!(objects.len(), 2);
+
+ assert_eq!(objects[0].id, "desk");
+ assert_eq!(objects[0].name, "My Desk");
+ assert_eq!(objects[0].position, Vec3::new(1.0, 0.0, 2.0));
+ assert!(matches!(objects[0].object_type, RoomObjectType::Table));
+
+ assert_eq!(objects[1].id, "chair1");
+ assert_eq!(objects[1].name, "Office Chair");
+ assert_eq!(objects[1].position, Vec3::ZERO);
+ assert!(matches!(objects[1].object_type, RoomObjectType::Chair));
+ }
+
+ #[test]
+ fn test_convert_with_model_url() {
+ let space = parse(
+ r#"(room (name "Gallery")
+ (group
+ (table (id t1) (name "Display Table")
+ (model-url "/models/table.glb")
+ (position 0 0 0))))"#,
+ )
+ .unwrap();
+
+ let (_, objects) = convert_space(&space);
+ assert_eq!(objects.len(), 1);
+ assert_eq!(objects[0].model_url.as_deref(), Some("/models/table.glb"));
+ }
+
+ #[test]
+ fn test_convert_custom_object() {
+ let space = parse(
+ r#"(room (name "Test")
+ (group
+ (prop (id p1) (name "Water Bottle"))))"#,
+ )
+ .unwrap();
+
+ let (_, objects) = convert_space(&space);
+ assert_eq!(objects.len(), 1);
+ assert_eq!(objects[0].id, "p1");
+ assert_eq!(objects[0].name, "Water Bottle");
+ }
+
+ #[test]
+ fn test_build_space_roundtrip() {
+ let room = Room {
+ name: "My Room".to_string(),
+ shape: RoomShape::Rectangle,
+ width: 15.0,
+ height: 10.0,
+ depth: 12.0,
+ };
+ let objects = vec![
+ RoomObject::new(
+ "desk".to_string(),
+ "Office Desk".to_string(),
+ Vec3::new(2.0, 0.0, 3.0),
+ )
+ .with_object_type(RoomObjectType::Table)
+ .with_model_url("/models/desk.glb".to_string()),
+ RoomObject::new("lamp".to_string(), "Floor Lamp".to_string(), Vec3::ZERO)
+ .with_object_type(RoomObjectType::Light),
+ ];
+
+ let space = build_space(&room, &objects);
+
+ // Serialize and re-parse
+ let serialized = protoverse::serialize(&space);
+ let reparsed = parse(&serialized).unwrap();
+
+ // Convert back
+ let (room2, objects2) = convert_space(&reparsed);
+
+ assert_eq!(room2.name, "My Room");
+ assert_eq!(room2.width, 15.0);
+ assert_eq!(room2.height, 10.0);
+ assert_eq!(room2.depth, 12.0);
+
+ assert_eq!(objects2.len(), 2);
+ assert_eq!(objects2[0].id, "desk");
+ assert_eq!(objects2[0].name, "Office Desk");
+ assert_eq!(objects2[0].model_url.as_deref(), Some("/models/desk.glb"));
+ assert_eq!(objects2[0].position, Vec3::new(2.0, 0.0, 3.0));
+ assert!(matches!(objects2[0].object_type, RoomObjectType::Table));
+
+ assert_eq!(objects2[1].id, "lamp");
+ assert_eq!(objects2[1].name, "Floor Lamp");
+ assert!(matches!(objects2[1].object_type, RoomObjectType::Light));
+ }
+
+ #[test]
+ fn test_convert_defaults() {
+ let space = parse("(room)").unwrap();
+ let (room, objects) = convert_space(&space);
+
+ assert_eq!(room.name, "Untitled Room");
+ assert_eq!(room.width, 20.0);
+ assert_eq!(room.height, 15.0);
+ assert_eq!(room.depth, 10.0);
+ assert!(objects.is_empty());
+ }
+
+ #[test]
+ fn test_convert_location_top_of() {
+ let space = parse(
+ r#"(room (group
+ (table (id obj1) (name "Table") (position 0 0 0))
+ (prop (id obj2) (name "Bottle") (location top-of obj1))))"#,
+ )
+ .unwrap();
+
+ let (_, objects) = convert_space(&space);
+ assert_eq!(objects.len(), 2);
+ assert_eq!(objects[0].location, None);
+ assert_eq!(
+ objects[1].location,
+ Some(ObjectLocation::TopOf("obj1".to_string()))
+ );
+ }
+
+ #[test]
+ fn test_build_space_always_emits_position() {
+ let room = Room {
+ name: "Test".to_string(),
+ shape: RoomShape::Rectangle,
+ width: 10.0,
+ height: 10.0,
+ depth: 10.0,
+ };
+ let objects = vec![RoomObject::new(
+ "a".to_string(),
+ "Thing".to_string(),
+ Vec3::ZERO,
+ )];
+
+ let space = build_space(&room, &objects);
+ let serialized = protoverse::serialize(&space);
+
+ // Position should appear even for Vec3::ZERO
+ assert!(serialized.contains("(position 0 0 0)"));
+ }
+
+ #[test]
+ fn test_build_space_location_roundtrip() {
+ let room = Room {
+ name: "Test".to_string(),
+ shape: RoomShape::Rectangle,
+ width: 10.0,
+ height: 10.0,
+ depth: 10.0,
+ };
+ let objects = vec![
+ RoomObject::new("obj1".to_string(), "Table".to_string(), Vec3::ZERO)
+ .with_object_type(RoomObjectType::Table),
+ RoomObject::new(
+ "obj2".to_string(),
+ "Bottle".to_string(),
+ Vec3::new(0.0, 1.5, 0.0),
+ )
+ .with_location(ObjectLocation::TopOf("obj1".to_string())),
+ ];
+
+ let space = build_space(&room, &objects);
+ let serialized = protoverse::serialize(&space);
+ let reparsed = parse(&serialized).unwrap();
+ let (_, objects2) = convert_space(&reparsed);
+
+ assert_eq!(
+ objects2[1].location,
+ Some(ObjectLocation::TopOf("obj1".to_string()))
+ );
+ assert_eq!(objects2[1].position, Vec3::new(0.0, 1.5, 0.0));
+ }
+}
diff --git a/crates/notedeck_nostrverse/src/lib.rs b/crates/notedeck_nostrverse/src/lib.rs
@@ -6,13 +6,18 @@
//! Rooms are rendered as 3D scenes using renderbud's PBR pipeline,
//! embedded in egui via wgpu paint callbacks.
+mod convert;
+mod nostr_events;
+mod presence;
mod room_state;
mod room_view;
+mod subscriptions;
pub use room_state::{
- NostrverseAction, NostrverseState, Presence, Room, RoomObject, RoomRef, RoomShape, RoomUser,
+ NostrverseAction, NostrverseState, Room, RoomObject, RoomObjectType, RoomRef, RoomShape,
+ RoomUser,
};
-pub use room_view::{NostrverseResponse, render_inspection_panel, show_room_view};
+pub use room_view::{NostrverseResponse, render_editing_panel, show_room_view};
use enostr::Pubkey;
use glam::Vec3;
@@ -21,6 +26,31 @@ use renderbud::Transform;
use egui_wgpu::wgpu;
+/// Demo pubkey (jb55) used for testing
+const DEMO_PUBKEY_HEX: &str = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245";
+const FALLBACK_PUBKEY_HEX: &str =
+ "0000000000000000000000000000000000000000000000000000000000000001";
+
+fn demo_pubkey() -> Pubkey {
+ Pubkey::from_hex(DEMO_PUBKEY_HEX)
+ .unwrap_or_else(|_| Pubkey::from_hex(FALLBACK_PUBKEY_HEX).unwrap())
+}
+
+/// Avatar scale: water bottle model is ~0.26m, scaled to human height (~1.8m)
+const AVATAR_SCALE: f32 = 7.0;
+/// How fast the avatar yaw lerps toward the target (higher = faster)
+const AVATAR_YAW_LERP_SPEED: f32 = 10.0;
+
+/// Demo room in protoverse .space format
+const DEMO_SPACE: &str = r#"(room (name "Demo Room") (shape rectangle) (width 20) (height 15) (depth 10)
+ (group
+ (table (id obj1) (name "Ironwood Table")
+ (model-url "/home/jb55/var/models/ironwood/ironwood.glb")
+ (position 0 0 0))
+ (prop (id obj2) (name "Water Bottle")
+ (model-url "/home/jb55/var/models/WaterBottle.glb")
+ (location top-of obj1))))"#;
+
/// Event kinds for nostrverse
pub mod kinds {
/// Room event kind (addressable)
@@ -41,8 +71,22 @@ pub struct NostrverseApp {
device: Option<wgpu::Device>,
/// GPU queue for model loading (Arc-wrapped internally by wgpu)
queue: Option<wgpu::Queue>,
- /// Whether the app has been initialized with demo data
+ /// Whether the app has been initialized
initialized: bool,
+ /// Cached avatar model AABB for ground placement
+ avatar_bounds: Option<renderbud::Aabb>,
+ /// Local nostrdb subscription for room events
+ room_sub: Option<subscriptions::RoomSubscription>,
+ /// Presence publisher (throttled heartbeats)
+ presence_pub: presence::PresencePublisher,
+ /// Presence expiry (throttled stale-user cleanup)
+ presence_expiry: presence::PresenceExpiry,
+ /// Local nostrdb subscription for presence events
+ presence_sub: Option<subscriptions::PresenceSubscription>,
+ /// Cached room naddr string (avoids format! per frame)
+ room_naddr: String,
+ /// Monotonic time tracker (seconds since app start)
+ start_time: std::time::Instant,
}
impl NostrverseApp {
@@ -53,27 +97,26 @@ impl NostrverseApp {
let device = render_state.map(|rs| rs.device.clone());
let queue = render_state.map(|rs| rs.queue.clone());
+ let room_naddr = room_ref.to_naddr();
Self {
state: NostrverseState::new(room_ref),
renderer,
device,
queue,
initialized: false,
+ avatar_bounds: None,
+ room_sub: None,
+ presence_pub: presence::PresencePublisher::new(),
+ presence_expiry: presence::PresenceExpiry::new(),
+ presence_sub: None,
+ room_naddr,
+ start_time: std::time::Instant::now(),
}
}
/// Create with a demo room
pub fn demo(render_state: Option<&egui_wgpu::RenderState>) -> Self {
- let demo_pubkey =
- Pubkey::from_hex("32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")
- .unwrap_or_else(|_| {
- Pubkey::from_hex(
- "0000000000000000000000000000000000000000000000000000000000000001",
- )
- .unwrap()
- });
-
- let room_ref = RoomRef::new("demo-room".to_string(), demo_pubkey);
+ let room_ref = RoomRef::new("demo-room".to_string(), demo_pubkey());
Self::new(room_ref, render_state)
}
@@ -92,114 +135,62 @@ impl NostrverseApp {
}
}
- /// Initialize with demo data (for testing)
- fn init_demo_data(&mut self) {
+ /// Initialize: ingest demo room into local nostrdb and subscribe.
+ fn initialize(&mut self, ctx: &mut AppContext<'_>) {
if self.initialized {
return;
}
- // Set up demo room
- self.state.room = Some(Room {
- name: "Demo Room".to_string(),
- shape: RoomShape::Rectangle,
- width: 20.0,
- height: 15.0,
- depth: 10.0,
- });
-
- // Load test models from disk
- let bottle = self.load_model("/home/jb55/var/models/WaterBottle.glb");
- let ironwood = self.load_model("/home/jb55/var/models/ironwood/ironwood.glb");
-
- // Query AABBs for placement
- let renderer = self.renderer.as_ref();
- let model_bounds = |m: Option<renderbud::Model>| -> Option<renderbud::Aabb> {
- let r = renderer?.renderer.lock().unwrap();
- r.model_bounds(m?)
+ // Parse the demo room and ingest it as a local nostr event
+ let space = match protoverse::parse(DEMO_SPACE) {
+ Ok(s) => s,
+ Err(e) => {
+ tracing::error!("Failed to parse demo space: {}", e);
+ return;
+ }
};
- let table_bounds = model_bounds(ironwood);
- let bottle_bounds = model_bounds(bottle);
-
- // Table top Y (in model space, 1 unit = 1 meter)
- let table_top_y = table_bounds.map(|b| b.max.y).unwrap_or(0.86);
- // Bottle half-height (real-world scale, ~0.26m tall)
- let bottle_half_h = bottle_bounds
- .map(|b| (b.max.y - b.min.y) * 0.5)
- .unwrap_or(0.0);
-
- // Ironwood (table) at origin
- let mut obj1 = RoomObject::new(
- "obj1".to_string(),
- "Ironwood Table".to_string(),
- Vec3::new(0.0, 0.0, 0.0),
- )
- .with_scale(Vec3::splat(1.0));
- obj1.model_handle = ironwood;
-
- // Water bottle on top of the table: table_top + half bottle height
- let mut obj2 = RoomObject::new(
- "obj2".to_string(),
- "Water Bottle".to_string(),
- Vec3::new(0.0, table_top_y + bottle_half_h, 0.0),
- )
- .with_scale(Vec3::splat(1.0));
- obj2.model_handle = bottle;
-
- self.state.objects = vec![obj1, obj2];
-
- // Add demo users
- let user1_pubkey =
- Pubkey::from_hex("32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")
- .unwrap_or_else(|_| {
- Pubkey::from_hex(
- "0000000000000000000000000000000000000000000000000000000000000001",
- )
- .unwrap()
- });
+ // Ingest as a local-only room event if we have a keypair
+ if let Some(kp) = ctx.accounts.selected_filled() {
+ let builder = nostr_events::build_room_event(&space, &self.state.room_ref.id);
+ nostr_events::ingest_event(builder, ctx.ndb, kp);
+ }
- let user2_pubkey =
- Pubkey::from_hex("fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52")
- .unwrap_or_else(|_| {
- Pubkey::from_hex(
- "0000000000000000000000000000000000000000000000000000000000000002",
- )
- .unwrap()
- });
+ // Subscribe to room and presence events in local nostrdb
+ self.room_sub = Some(subscriptions::RoomSubscription::new(ctx.ndb));
+ self.presence_sub = Some(subscriptions::PresenceSubscription::new(ctx.ndb));
- let agent_pubkey =
- Pubkey::from_hex("ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49")
- .unwrap_or_else(|_| {
- Pubkey::from_hex(
- "0000000000000000000000000000000000000000000000000000000000000003",
- )
- .unwrap()
- });
+ // Query for any existing room events (including the one we just ingested)
+ let txn = nostrdb::Transaction::new(ctx.ndb).expect("txn");
+ self.load_room_from_ndb(ctx.ndb, &txn);
+ // Add self user
+ let self_pubkey = *ctx.accounts.selected_account_pubkey();
self.state.users = vec![
- RoomUser::new(user1_pubkey, "jb55".to_string(), Vec3::new(-2.0, 0.0, -2.0))
+ RoomUser::new(self_pubkey, "jb55".to_string(), Vec3::new(-2.0, 0.0, -2.0))
.with_self(true),
- RoomUser::new(
- user2_pubkey,
- "fiatjaf".to_string(),
- Vec3::new(3.0, 0.0, 1.0),
- ),
- RoomUser::new(
- agent_pubkey,
- "Claude".to_string(),
- Vec3::new(-5.0, 0.0, 4.0),
- )
- .with_agent(true),
];
- // Assign the bottle model as avatar placeholder for all users
- if let Some(model) = bottle {
+ // Assign avatar model (use first model with id "obj2" as placeholder)
+ let avatar_model = self
+ .state
+ .objects
+ .iter()
+ .find(|o| o.id == "obj2")
+ .and_then(|o| o.model_handle);
+ let avatar_bounds = avatar_model.and_then(|m| {
+ let renderer = self.renderer.as_ref()?;
+ let r = renderer.renderer.lock().unwrap();
+ r.model_bounds(m)
+ });
+ if let Some(model) = avatar_model {
for user in &mut self.state.users {
user.model_handle = Some(model);
}
}
+ self.avatar_bounds = avatar_bounds;
- // Switch to third-person camera mode centered on the self-user
+ // Switch to third-person camera mode
if let Some(renderer) = &self.renderer {
let self_pos = self
.state
@@ -215,6 +206,224 @@ impl NostrverseApp {
self.initialized = true;
}
+ /// Apply a parsed Space to the room state: convert, load models, update state.
+ fn apply_space(&mut self, space: &protoverse::Space) {
+ let (room, mut objects) = convert::convert_space(space);
+ self.state.room = Some(room);
+ self.load_object_models(&mut objects);
+ self.state.objects = objects;
+ self.state.dirty = false;
+ }
+
+ /// Load room state from a nostrdb query result.
+ fn load_room_from_ndb(&mut self, ndb: &nostrdb::Ndb, txn: &nostrdb::Transaction) {
+ let notes = subscriptions::RoomSubscription::query_existing(ndb, txn);
+
+ for note in ¬es {
+ let Some(room_id) = nostr_events::get_room_id(note) else {
+ continue;
+ };
+ if room_id != self.state.room_ref.id {
+ continue;
+ }
+
+ let Some(space) = nostr_events::parse_room_event(note) else {
+ tracing::warn!("Failed to parse room event content");
+ continue;
+ };
+
+ self.apply_space(&space);
+ tracing::info!("Loaded room '{}' from nostrdb", room_id);
+ return;
+ }
+ }
+
+ /// Save current room state: build Space, serialize, ingest as new nostr event.
+ fn save_room(&self, ctx: &mut AppContext<'_>) {
+ let Some(room) = &self.state.room else {
+ tracing::warn!("save_room: no room to save");
+ return;
+ };
+ let Some(kp) = ctx.accounts.selected_filled() else {
+ tracing::warn!("save_room: no keypair available");
+ return;
+ };
+
+ let space = convert::build_space(room, &self.state.objects);
+ let builder = nostr_events::build_room_event(&space, &self.state.room_ref.id);
+ nostr_events::ingest_event(builder, ctx.ndb, kp);
+ tracing::info!("Saved room '{}'", self.state.room_ref.id);
+ }
+
+ /// Load 3D models for objects, then resolve any semantic locations
+ /// (e.g. "top-of obj1") to concrete positions using AABB bounds.
+ fn load_object_models(&self, objects: &mut Vec<RoomObject>) {
+ let renderer = self.renderer.as_ref();
+ let model_bounds_fn = |m: Option<renderbud::Model>| -> Option<renderbud::Aabb> {
+ let r = renderer?.renderer.lock().unwrap();
+ r.model_bounds(m?)
+ };
+
+ // Phase 1: Load all models and cache their AABB bounds
+ let mut bounds_by_id: std::collections::HashMap<String, renderbud::Aabb> =
+ std::collections::HashMap::new();
+
+ for obj in objects.iter_mut() {
+ if let Some(url) = &obj.model_url {
+ let model = self.load_model(url);
+ if let Some(bounds) = model_bounds_fn(model) {
+ bounds_by_id.insert(obj.id.clone(), bounds);
+ }
+ obj.model_handle = model;
+ }
+ }
+
+ // Phase 2: Resolve semantic locations to positions
+ // Collect resolved positions first to avoid borrow issues
+ let mut resolved: Vec<(usize, Vec3)> = Vec::new();
+
+ for (i, obj) in objects.iter().enumerate() {
+ let Some(loc) = &obj.location else {
+ continue;
+ };
+
+ match loc {
+ room_state::ObjectLocation::TopOf(target_id) => {
+ // Find the target object's position and top-of-AABB
+ let target = objects.iter().find(|o| o.id == *target_id);
+ if let Some(target) = target {
+ let target_top =
+ bounds_by_id.get(target_id).map(|b| b.max.y).unwrap_or(0.0);
+ let self_half_h = bounds_by_id
+ .get(&obj.id)
+ .map(|b| (b.max.y - b.min.y) * 0.5)
+ .unwrap_or(0.0);
+ let pos = Vec3::new(
+ target.position.x,
+ target_top + self_half_h,
+ target.position.z,
+ );
+ resolved.push((i, pos));
+ }
+ }
+ room_state::ObjectLocation::Near(target_id) => {
+ // Place nearby: offset by target's width + margin
+ let target = objects.iter().find(|o| o.id == *target_id);
+ if let Some(target) = target {
+ let offset = bounds_by_id
+ .get(target_id)
+ .map(|b| b.max.x - b.min.x)
+ .unwrap_or(1.0);
+ let pos = Vec3::new(
+ target.position.x + offset,
+ target.position.y,
+ target.position.z,
+ );
+ resolved.push((i, pos));
+ }
+ }
+ room_state::ObjectLocation::Floor => {
+ let self_half_h = bounds_by_id
+ .get(&obj.id)
+ .map(|b| (b.max.y - b.min.y) * 0.5)
+ .unwrap_or(0.0);
+ resolved.push((i, Vec3::new(obj.position.x, self_half_h, obj.position.z)));
+ }
+ _ => {}
+ }
+ }
+
+ for (i, pos) in resolved {
+ objects[i].position = pos;
+ }
+ }
+
+ /// Poll the room subscription for updates.
+ /// Skips applying updates while the room has unsaved local edits.
+ fn poll_room_updates(&mut self, ndb: &nostrdb::Ndb) {
+ if self.state.dirty {
+ return;
+ }
+ let Some(sub) = &self.room_sub else {
+ return;
+ };
+ let txn = nostrdb::Transaction::new(ndb).expect("txn");
+ let notes = sub.poll(ndb, &txn);
+
+ for note in ¬es {
+ let Some(room_id) = nostr_events::get_room_id(note) else {
+ continue;
+ };
+ if room_id != self.state.room_ref.id {
+ continue;
+ }
+
+ let Some(space) = nostr_events::parse_room_event(note) else {
+ continue;
+ };
+
+ self.apply_space(&space);
+ tracing::info!("Room '{}' updated from nostrdb", room_id);
+ }
+ }
+
+ /// Run one tick of presence: publish local position, poll remote, expire stale.
+ fn tick_presence(&mut self, ctx: &mut AppContext<'_>) {
+ let now = self.start_time.elapsed().as_secs_f64();
+
+ // Publish our position (throttled — only on change or keep-alive)
+ if let Some(kp) = ctx.accounts.selected_filled() {
+ let self_pos = self
+ .state
+ .users
+ .iter()
+ .find(|u| u.is_self)
+ .map(|u| u.position)
+ .unwrap_or(Vec3::ZERO);
+
+ self.presence_pub
+ .maybe_publish(ctx.ndb, kp, &self.room_naddr, self_pos, now);
+ }
+
+ // Poll for remote presence events
+ let self_pubkey = *ctx.accounts.selected_account_pubkey();
+ if let Some(sub) = &self.presence_sub {
+ let changed = presence::poll_presence(
+ sub,
+ ctx.ndb,
+ &self.room_naddr,
+ &self_pubkey,
+ &mut self.state.users,
+ now,
+ );
+
+ // Assign avatar model to new users
+ if changed {
+ let avatar_model = self
+ .state
+ .users
+ .iter()
+ .find(|u| u.is_self)
+ .and_then(|u| u.model_handle);
+ if let Some(model) = avatar_model {
+ for user in &mut self.state.users {
+ if user.model_handle.is_none() {
+ user.model_handle = Some(model);
+ }
+ }
+ }
+ }
+ }
+
+ // Expire stale remote users (throttled to every ~10s)
+ let removed = self
+ .presence_expiry
+ .maybe_expire(&mut self.state.users, now);
+ if removed > 0 {
+ tracing::info!("Expired {} stale users", removed);
+ }
+ }
+
/// Sync room objects and user avatars to the renderbud scene
fn sync_scene(&mut self) {
let Some(renderer) = &self.renderer else {
@@ -250,19 +459,34 @@ impl NostrverseApp {
}
// Sync all user avatars to the scene
- // Water bottle is ~0.26m; scale to human height (~1.8m)
- let avatar_scale = 7.0_f32;
+ let avatar_half_h = self
+ .avatar_bounds
+ .map(|b| (b.max.y - b.min.y) * 0.5)
+ .unwrap_or(0.0);
+ let avatar_y_offset = avatar_half_h * AVATAR_SCALE;
+
+ // Smoothly lerp avatar yaw toward target
+ if let Some(target_yaw) = avatar_yaw {
+ let current = self.state.smooth_avatar_yaw;
+ let mut diff = target_yaw - current;
+ diff = (diff + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU)
+ - std::f32::consts::PI;
+ let dt = 1.0 / 60.0;
+ let t = (AVATAR_YAW_LERP_SPEED * dt).min(1.0);
+ self.state.smooth_avatar_yaw = current + diff * t;
+ }
+
for user in &mut self.state.users {
let yaw = if user.is_self {
- avatar_yaw.unwrap_or(0.0)
+ self.state.smooth_avatar_yaw
} else {
0.0
};
let transform = Transform {
- translation: user.position,
+ translation: user.position + Vec3::new(0.0, avatar_y_offset, 0.0),
rotation: glam::Quat::from_rotation_y(yaw),
- scale: Vec3::splat(avatar_scale),
+ scale: Vec3::splat(AVATAR_SCALE),
};
if let Some(scene_id) = user.scene_object_id {
@@ -286,22 +510,28 @@ impl NostrverseApp {
}
impl notedeck::App for NostrverseApp {
- fn update(&mut self, _ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse {
- // Initialize demo data on first frame
- self.init_demo_data();
+ fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse {
+ // Initialize on first frame
+ self.initialize(ctx);
+
+ // Poll for room event updates
+ self.poll_room_updates(ctx.ndb);
+
+ // Presence: publish, poll, expire
+ self.tick_presence(ctx);
// Sync state to 3D scene
self.sync_scene();
// Get available size before layout
let available = ui.available_size();
+ let panel_width = 240.0;
- // Main layout with room view and optional inspection panel
+ // Main layout: 3D view + editing panel
ui.allocate_ui(available, |ui| {
ui.horizontal(|ui| {
- // Reserve space for panel if needed
- let room_width = if self.state.selected_object.is_some() {
- available.x - 200.0
+ let room_width = if self.state.edit_mode {
+ available.x - panel_width
} else {
available.x
};
@@ -310,19 +540,8 @@ impl notedeck::App for NostrverseApp {
if let Some(renderer) = &self.renderer {
let response = show_room_view(ui, &mut self.state, renderer);
- // Handle actions from room view
if let Some(action) = response.action {
- match action {
- NostrverseAction::MoveObject { id, position } => {
- tracing::info!("Object {} moved to {:?}", id, position);
- }
- NostrverseAction::SelectObject(selected) => {
- self.state.selected_object = selected;
- }
- NostrverseAction::OpenAddObject => {
- // TODO: Open add object dialog
- }
- }
+ self.handle_action(action, ctx);
}
} else {
ui.centered_and_justified(|ui| {
@@ -331,13 +550,11 @@ impl notedeck::App for NostrverseApp {
}
});
- // Inspection panel when object selected
- if self.state.selected_object.is_some() {
- ui.allocate_ui(egui::vec2(200.0, available.y), |ui| {
- if let Some(action) = render_inspection_panel(ui, &mut self.state)
- && let NostrverseAction::SelectObject(None) = action
- {
- self.state.selected_object = None;
+ // Editing panel (always visible in edit mode)
+ if self.state.edit_mode {
+ ui.allocate_ui(egui::vec2(panel_width, available.y), |ui| {
+ if let Some(action) = render_editing_panel(ui, &mut self.state) {
+ self.handle_action(action, ctx);
}
});
}
@@ -347,3 +564,34 @@ impl notedeck::App for NostrverseApp {
AppResponse::none()
}
}
+
+impl NostrverseApp {
+ fn handle_action(&mut self, action: NostrverseAction, ctx: &mut AppContext<'_>) {
+ match action {
+ NostrverseAction::MoveObject { id, position } => {
+ if let Some(obj) = self.state.get_object_mut(&id) {
+ obj.position = position;
+ self.state.dirty = true;
+ }
+ }
+ NostrverseAction::SelectObject(selected) => {
+ self.state.selected_object = selected;
+ }
+ NostrverseAction::SaveRoom => {
+ self.save_room(ctx);
+ self.state.dirty = false;
+ }
+ NostrverseAction::AddObject(obj) => {
+ self.state.objects.push(obj);
+ self.state.dirty = true;
+ }
+ NostrverseAction::RemoveObject(id) => {
+ self.state.objects.retain(|o| o.id != id);
+ if self.state.selected_object.as_ref() == Some(&id) {
+ self.state.selected_object = None;
+ }
+ self.state.dirty = true;
+ }
+ }
+ }
+}
diff --git a/crates/notedeck_nostrverse/src/nostr_events.rs b/crates/notedeck_nostrverse/src/nostr_events.rs
@@ -0,0 +1,233 @@
+//! Nostr event creation and parsing for nostrverse rooms.
+//!
+//! Room events (kind 37555) are NIP-33 parameterized replaceable events
+//! where the content is a protoverse `.space` s-expression.
+
+use enostr::FilledKeypair;
+use nostrdb::{Ndb, Note, NoteBuilder};
+use protoverse::Space;
+
+use crate::kinds;
+
+/// Build a room event (kind 37555) from a protoverse Space.
+///
+/// Tags: ["d", room_id], ["name", room_name], ["summary", text_description]
+/// Content: serialized .space s-expression
+pub fn build_room_event<'a>(space: &Space, room_id: &str) -> NoteBuilder<'a> {
+ let content = protoverse::serialize(space);
+ let summary = protoverse::describe(space);
+ let name = space.name(space.root).unwrap_or("Untitled Room");
+
+ NoteBuilder::new()
+ .kind(kinds::ROOM as u32)
+ .content(&content)
+ .start_tag()
+ .tag_str("d")
+ .tag_str(room_id)
+ .start_tag()
+ .tag_str("name")
+ .tag_str(name)
+ .start_tag()
+ .tag_str("summary")
+ .tag_str(&summary)
+}
+
+/// Parse a room event's content into a protoverse Space.
+pub fn parse_room_event(note: &Note<'_>) -> Option<Space> {
+ let content = note.content();
+ if content.is_empty() {
+ return None;
+ }
+ protoverse::parse(content).ok()
+}
+
+/// Extract the "d" tag (room identifier) from a note.
+pub fn get_room_id<'a>(note: &'a Note<'a>) -> Option<&'a str> {
+ get_tag_value(note, "d")
+}
+
+/// Extract a tag value by name from a note.
+fn get_tag_value<'a>(note: &'a Note<'a>, tag_name: &str) -> Option<&'a str> {
+ for tag in note.tags() {
+ if tag.count() < 2 {
+ continue;
+ }
+ let Some(name) = tag.get_str(0) else {
+ continue;
+ };
+ if name != tag_name {
+ continue;
+ }
+ return tag.get_str(1);
+ }
+ None
+}
+
+/// Build a coarse presence heartbeat event (kind 10555).
+///
+/// Published on meaningful position change, plus periodic keep-alive.
+/// Tags: ["a", room_naddr], ["position", "x y z"], ["expiration", unix_ts]
+/// Content: empty
+///
+/// The expiration tag (NIP-40) tells relays/nostrdb to discard the event
+/// after 90 seconds, matching the client-side stale timeout.
+pub fn build_presence_event<'a>(room_naddr: &str, position: glam::Vec3) -> NoteBuilder<'a> {
+ let pos_str = format!("{} {} {}", position.x, position.y, position.z);
+
+ let expiration = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs()
+ + 90;
+ let exp_str = expiration.to_string();
+
+ NoteBuilder::new()
+ .kind(kinds::PRESENCE as u32)
+ .content("")
+ .start_tag()
+ .tag_str("a")
+ .tag_str(room_naddr)
+ .start_tag()
+ .tag_str("position")
+ .tag_str(&pos_str)
+ .start_tag()
+ .tag_str("expiration")
+ .tag_str(&exp_str)
+}
+
+/// Parse a presence event's position tag into a Vec3.
+pub fn parse_presence_position(note: &Note<'_>) -> Option<glam::Vec3> {
+ let pos_str = get_tag_value(note, "position")?;
+ let mut parts = pos_str.split_whitespace();
+ let x: f32 = parts.next()?.parse().ok()?;
+ let y: f32 = parts.next()?.parse().ok()?;
+ let z: f32 = parts.next()?.parse().ok()?;
+ Some(glam::Vec3::new(x, y, z))
+}
+
+/// Extract the "a" tag (room naddr) from a presence note.
+pub fn get_presence_room<'a>(note: &'a Note<'a>) -> Option<&'a str> {
+ get_tag_value(note, "a")
+}
+
+/// Sign and ingest a nostr event into the local nostrdb only (no relay publishing).
+pub fn ingest_event(builder: NoteBuilder<'_>, ndb: &Ndb, kp: FilledKeypair) {
+ let note = builder
+ .sign(&kp.secret_key.secret_bytes())
+ .build()
+ .expect("build note");
+
+ let Ok(event) = &enostr::ClientMessage::event(¬e) else {
+ tracing::error!("ingest_event: failed to build client message");
+ return;
+ };
+
+ let Ok(json) = event.to_json() else {
+ tracing::error!("ingest_event: failed to serialize json");
+ return;
+ };
+
+ let _ = ndb.process_event_with(&json, nostrdb::IngestMetadata::new().client(true));
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_build_room_event() {
+ let space = protoverse::parse(
+ r#"(room (name "Test Room") (shape rectangle) (width 10) (depth 8)
+ (group (table (id desk) (name "My Desk"))))"#,
+ )
+ .unwrap();
+
+ let mut builder = build_room_event(&space, "my-room");
+ let note = builder.build().expect("build note");
+
+ // Content should be the serialized space
+ let content = note.content();
+ assert!(content.contains("room"));
+ assert!(content.contains("Test Room"));
+
+ // Should have d, name, summary tags
+ let mut has_d = false;
+ let mut has_name = false;
+ let mut has_summary = false;
+
+ for tag in note.tags() {
+ if tag.count() < 2 {
+ continue;
+ }
+ match tag.get_str(0) {
+ Some("d") => {
+ assert_eq!(tag.get_str(1), Some("my-room"));
+ has_d = true;
+ }
+ Some("name") => {
+ assert_eq!(tag.get_str(1), Some("Test Room"));
+ has_name = true;
+ }
+ Some("summary") => {
+ has_summary = true;
+ }
+ _ => {}
+ }
+ }
+
+ assert!(has_d, "missing d tag");
+ assert!(has_name, "missing name tag");
+ assert!(has_summary, "missing summary tag");
+ }
+
+ #[test]
+ fn test_parse_room_event_roundtrip() {
+ let original = r#"(room (name "Test Room") (shape rectangle) (width 10) (depth 8)
+ (group (table (id desk) (name "My Desk"))))"#;
+
+ let space = protoverse::parse(original).unwrap();
+ let mut builder = build_room_event(&space, "test-room");
+ let note = builder.build().expect("build note");
+
+ // Parse the event content back into a Space
+ let parsed = parse_room_event(¬e).expect("parse room event");
+ assert_eq!(parsed.name(parsed.root), Some("Test Room"));
+
+ // Should have same structure
+ assert_eq!(space.cells.len(), parsed.cells.len());
+ }
+
+ #[test]
+ fn test_get_room_id() {
+ let space = protoverse::parse("(room (name \"X\"))").unwrap();
+ let mut builder = build_room_event(&space, "my-id");
+ let note = builder.build().expect("build note");
+
+ assert_eq!(get_room_id(¬e), Some("my-id"));
+ }
+
+ #[test]
+ fn test_build_presence_event() {
+ let pos = glam::Vec3::new(1.5, 0.0, -3.2);
+ let mut builder = build_presence_event("37555:abc123:my-room", pos);
+ let note = builder.build().expect("build note");
+
+ assert_eq!(note.content(), "");
+ assert_eq!(get_presence_room(¬e), Some("37555:abc123:my-room"));
+
+ let parsed_pos = parse_presence_position(¬e).expect("parse position");
+ assert!((parsed_pos.x - 1.5).abs() < 0.01);
+ assert!((parsed_pos.y - 0.0).abs() < 0.01);
+ assert!((parsed_pos.z - (-3.2)).abs() < 0.01);
+
+ // Should have an expiration tag (NIP-40)
+ let exp = get_tag_value(¬e, "expiration").expect("missing expiration tag");
+ let exp_ts: u64 = exp.parse().expect("expiration should be a number");
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_secs();
+ assert!(exp_ts > now, "expiration should be in the future");
+ assert!(exp_ts <= now + 91, "expiration should be ~90s from now");
+ }
+}
diff --git a/crates/notedeck_nostrverse/src/presence.rs b/crates/notedeck_nostrverse/src/presence.rs
@@ -0,0 +1,262 @@
+//! Coarse presence via nostr events (kind 10555).
+//!
+//! Publishes only on meaningful position change (with 1s minimum gap),
+//! plus a keep-alive heartbeat every 60s to maintain room presence.
+//! Not intended for smooth real-time movement sync.
+
+use enostr::{FilledKeypair, Pubkey};
+use glam::Vec3;
+use nostrdb::Ndb;
+
+use crate::{nostr_events, room_state::RoomUser, subscriptions::PresenceSubscription};
+
+/// Minimum position change (distance) to trigger a publish.
+const POSITION_THRESHOLD: f32 = 0.5;
+
+/// Minimum seconds between publishes even when moving.
+const MIN_PUBLISH_GAP: f64 = 1.0;
+
+/// Keep-alive interval: publish even when idle to stay visible.
+const KEEPALIVE_INTERVAL: f64 = 60.0;
+
+/// Seconds without a heartbeat before a remote user is considered gone.
+const STALE_TIMEOUT: f64 = 90.0;
+
+/// How often to check for stale users (seconds).
+const EXPIRY_CHECK_INTERVAL: f64 = 10.0;
+
+/// Publishes local user presence as kind 10555 events.
+///
+/// Only publishes when position changes meaningfully, plus periodic
+/// keep-alive to maintain room presence. Does not spam on idle.
+pub struct PresencePublisher {
+ /// Last position we published
+ last_position: Vec3,
+ /// Monotonic time of last publish
+ last_publish_time: f64,
+ /// Whether we've published at least once
+ published_once: bool,
+}
+
+impl PresencePublisher {
+ pub fn new() -> Self {
+ Self {
+ last_position: Vec3::ZERO,
+ last_publish_time: 0.0,
+ published_once: false,
+ }
+ }
+
+ /// Check whether a publish should happen (without side effects).
+ /// Used for both the real publish path and tests.
+ fn should_publish(&self, position: Vec3, now: f64) -> bool {
+ // Always publish the first time
+ if !self.published_once {
+ return true;
+ }
+
+ let elapsed = now - self.last_publish_time;
+
+ // Rate limit: never more than once per second
+ if elapsed < MIN_PUBLISH_GAP {
+ return false;
+ }
+
+ // Publish if position changed meaningfully
+ let moved = self.last_position.distance(position) > POSITION_THRESHOLD;
+ if moved {
+ return true;
+ }
+
+ // Keep-alive: publish periodically even when idle
+ elapsed >= KEEPALIVE_INTERVAL
+ }
+
+ /// Record that a publish happened (update internal state).
+ fn record_publish(&mut self, position: Vec3, now: f64) {
+ self.last_position = position;
+ self.last_publish_time = now;
+ self.published_once = true;
+ }
+
+ /// Maybe publish a presence heartbeat. Returns true if published.
+ pub fn maybe_publish(
+ &mut self,
+ ndb: &Ndb,
+ kp: FilledKeypair,
+ room_naddr: &str,
+ position: Vec3,
+ now: f64,
+ ) -> bool {
+ if !self.should_publish(position, now) {
+ return false;
+ }
+
+ let builder = nostr_events::build_presence_event(room_naddr, position);
+ nostr_events::ingest_event(builder, ndb, kp);
+
+ self.record_publish(position, now);
+ true
+ }
+}
+
+/// Poll for presence events and update the user list.
+///
+/// Returns true if any users were added or updated.
+pub fn poll_presence(
+ sub: &PresenceSubscription,
+ ndb: &Ndb,
+ room_naddr: &str,
+ self_pubkey: &Pubkey,
+ users: &mut Vec<RoomUser>,
+ now: f64,
+) -> bool {
+ let txn = nostrdb::Transaction::new(ndb).expect("txn");
+ let notes = sub.poll(ndb, &txn);
+ let mut changed = false;
+
+ for note in ¬es {
+ // Filter to our room
+ let Some(event_room) = nostr_events::get_presence_room(note) else {
+ continue;
+ };
+ if event_room != room_naddr {
+ continue;
+ }
+
+ let Some(position) = nostr_events::parse_presence_position(note) else {
+ continue;
+ };
+
+ let pubkey = Pubkey::new(*note.pubkey());
+
+ // Skip our own presence events
+ if &pubkey == self_pubkey {
+ continue;
+ }
+
+ // Update or insert user
+ if let Some(user) = users.iter_mut().find(|u| u.pubkey == pubkey) {
+ user.position = position;
+ user.last_seen = now;
+ } else {
+ let mut user = RoomUser::new(pubkey, "anon".to_string(), position);
+ user.last_seen = now;
+ users.push(user);
+ }
+ changed = true;
+ }
+
+ changed
+}
+
+/// Remove users who haven't sent a heartbeat recently.
+/// Throttled to only run every EXPIRY_CHECK_INTERVAL seconds.
+pub struct PresenceExpiry {
+ last_check: f64,
+}
+
+impl PresenceExpiry {
+ pub fn new() -> Self {
+ Self { last_check: 0.0 }
+ }
+
+ /// Maybe expire stale users. Returns the number removed (0 if check was skipped).
+ pub fn maybe_expire(&mut self, users: &mut Vec<RoomUser>, now: f64) -> usize {
+ if now - self.last_check < EXPIRY_CHECK_INTERVAL {
+ return 0;
+ }
+ self.last_check = now;
+ let before = users.len();
+ users.retain(|u| u.is_self || (now - u.last_seen) < STALE_TIMEOUT);
+ before - users.len()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_expiry_throttle_and_cleanup() {
+ let pk1 = Pubkey::new([1; 32]);
+ let pk2 = Pubkey::new([2; 32]);
+ let pk_self = Pubkey::new([3; 32]);
+
+ let mut users = vec![
+ {
+ let mut u = RoomUser::new(pk_self, "me".to_string(), Vec3::ZERO);
+ u.is_self = true;
+ u.last_seen = 0.0; // stale but self — should survive
+ u
+ },
+ {
+ let mut u = RoomUser::new(pk1, "alice".to_string(), Vec3::ZERO);
+ u.last_seen = 80.0; // fresh (within 90s timeout)
+ u
+ },
+ {
+ let mut u = RoomUser::new(pk2, "bob".to_string(), Vec3::ZERO);
+ u.last_seen = 1.0; // stale (>90s ago)
+ u
+ },
+ ];
+
+ let mut expiry = PresenceExpiry::new();
+
+ // First call at t=5 — too soon (< 10s from init at 0.0), skipped
+ assert_eq!(expiry.maybe_expire(&mut users, 5.0), 0);
+ assert_eq!(users.len(), 3); // no one removed
+
+ // At t=100 — enough time, bob is stale
+ let removed = expiry.maybe_expire(&mut users, 100.0);
+ assert_eq!(removed, 1);
+ assert_eq!(users.len(), 2);
+ assert!(users.iter().any(|u| u.is_self));
+ assert!(users.iter().any(|u| u.display_name == "alice"));
+
+ // Immediately again at t=101 — throttled, skipped
+ assert_eq!(expiry.maybe_expire(&mut users, 101.0), 0);
+ }
+
+ #[test]
+ fn test_publisher_first_publish() {
+ let pub_ = PresencePublisher::new();
+ // First publish should always happen
+ assert!(pub_.should_publish(Vec3::ZERO, 0.0));
+ }
+
+ #[test]
+ fn test_publisher_no_spam_when_idle() {
+ let mut pub_ = PresencePublisher::new();
+ pub_.record_publish(Vec3::ZERO, 0.0);
+
+ // Idle at same position — should NOT publish at 1s, 5s, 10s, 30s
+ assert!(!pub_.should_publish(Vec3::ZERO, 1.0));
+ assert!(!pub_.should_publish(Vec3::ZERO, 5.0));
+ assert!(!pub_.should_publish(Vec3::ZERO, 10.0));
+ assert!(!pub_.should_publish(Vec3::ZERO, 30.0));
+
+ // Keep-alive triggers at 60s
+ assert!(pub_.should_publish(Vec3::ZERO, 60.1));
+ }
+
+ #[test]
+ fn test_publisher_on_movement() {
+ let mut pub_ = PresencePublisher::new();
+ pub_.record_publish(Vec3::ZERO, 0.0);
+
+ // Small movement below threshold — no publish
+ assert!(!pub_.should_publish(Vec3::new(0.1, 0.0, 0.0), 2.0));
+
+ // Significant movement — publish
+ assert!(pub_.should_publish(Vec3::new(5.0, 0.0, 0.0), 2.0));
+
+ // But rate limited: can't publish again within 1s
+ pub_.record_publish(Vec3::new(5.0, 0.0, 0.0), 2.0);
+ assert!(!pub_.should_publish(Vec3::new(10.0, 0.0, 0.0), 2.5));
+
+ // After 1s gap, can publish again
+ assert!(pub_.should_publish(Vec3::new(10.0, 0.0, 0.0), 3.1));
+ }
+}
diff --git a/crates/notedeck_nostrverse/src/room_state.rs b/crates/notedeck_nostrverse/src/room_state.rs
@@ -11,8 +11,12 @@ pub enum NostrverseAction {
MoveObject { id: String, position: Vec3 },
/// Object was selected
SelectObject(Option<String>),
- /// Request to open add object UI
- OpenAddObject,
+ /// Room or object was edited, needs re-ingest
+ SaveRoom,
+ /// A new object was added
+ AddObject(RoomObject),
+ /// An object was removed
+ RemoveObject(String),
}
/// Reference to a nostrverse room
@@ -66,13 +70,43 @@ pub enum RoomShape {
Custom,
}
+/// Spatial location relative to the room or another object.
+/// Mirrors protoverse::Location for decoupling.
+#[derive(Clone, Debug, PartialEq)]
+pub enum ObjectLocation {
+ Center,
+ Floor,
+ Ceiling,
+ /// On top of another object (by id)
+ TopOf(String),
+ /// Near another object (by id)
+ Near(String),
+ Custom(String),
+}
+
+/// Protoverse object type, preserved for round-trip serialization
+#[derive(Clone, Debug, Default)]
+pub enum RoomObjectType {
+ Table,
+ Chair,
+ Door,
+ Light,
+ #[default]
+ Prop,
+ Custom(String),
+}
+
/// Object in a room - references a 3D model
#[derive(Clone, Debug)]
pub struct RoomObject {
pub id: String,
pub name: String,
+ /// Protoverse cell type (table, chair, prop, etc.)
+ pub object_type: RoomObjectType,
/// URL to a glTF model (None = use placeholder geometry)
pub model_url: Option<String>,
+ /// Semantic location (e.g. "top-of obj1"), resolved to position at load time
+ pub location: Option<ObjectLocation>,
/// 3D position in world space
pub position: Vec3,
/// 3D rotation
@@ -90,7 +124,9 @@ impl RoomObject {
Self {
id,
name,
+ object_type: RoomObjectType::Prop,
model_url: None,
+ location: None,
position,
rotation: Quat::IDENTITY,
scale: Vec3::ONE,
@@ -99,25 +135,27 @@ impl RoomObject {
}
}
+ pub fn with_object_type(mut self, object_type: RoomObjectType) -> Self {
+ self.object_type = object_type;
+ self
+ }
+
pub fn with_model_url(mut self, url: String) -> Self {
self.model_url = Some(url);
self
}
+ pub fn with_location(mut self, loc: ObjectLocation) -> Self {
+ self.location = Some(loc);
+ self
+ }
+
pub fn with_scale(mut self, scale: Vec3) -> Self {
self.scale = scale;
self
}
}
-/// User presence in a room (legacy, use RoomUser for rendering)
-#[derive(Clone, Debug)]
-pub struct Presence {
- pub pubkey: Pubkey,
- pub position: Vec3,
- pub status: Option<String>,
-}
-
/// A user present in a room (for rendering)
#[derive(Clone, Debug)]
pub struct RoomUser {
@@ -126,8 +164,8 @@ pub struct RoomUser {
pub position: Vec3,
/// Whether this is the current user
pub is_self: bool,
- /// Whether this user is an AI agent
- pub is_agent: bool,
+ /// Monotonic timestamp (seconds) of last presence update
+ pub last_seen: f64,
/// Runtime: renderbud scene object handle for avatar
pub scene_object_id: Option<ObjectId>,
/// Runtime: loaded model handle for avatar
@@ -141,7 +179,7 @@ impl RoomUser {
display_name,
position,
is_self: false,
- is_agent: false,
+ last_seen: 0.0,
scene_object_id: None,
model_handle: None,
}
@@ -151,11 +189,6 @@ impl RoomUser {
self.is_self = is_self;
self
}
-
- pub fn with_agent(mut self, is_agent: bool) -> Self {
- self.is_agent = is_agent;
- self
- }
}
/// State for a nostrverse view
@@ -172,6 +205,10 @@ pub struct NostrverseState {
pub selected_object: Option<String>,
/// Whether we're in edit mode
pub edit_mode: bool,
+ /// Smoothed avatar yaw for lerped rotation
+ pub smooth_avatar_yaw: f32,
+ /// Room has unsaved edits
+ pub dirty: bool,
}
impl NostrverseState {
@@ -182,7 +219,9 @@ impl NostrverseState {
objects: Vec::new(),
users: Vec::new(),
selected_object: None,
- edit_mode: false,
+ edit_mode: true,
+ smooth_avatar_yaw: 0.0,
+ dirty: false,
}
}
diff --git a/crates/notedeck_nostrverse/src/room_view.rs b/crates/notedeck_nostrverse/src/room_view.rs
@@ -1,8 +1,9 @@
-//! Room 3D rendering for nostrverse via renderbud
+//! Room 3D rendering and editing UI for nostrverse via renderbud
use egui::{Color32, Pos2, Rect, Response, Sense, Stroke, Ui};
+use glam::Vec3;
-use super::room_state::{NostrverseAction, NostrverseState};
+use super::room_state::{NostrverseAction, NostrverseState, RoomObject, RoomShape};
/// Response from rendering the nostrverse view
pub struct NostrverseResponse {
@@ -46,10 +47,10 @@ pub fn show_room_view(
ui.input(|i| {
if i.key_down(egui::Key::W) {
- forward += 1.0;
+ forward -= 1.0;
}
if i.key_down(egui::Key::S) {
- forward -= 1.0;
+ forward += 1.0;
}
if i.key_down(egui::Key::D) {
right += 1.0;
@@ -116,60 +117,236 @@ fn draw_info_overlay(painter: &egui::Painter, state: &NostrverseState, rect: Rec
);
}
-/// Render the object inspection panel (side panel when object is selected)
-pub fn render_inspection_panel(
- ui: &mut Ui,
- state: &mut NostrverseState,
-) -> Option<NostrverseAction> {
- let selected_id = state.selected_object.as_ref()?;
- let obj = state.objects.iter().find(|o| &o.id == selected_id)?;
-
+/// Render the side panel with room editing, object list, and object inspector.
+pub fn render_editing_panel(ui: &mut Ui, state: &mut NostrverseState) -> Option<NostrverseAction> {
let mut action = None;
- egui::Frame::default()
+ let panel = egui::Frame::default()
.fill(Color32::from_rgba_unmultiplied(30, 35, 45, 240))
.inner_margin(12.0)
.outer_margin(8.0)
.corner_radius(8.0)
- .stroke(Stroke::new(1.0, Color32::from_rgb(80, 90, 110)))
- .show(ui, |ui| {
- ui.set_min_width(180.0);
-
- ui.horizontal(|ui| {
- ui.strong("Object Inspector");
- ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- if ui.small_button("X").clicked() {
- action = Some(NostrverseAction::SelectObject(None));
- }
- });
- });
+ .stroke(Stroke::new(1.0, Color32::from_rgb(80, 90, 110)));
- ui.separator();
+ panel.show(ui, |ui| {
+ ui.set_min_width(220.0);
- ui.label(format!("Name: {}", obj.name));
- ui.label(format!(
- "Position: ({:.1}, {:.1}, {:.1})",
- obj.position.x, obj.position.y, obj.position.z
- ));
- ui.label(format!(
- "Scale: ({:.1}, {:.1}, {:.1})",
- obj.scale.x, obj.scale.y, obj.scale.z
- ));
-
- if let Some(url) = &obj.model_url {
+ egui::ScrollArea::vertical().show(ui, |ui| {
+ // --- Room Properties ---
+ if let Some(room) = &mut state.room {
+ ui.strong("Room");
ui.separator();
- ui.small(format!("Model: {}", url));
+
+ let name_changed = ui
+ .horizontal(|ui| {
+ ui.label("Name:");
+ ui.text_edit_singleline(&mut room.name).changed()
+ })
+ .inner;
+
+ let mut width = room.width;
+ let mut height = room.height;
+ let mut depth = room.depth;
+
+ let dims_changed = ui
+ .horizontal(|ui| {
+ ui.label("W:");
+ let w = ui
+ .add(
+ egui::DragValue::new(&mut width)
+ .speed(0.5)
+ .range(1.0..=200.0),
+ )
+ .changed();
+ ui.label("H:");
+ let h = ui
+ .add(
+ egui::DragValue::new(&mut height)
+ .speed(0.5)
+ .range(1.0..=200.0),
+ )
+ .changed();
+ ui.label("D:");
+ let d = ui
+ .add(
+ egui::DragValue::new(&mut depth)
+ .speed(0.5)
+ .range(1.0..=200.0),
+ )
+ .changed();
+ w || h || d
+ })
+ .inner;
+
+ room.width = width;
+ room.height = height;
+ room.depth = depth;
+
+ let shape_changed = ui
+ .horizontal(|ui| {
+ ui.label("Shape:");
+ let mut changed = false;
+ egui::ComboBox::from_id_salt("room_shape")
+ .selected_text(match room.shape {
+ RoomShape::Rectangle => "Rectangle",
+ RoomShape::Circle => "Circle",
+ RoomShape::Custom => "Custom",
+ })
+ .show_ui(ui, |ui| {
+ changed |= ui
+ .selectable_value(
+ &mut room.shape,
+ RoomShape::Rectangle,
+ "Rectangle",
+ )
+ .changed();
+ changed |= ui
+ .selectable_value(&mut room.shape, RoomShape::Circle, "Circle")
+ .changed();
+ });
+ changed
+ })
+ .inner;
+
+ if name_changed || dims_changed || shape_changed {
+ state.dirty = true;
+ }
+
+ ui.add_space(8.0);
}
+ // --- Object List ---
+ ui.strong("Objects");
ui.separator();
- let id_display = if obj.id.len() > 16 {
- format!("{}...", &obj.id[..16])
- } else {
- obj.id.clone()
- };
- ui.small(format!("ID: {}", id_display));
+ let num_objects = state.objects.len();
+ for i in 0..num_objects {
+ let is_selected = state
+ .selected_object
+ .as_ref()
+ .map(|s| s == &state.objects[i].id)
+ .unwrap_or(false);
+
+ let label = format!("{} ({})", state.objects[i].name, state.objects[i].id);
+ if ui.selectable_label(is_selected, label).clicked() {
+ let id = state.objects[i].id.clone();
+ state.selected_object = if is_selected { None } else { Some(id) };
+ }
+ }
+
+ // Add object button
+ ui.add_space(4.0);
+ if ui.button("+ Add Object").clicked() {
+ let new_id = format!("obj-{}", state.objects.len() + 1);
+ let obj = RoomObject::new(new_id.clone(), "New Object".to_string(), Vec3::ZERO);
+ action = Some(NostrverseAction::AddObject(obj));
+ }
+
+ ui.add_space(12.0);
+
+ // --- Object Inspector ---
+ if let Some(selected_id) = state.selected_object.clone() {
+ if let Some(obj) = state.objects.iter_mut().find(|o| o.id == selected_id) {
+ ui.strong("Inspector");
+ ui.separator();
+
+ ui.small(format!("ID: {}", obj.id));
+ ui.add_space(4.0);
+
+ // Editable name
+ let name_changed = ui
+ .horizontal(|ui| {
+ ui.label("Name:");
+ ui.text_edit_singleline(&mut obj.name).changed()
+ })
+ .inner;
+
+ // Editable position
+ let mut px = obj.position.x;
+ let mut py = obj.position.y;
+ let mut pz = obj.position.z;
+ let pos_changed = ui
+ .horizontal(|ui| {
+ ui.label("Pos:");
+ let x = ui
+ .add(egui::DragValue::new(&mut px).speed(0.1).prefix("x:"))
+ .changed();
+ let y = ui
+ .add(egui::DragValue::new(&mut py).speed(0.1).prefix("y:"))
+ .changed();
+ let z = ui
+ .add(egui::DragValue::new(&mut pz).speed(0.1).prefix("z:"))
+ .changed();
+ x || y || z
+ })
+ .inner;
+ obj.position = Vec3::new(px, py, pz);
+
+ // Editable scale (uniform)
+ let mut sx = obj.scale.x;
+ let mut sy = obj.scale.y;
+ let mut sz = obj.scale.z;
+ let scale_changed = ui
+ .horizontal(|ui| {
+ ui.label("Scale:");
+ let x = ui
+ .add(
+ egui::DragValue::new(&mut sx)
+ .speed(0.05)
+ .prefix("x:")
+ .range(0.01..=100.0),
+ )
+ .changed();
+ let y = ui
+ .add(
+ egui::DragValue::new(&mut sy)
+ .speed(0.05)
+ .prefix("y:")
+ .range(0.01..=100.0),
+ )
+ .changed();
+ let z = ui
+ .add(
+ egui::DragValue::new(&mut sz)
+ .speed(0.05)
+ .prefix("z:")
+ .range(0.01..=100.0),
+ )
+ .changed();
+ x || y || z
+ })
+ .inner;
+ obj.scale = Vec3::new(sx, sy, sz);
+
+ // Model URL (read-only for now)
+ if let Some(url) = &obj.model_url {
+ ui.add_space(4.0);
+ ui.small(format!("Model: {}", url));
+ }
+
+ if name_changed || pos_changed || scale_changed {
+ state.dirty = true;
+ }
+
+ ui.add_space(8.0);
+ if ui.button("Delete Object").clicked() {
+ action = Some(NostrverseAction::RemoveObject(selected_id));
+ }
+ }
+ }
+
+ // --- Save button ---
+ ui.add_space(12.0);
+ ui.separator();
+ let save_label = if state.dirty { "Save *" } else { "Save" };
+ if ui
+ .add_enabled(state.dirty, egui::Button::new(save_label))
+ .clicked()
+ {
+ action = Some(NostrverseAction::SaveRoom);
+ }
});
+ });
action
}
diff --git a/crates/notedeck_nostrverse/src/subscriptions.rs b/crates/notedeck_nostrverse/src/subscriptions.rs
@@ -0,0 +1,76 @@
+//! Local nostrdb subscription management for nostrverse rooms.
+//!
+//! Subscribes to room events (kind 37555) in the local nostrdb and
+//! polls for updates each frame. No remote relay subscriptions — rooms
+//! are local-only for now.
+
+use nostrdb::{Filter, Ndb, Note, Subscription, Transaction};
+
+use crate::kinds;
+
+/// Manages a local nostrdb subscription for room events.
+pub struct RoomSubscription {
+ /// Local nostrdb subscription handle
+ sub: Subscription,
+}
+
+impl RoomSubscription {
+ /// Subscribe to all room events (kind 37555) in the local nostrdb.
+ pub fn new(ndb: &Ndb) -> Self {
+ let filter = Filter::new().kinds([kinds::ROOM as u64]).build();
+ let sub = ndb.subscribe(&[filter]).expect("room subscription");
+ Self { sub }
+ }
+
+ /// Subscribe to room events from a specific author.
+ pub fn for_author(ndb: &Ndb, author: &[u8; 32]) -> Self {
+ let filter = Filter::new()
+ .kinds([kinds::ROOM as u64])
+ .authors([author])
+ .build();
+ let sub = ndb.subscribe(&[filter]).expect("room subscription");
+ Self { sub }
+ }
+
+ /// Poll for new room events. Returns parsed notes.
+ pub fn poll<'a>(&self, ndb: &'a Ndb, txn: &'a Transaction) -> Vec<Note<'a>> {
+ let note_keys = ndb.poll_for_notes(self.sub, 50);
+ note_keys
+ .into_iter()
+ .filter_map(|nk| ndb.get_note_by_key(txn, nk).ok())
+ .collect()
+ }
+
+ /// Query for existing room events (e.g. on startup).
+ pub fn query_existing<'a>(ndb: &'a Ndb, txn: &'a Transaction) -> Vec<Note<'a>> {
+ let filter = Filter::new().kinds([kinds::ROOM as u64]).limit(50).build();
+ ndb.query(txn, &[filter], 50)
+ .unwrap_or_default()
+ .into_iter()
+ .map(|qr| qr.note)
+ .collect()
+ }
+}
+
+/// Manages a local nostrdb subscription for presence events (kind 10555).
+pub struct PresenceSubscription {
+ sub: Subscription,
+}
+
+impl PresenceSubscription {
+ /// Subscribe to presence events in the local nostrdb.
+ pub fn new(ndb: &Ndb) -> Self {
+ let filter = Filter::new().kinds([kinds::PRESENCE as u64]).build();
+ let sub = ndb.subscribe(&[filter]).expect("presence subscription");
+ Self { sub }
+ }
+
+ /// Poll for new presence events.
+ pub fn poll<'a>(&self, ndb: &'a Ndb, txn: &'a Transaction) -> Vec<Note<'a>> {
+ let note_keys = ndb.poll_for_notes(self.sub, 50);
+ note_keys
+ .into_iter()
+ .filter_map(|nk| ndb.get_note_by_key(txn, nk).ok())
+ .collect()
+ }
+}
diff --git a/crates/protoverse/Cargo.toml b/crates/protoverse/Cargo.toml
@@ -0,0 +1,5 @@
+[package]
+name = "protoverse"
+version = "0.1.0"
+edition = "2021"
+description = "S-expression parser for protoverse spatial descriptions"
diff --git a/crates/protoverse/src/ast.rs b/crates/protoverse/src/ast.rs
@@ -0,0 +1,250 @@
+use std::fmt;
+
+/// Index into Space.cells
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub struct CellId(pub u32);
+
+/// The parsed space — a flat arena of cells and attributes.
+///
+/// Cells and attributes are stored contiguously. Each cell references
+/// its attributes via a range into `attributes`, and its children
+/// via a range into `child_ids` (which itself stores CellIds).
+pub struct Space {
+ /// All cells, indexed by CellId
+ pub cells: Vec<Cell>,
+ /// All attributes, contiguous per cell
+ pub attributes: Vec<Attribute>,
+ /// Flat child reference array — cells index into this
+ pub child_ids: Vec<CellId>,
+ /// Root cell of the space
+ pub root: CellId,
+}
+
+pub struct Cell {
+ pub cell_type: CellType,
+ /// Index of first attribute in Space.attributes
+ pub first_attr: u32,
+ /// Number of attributes
+ pub attr_count: u16,
+ /// Index of first child reference in Space.child_ids
+ pub first_child: u32,
+ /// Number of children
+ pub child_count: u16,
+ /// Parent cell
+ pub parent: Option<CellId>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum CellType {
+ Room,
+ Space,
+ Group,
+ Object(ObjectType),
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum ObjectType {
+ Table,
+ Chair,
+ Door,
+ Light,
+ Custom(String),
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Attribute {
+ Id(String),
+ Type(String),
+ Name(String),
+ Material(String),
+ Condition(String),
+ Shape(Shape),
+ Width(f64),
+ Depth(f64),
+ Height(f64),
+ Location(Location),
+ State(CellState),
+ Position(f64, f64, f64),
+ ModelUrl(String),
+}
+
+/// Spatial location relative to the room or another object.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Location {
+ /// Center of parent container
+ Center,
+ /// On the floor
+ Floor,
+ /// On the ceiling
+ Ceiling,
+ /// On top of another object (by id)
+ TopOf(String),
+ /// Near another object (by id)
+ Near(String),
+ /// Freeform / unrecognized location value
+ Custom(String),
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum Shape {
+ Rectangle,
+ Circle,
+ Square,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum CellState {
+ On,
+ Off,
+ Sleeping,
+}
+
+// --- Display implementations ---
+
+impl fmt::Display for ObjectType {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ ObjectType::Table => write!(f, "table"),
+ ObjectType::Chair => write!(f, "chair"),
+ ObjectType::Door => write!(f, "door"),
+ ObjectType::Light => write!(f, "light"),
+ ObjectType::Custom(s) => write!(f, "{}", s),
+ }
+ }
+}
+
+impl fmt::Display for CellType {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ CellType::Room => write!(f, "room"),
+ CellType::Space => write!(f, "space"),
+ CellType::Group => write!(f, "group"),
+ CellType::Object(o) => write!(f, "{}", o),
+ }
+ }
+}
+
+impl fmt::Display for Shape {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Shape::Rectangle => write!(f, "rectangle"),
+ Shape::Circle => write!(f, "circle"),
+ Shape::Square => write!(f, "square"),
+ }
+ }
+}
+
+impl fmt::Display for CellState {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ CellState::On => write!(f, "on"),
+ CellState::Off => write!(f, "off"),
+ CellState::Sleeping => write!(f, "sleeping"),
+ }
+ }
+}
+
+impl fmt::Display for Location {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Location::Center => write!(f, "center"),
+ Location::Floor => write!(f, "floor"),
+ Location::Ceiling => write!(f, "ceiling"),
+ Location::TopOf(id) => write!(f, "top-of {}", id),
+ Location::Near(id) => write!(f, "near {}", id),
+ Location::Custom(s) => write!(f, "{}", s),
+ }
+ }
+}
+
+// --- Space accessor methods ---
+
+impl Space {
+ pub fn cell(&self, id: CellId) -> &Cell {
+ &self.cells[id.0 as usize]
+ }
+
+ pub fn children(&self, id: CellId) -> &[CellId] {
+ let cell = self.cell(id);
+ let start = cell.first_child as usize;
+ let end = start + cell.child_count as usize;
+ &self.child_ids[start..end]
+ }
+
+ pub fn attrs(&self, id: CellId) -> &[Attribute] {
+ let cell = self.cell(id);
+ let start = cell.first_attr as usize;
+ let end = start + cell.attr_count as usize;
+ &self.attributes[start..end]
+ }
+
+ pub fn name(&self, id: CellId) -> Option<&str> {
+ self.attrs(id).iter().find_map(|a| match a {
+ Attribute::Name(s) => Some(s.as_str()),
+ _ => None,
+ })
+ }
+
+ pub fn find_attr<F>(&self, id: CellId, pred: F) -> Option<&Attribute>
+ where
+ F: Fn(&Attribute) -> bool,
+ {
+ self.attrs(id).iter().find(|a| pred(a))
+ }
+
+ pub fn id_str(&self, id: CellId) -> Option<&str> {
+ self.attrs(id).iter().find_map(|a| match a {
+ Attribute::Id(s) => Some(s.as_str()),
+ _ => None,
+ })
+ }
+
+ pub fn position(&self, id: CellId) -> Option<(f64, f64, f64)> {
+ self.attrs(id).iter().find_map(|a| match a {
+ Attribute::Position(x, y, z) => Some((*x, *y, *z)),
+ _ => None,
+ })
+ }
+
+ pub fn model_url(&self, id: CellId) -> Option<&str> {
+ self.attrs(id).iter().find_map(|a| match a {
+ Attribute::ModelUrl(s) => Some(s.as_str()),
+ _ => None,
+ })
+ }
+
+ pub fn location(&self, id: CellId) -> Option<&Location> {
+ self.attrs(id).iter().find_map(|a| match a {
+ Attribute::Location(loc) => Some(loc),
+ _ => None,
+ })
+ }
+
+ pub fn width(&self, id: CellId) -> Option<f64> {
+ self.attrs(id).iter().find_map(|a| match a {
+ Attribute::Width(n) => Some(*n),
+ _ => None,
+ })
+ }
+
+ pub fn height(&self, id: CellId) -> Option<f64> {
+ self.attrs(id).iter().find_map(|a| match a {
+ Attribute::Height(n) => Some(*n),
+ _ => None,
+ })
+ }
+
+ pub fn depth(&self, id: CellId) -> Option<f64> {
+ self.attrs(id).iter().find_map(|a| match a {
+ Attribute::Depth(n) => Some(*n),
+ _ => None,
+ })
+ }
+
+ pub fn shape(&self, id: CellId) -> Option<&Shape> {
+ self.attrs(id).iter().find_map(|a| match a {
+ Attribute::Shape(s) => Some(s),
+ _ => None,
+ })
+ }
+}
diff --git a/crates/protoverse/src/describe.rs b/crates/protoverse/src/describe.rs
@@ -0,0 +1,204 @@
+use crate::ast::*;
+
+/// Generate a natural language description of a space.
+pub fn describe(space: &Space) -> String {
+ describe_from(space, space.root, 10)
+}
+
+/// Generate a description starting from a specific cell with depth limit.
+pub fn describe_from(space: &Space, root: CellId, max_depth: usize) -> String {
+ let mut buf = String::new();
+ describe_cells(space, root, max_depth, 0, &mut buf);
+ buf
+}
+
+fn describe_cells(space: &Space, id: CellId, max_depth: usize, depth: usize, buf: &mut String) {
+ if depth > max_depth {
+ return;
+ }
+
+ if !describe_cell(space, id, buf) {
+ return;
+ }
+
+ buf.push_str(".\n");
+
+ let children = space.children(id);
+ if children.is_empty() {
+ return;
+ }
+
+ let cell = space.cell(id);
+ if matches!(cell.cell_type, CellType::Room | CellType::Space) {
+ push_word(buf, "It contains");
+ }
+
+ // Recurse into first child (matches C behavior)
+ describe_cells(space, children[0], max_depth, depth + 1, buf);
+}
+
+fn describe_cell(space: &Space, id: CellId, buf: &mut String) -> bool {
+ let cell = space.cell(id);
+ match &cell.cell_type {
+ CellType::Room => describe_area(space, id, "room", buf),
+ CellType::Space => describe_area(space, id, "space", buf),
+ CellType::Group => describe_group(space, id, buf),
+ CellType::Object(_) => false, // unimplemented in C reference
+ }
+}
+
+fn describe_area(space: &Space, id: CellId, area_name: &str, buf: &mut String) -> bool {
+ buf.push_str("There is a(n)");
+
+ push_adjectives(space, id, buf);
+ push_shape(space, id, buf);
+ push_word(buf, area_name);
+ push_made_of(space, id, buf);
+ push_named(space, id, buf);
+
+ true
+}
+
+fn describe_group(space: &Space, id: CellId, buf: &mut String) -> bool {
+ let children = space.children(id);
+ let nobjs = children.len();
+
+ describe_amount(nobjs, buf);
+ push_word(buf, "object");
+
+ if nobjs > 1 {
+ buf.push_str("s:");
+ } else {
+ buf.push(':');
+ }
+
+ push_word(buf, "a");
+
+ for (i, &child_id) in children.iter().enumerate() {
+ if i > 0 {
+ if i == nobjs - 1 {
+ push_word(buf, "and");
+ } else {
+ buf.push(',');
+ }
+ }
+ describe_object_name(space, child_id, buf);
+ }
+
+ true
+}
+
+fn describe_object_name(space: &Space, id: CellId, buf: &mut String) {
+ if let Some(name) = space.name(id) {
+ push_word(buf, name);
+ }
+
+ let cell = space.cell(id);
+ let type_str = match &cell.cell_type {
+ CellType::Object(obj) => obj.to_string(),
+ other => other.to_string(),
+ };
+ push_word(buf, &type_str);
+}
+
+fn describe_amount(n: usize, buf: &mut String) {
+ let word = match n {
+ 1 => "a single",
+ 2 => "a couple",
+ 3 => "three",
+ 4 => "four",
+ 5 => "five",
+ _ => "many",
+ };
+ push_word(buf, word);
+}
+
+// --- Helper functions ---
+
+/// Push a word with automatic space separation.
+/// Adds a space before the word if the previous character is not whitespace.
+fn push_word(buf: &mut String, word: &str) {
+ if let Some(last) = buf.as_bytes().last() {
+ if !last.is_ascii_whitespace() {
+ buf.push(' ');
+ }
+ }
+ buf.push_str(word);
+}
+
+fn push_adjectives(space: &Space, id: CellId, buf: &mut String) {
+ let attrs = space.attrs(id);
+ let conditions: Vec<&str> = attrs
+ .iter()
+ .filter_map(|a| match a {
+ Attribute::Condition(s) => Some(s.as_str()),
+ _ => None,
+ })
+ .collect();
+
+ let adj_count = conditions.len();
+
+ for (i, cond) in conditions.iter().enumerate() {
+ if i > 0 {
+ if i == adj_count - 1 {
+ push_word(buf, "and");
+ } else {
+ buf.push(',');
+ }
+ }
+ push_word(buf, cond);
+ }
+}
+
+fn push_shape(space: &Space, id: CellId, buf: &mut String) {
+ let shape = space.attrs(id).iter().find_map(|a| match a {
+ Attribute::Shape(s) => Some(s),
+ _ => None,
+ });
+
+ if let Some(shape) = shape {
+ let adj = match shape {
+ Shape::Rectangle => "rectangular",
+ Shape::Circle => "circular",
+ Shape::Square => "square",
+ };
+ push_word(buf, adj);
+ }
+}
+
+fn push_made_of(space: &Space, id: CellId, buf: &mut String) {
+ let material = space.attrs(id).iter().find_map(|a| match a {
+ Attribute::Material(s) => Some(s.as_str()),
+ _ => None,
+ });
+
+ if let Some(mat) = material {
+ push_word(buf, "made of");
+ push_word(buf, mat);
+ }
+}
+
+fn push_named(space: &Space, id: CellId, buf: &mut String) {
+ if let Some(name) = space.name(id) {
+ push_word(buf, "named");
+ push_word(buf, name);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::parser::parse;
+
+ #[test]
+ fn test_describe_simple_room() {
+ let space =
+ parse("(room (shape rectangle) (name \"Test Room\") (material \"wood\"))").unwrap();
+ let desc = describe(&space);
+ assert!(desc.contains("There is a(n)"));
+ assert!(desc.contains("rectangular"));
+ assert!(desc.contains("room"));
+ assert!(desc.contains("made of wood"));
+ assert!(desc.contains("named Test Room"));
+ }
+}
diff --git a/crates/protoverse/src/lib.rs b/crates/protoverse/src/lib.rs
@@ -0,0 +1,272 @@
+//! Protoverse: S-expression parser for spatial world descriptions
+//!
+//! Parses protoverse `.space` format — an s-expression language for
+//! describing rooms, objects, and their attributes. Designed for
+//! progressive LOD: text descriptions, 2D maps, and 3D rendering
+//! can all be derived from the same source.
+//!
+//! # Example
+//!
+//! ```
+//! use protoverse::{parse, serialize, describe};
+//!
+//! let input = r#"(room (name "My Room") (shape rectangle) (width 10) (depth 8)
+//! (group
+//! (table (name "desk") (material "wood"))
+//! (chair (name "office chair"))))"#;
+//!
+//! let space = parse(input).unwrap();
+//! let description = describe(&space);
+//! let roundtrip = serialize(&space);
+//! ```
+
+pub mod ast;
+pub mod describe;
+pub mod parser;
+pub mod serializer;
+pub mod tokenizer;
+
+pub use ast::*;
+pub use describe::{describe, describe_from};
+pub use parser::parse;
+pub use serializer::{serialize, serialize_from};
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const SATOSHIS_CITADEL: &str = r#"(space (shape rectangle)
+ (condition "clean")
+ (condition "shiny")
+ (material "solid gold")
+ (name "Satoshi's Den")
+ (width 10) (depth 10) (height 100)
+ (group
+ (table (id welcome-desk)
+ (name "welcome desk")
+ (material "marble")
+ (condition "clean")
+ (condition "new")
+ (width 1) (depth 2) (height 1)
+ (location center)
+ (light (name "desk")))
+
+ (chair (id welcome-desk-chair)
+ (name "fancy"))
+
+ (chair (name "throne") (material "invisible"))
+
+ (light (location ceiling)
+ (name "ceiling")
+ (state off)
+ (shape circle))))"#;
+
+ const EXAMPLE_ROOM: &str = r#"(room (shape rectangle)
+ (condition "clean")
+ (material "gold")
+ (name "Satoshi's Den")
+ (width 10) (depth 10) (height 100)
+ (group
+ (table (id welcome-desk)
+ (name "welcome desk")
+ (material "marble")
+ (condition "new")
+ (width 1) (depth 2) (height 1)
+ (light (name "desk")))
+
+ (chair (id welcome-desk-chair)
+ (name "fancy"))
+
+ (light (location ceiling)
+ (name "ceiling")
+ (state off)
+ (shape circle))))"#;
+
+ #[test]
+ fn test_parse_satoshis_citadel() {
+ let space = parse(SATOSHIS_CITADEL).unwrap();
+
+ // Root is a space cell
+ let root = space.cell(space.root);
+ assert_eq!(root.cell_type, CellType::Space);
+ assert_eq!(space.name(space.root), Some("Satoshi's Den"));
+
+ // Root has 8 attributes
+ let attrs = space.attrs(space.root);
+ assert_eq!(attrs.len(), 8);
+
+ // Root has one child (group)
+ let root_children = space.children(space.root);
+ assert_eq!(root_children.len(), 1);
+ let group_id = root_children[0];
+ let group = space.cell(group_id);
+ assert_eq!(group.cell_type, CellType::Group);
+
+ // Group has 4 children: table, chair, chair, light
+ let group_children = space.children(group_id);
+ assert_eq!(group_children.len(), 4);
+
+ assert_eq!(
+ space.cell(group_children[0]).cell_type,
+ CellType::Object(ObjectType::Table)
+ );
+ assert_eq!(
+ space.cell(group_children[1]).cell_type,
+ CellType::Object(ObjectType::Chair)
+ );
+ assert_eq!(
+ space.cell(group_children[2]).cell_type,
+ CellType::Object(ObjectType::Chair)
+ );
+ assert_eq!(
+ space.cell(group_children[3]).cell_type,
+ CellType::Object(ObjectType::Light)
+ );
+
+ // Table has a child light
+ let table_children = space.children(group_children[0]);
+ assert_eq!(table_children.len(), 1);
+ assert_eq!(
+ space.cell(table_children[0]).cell_type,
+ CellType::Object(ObjectType::Light)
+ );
+ assert_eq!(space.name(table_children[0]), Some("desk"));
+
+ // Check object names
+ assert_eq!(space.name(group_children[0]), Some("welcome desk"));
+ assert_eq!(space.name(group_children[1]), Some("fancy"));
+ assert_eq!(space.name(group_children[2]), Some("throne"));
+ assert_eq!(space.name(group_children[3]), Some("ceiling"));
+ }
+
+ #[test]
+ fn test_parse_example_room() {
+ let space = parse(EXAMPLE_ROOM).unwrap();
+ let root = space.cell(space.root);
+ assert_eq!(root.cell_type, CellType::Room);
+ assert_eq!(space.name(space.root), Some("Satoshi's Den"));
+ }
+
+ #[test]
+ fn test_round_trip() {
+ let space1 = parse(SATOSHIS_CITADEL).unwrap();
+ let serialized = serialize(&space1);
+
+ // Re-parse the serialized output
+ let space2 = parse(&serialized).unwrap();
+
+ // Same structure
+ assert_eq!(space1.cells.len(), space2.cells.len());
+ assert_eq!(space1.attributes.len(), space2.attributes.len());
+ assert_eq!(space1.child_ids.len(), space2.child_ids.len());
+
+ // Same root type
+ assert_eq!(
+ space1.cell(space1.root).cell_type,
+ space2.cell(space2.root).cell_type
+ );
+
+ // Same name
+ assert_eq!(space1.name(space1.root), space2.name(space2.root));
+
+ // Same group children count
+ let g1 = space1.children(space1.root)[0];
+ let g2 = space2.children(space2.root)[0];
+ assert_eq!(space1.children(g1).len(), space2.children(g2).len());
+ }
+
+ #[test]
+ fn test_describe_satoshis_citadel() {
+ let space = parse(SATOSHIS_CITADEL).unwrap();
+ let desc = describe(&space);
+
+ // Check the area description
+ assert!(desc.contains("There is a(n)"));
+ assert!(desc.contains("clean"));
+ assert!(desc.contains("shiny"));
+ assert!(desc.contains("rectangular"));
+ assert!(desc.contains("space"));
+ assert!(desc.contains("made of solid gold"));
+ assert!(desc.contains("named Satoshi's Den"));
+
+ // Check the group description
+ assert!(desc.contains("It contains"));
+ assert!(desc.contains("four"));
+ assert!(desc.contains("objects:"));
+ assert!(desc.contains("welcome desk table"));
+ assert!(desc.contains("fancy chair"));
+ assert!(desc.contains("throne chair"));
+ assert!(desc.contains("ceiling light"));
+
+ // Exact match against C reference output
+ let expected = "There is a(n) clean and shiny rectangular space made of solid gold named Satoshi's Den.\nIt contains four objects: a welcome desk table, fancy chair, throne chair and ceiling light.\n";
+ assert_eq!(desc, expected);
+ }
+
+ #[test]
+ fn test_parse_real_space_file() {
+ // Parse the actual .space file from the protoverse repo
+ let path = "/home/jb55/src/c/protoverse/satoshis-citadel.space";
+ if let Ok(content) = std::fs::read_to_string(path) {
+ let space = parse(&content).unwrap();
+ assert_eq!(space.cell(space.root).cell_type, CellType::Space);
+ assert_eq!(space.name(space.root), Some("Satoshi's Den"));
+
+ // Verify round-trip
+ let serialized = serialize(&space);
+ let space2 = parse(&serialized).unwrap();
+ assert_eq!(space.cells.len(), space2.cells.len());
+ }
+ }
+
+ #[test]
+ fn test_parent_references() {
+ let space = parse(SATOSHIS_CITADEL).unwrap();
+
+ // Root has no parent
+ assert_eq!(space.cell(space.root).parent, None);
+
+ // Group's parent is root
+ let group_id = space.children(space.root)[0];
+ assert_eq!(space.cell(group_id).parent, Some(space.root));
+
+ // Table's parent is group
+ let table_id = space.children(group_id)[0];
+ assert_eq!(space.cell(table_id).parent, Some(group_id));
+
+ // Desk light's parent is table
+ let light_id = space.children(table_id)[0];
+ assert_eq!(space.cell(light_id).parent, Some(table_id));
+ }
+
+ #[test]
+ fn test_attribute_details() {
+ let space = parse(SATOSHIS_CITADEL).unwrap();
+
+ // Check root shape
+ let shape = space
+ .find_attr(space.root, |a| matches!(a, Attribute::Shape(_)))
+ .unwrap();
+ assert_eq!(*shape, Attribute::Shape(Shape::Rectangle));
+
+ // Check root dimensions
+ let width = space
+ .find_attr(space.root, |a| matches!(a, Attribute::Width(_)))
+ .unwrap();
+ assert_eq!(*width, Attribute::Width(10.0));
+
+ // Check table material
+ let table_id = space.children(space.children(space.root)[0])[0];
+ let material = space
+ .find_attr(table_id, |a| matches!(a, Attribute::Material(_)))
+ .unwrap();
+ assert_eq!(*material, Attribute::Material("marble".to_string()));
+
+ // Check light state
+ let light_id = space.children(space.children(space.root)[0])[3];
+ let state = space
+ .find_attr(light_id, |a| matches!(a, Attribute::State(_)))
+ .unwrap();
+ assert_eq!(*state, Attribute::State(CellState::Off));
+ }
+}
diff --git a/crates/protoverse/src/parser.rs b/crates/protoverse/src/parser.rs
@@ -0,0 +1,526 @@
+use crate::ast::*;
+use crate::tokenizer::{tokenize, Token};
+use std::fmt;
+
+#[derive(Debug)]
+pub struct ParseError {
+ pub msg: String,
+}
+
+impl fmt::Display for ParseError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "parse error: {}", self.msg)
+ }
+}
+
+impl std::error::Error for ParseError {}
+
+/// Parse an s-expression string into a Space.
+pub fn parse(input: &str) -> Result<Space, ParseError> {
+ let tokens = tokenize(input).map_err(|e| ParseError {
+ msg: format!("tokenization failed: {}", e),
+ })?;
+
+ let mut parser = Parser {
+ tokens,
+ pos: 0,
+ cells: Vec::new(),
+ attributes: Vec::new(),
+ child_ids: Vec::new(),
+ };
+
+ let root = parser.parse_cell().ok_or_else(|| ParseError {
+ msg: "failed to parse root cell".into(),
+ })?;
+
+ Ok(Space {
+ cells: parser.cells,
+ attributes: parser.attributes,
+ child_ids: parser.child_ids,
+ root,
+ })
+}
+
+struct Parser<'a> {
+ tokens: Vec<Token<'a>>,
+ pos: usize,
+ cells: Vec<Cell>,
+ attributes: Vec<Attribute>,
+ child_ids: Vec<CellId>,
+}
+
+#[derive(Clone)]
+struct Checkpoint {
+ pos: usize,
+ cells_len: usize,
+ attrs_len: usize,
+ child_ids_len: usize,
+}
+
+impl<'a> Parser<'a> {
+ fn checkpoint(&self) -> Checkpoint {
+ Checkpoint {
+ pos: self.pos,
+ cells_len: self.cells.len(),
+ attrs_len: self.attributes.len(),
+ child_ids_len: self.child_ids.len(),
+ }
+ }
+
+ fn restore(&mut self, cp: Checkpoint) {
+ self.pos = cp.pos;
+ self.cells.truncate(cp.cells_len);
+ self.attributes.truncate(cp.attrs_len);
+ self.child_ids.truncate(cp.child_ids_len);
+ }
+
+ fn peek(&self) -> Option<&Token<'a>> {
+ self.tokens.get(self.pos)
+ }
+
+ fn eat_open(&mut self) -> bool {
+ if matches!(self.peek(), Some(Token::Open)) {
+ self.pos += 1;
+ true
+ } else {
+ false
+ }
+ }
+
+ fn eat_close(&mut self) -> bool {
+ if matches!(self.peek(), Some(Token::Close)) {
+ self.pos += 1;
+ true
+ } else {
+ false
+ }
+ }
+
+ fn eat_symbol_match(&mut self, expected: &str) -> bool {
+ if let Some(Token::Symbol(s)) = self.peek() {
+ if *s == expected {
+ self.pos += 1;
+ return true;
+ }
+ }
+ false
+ }
+
+ fn eat_symbol(&mut self) -> Option<&'a str> {
+ if let Some(Token::Symbol(s)) = self.peek() {
+ let s = *s;
+ self.pos += 1;
+ Some(s)
+ } else {
+ None
+ }
+ }
+
+ fn eat_string(&mut self) -> Option<&'a str> {
+ if let Some(Token::Str(s)) = self.peek() {
+ let s = *s;
+ self.pos += 1;
+ Some(s)
+ } else {
+ None
+ }
+ }
+
+ fn eat_number(&mut self) -> Option<f64> {
+ if let Some(Token::Number(s)) = self.peek() {
+ if let Ok(n) = s.parse::<f64>() {
+ self.pos += 1;
+ return Some(n);
+ }
+ }
+ None
+ }
+
+ fn push_cell(&mut self, cell: Cell) -> CellId {
+ let id = CellId(self.cells.len() as u32);
+ self.cells.push(cell);
+ id
+ }
+
+ // --- Attribute parsing ---
+
+ fn try_parse_attribute(&mut self) -> Option<Attribute> {
+ let cp = self.checkpoint();
+
+ if !self.eat_open() {
+ return None;
+ }
+
+ let sym = match self.eat_symbol() {
+ Some(s) => s,
+ None => {
+ self.restore(cp);
+ return None;
+ }
+ };
+
+ let result = match sym {
+ "shape" => self.eat_symbol().and_then(|s| {
+ let shape = match s {
+ "rectangle" => Shape::Rectangle,
+ "circle" => Shape::Circle,
+ "square" => Shape::Square,
+ _ => return None,
+ };
+ Some(Attribute::Shape(shape))
+ }),
+ "id" => self.eat_symbol().map(|s| Attribute::Id(s.to_string())),
+ "name" => self.eat_string().map(|s| Attribute::Name(s.to_string())),
+ "material" => self
+ .eat_string()
+ .map(|s| Attribute::Material(s.to_string())),
+ "condition" => self
+ .eat_string()
+ .map(|s| Attribute::Condition(s.to_string())),
+ "location" => self.eat_symbol().and_then(|s| {
+ let loc = match s {
+ "center" => Location::Center,
+ "floor" => Location::Floor,
+ "ceiling" => Location::Ceiling,
+ "top-of" => {
+ let id = self.eat_symbol()?;
+ Location::TopOf(id.to_string())
+ }
+ "near" => {
+ let id = self.eat_symbol()?;
+ Location::Near(id.to_string())
+ }
+ other => Location::Custom(other.to_string()),
+ };
+ Some(Attribute::Location(loc))
+ }),
+ "state" => self.eat_symbol().and_then(|s| {
+ let state = match s {
+ "on" => CellState::On,
+ "off" => CellState::Off,
+ "sleeping" => CellState::Sleeping,
+ _ => return None,
+ };
+ Some(Attribute::State(state))
+ }),
+ "type" => self.eat_symbol().map(|s| Attribute::Type(s.to_string())),
+ "width" => self.eat_number().map(Attribute::Width),
+ "height" => self.eat_number().map(Attribute::Height),
+ "depth" => self.eat_number().map(Attribute::Depth),
+ "position" => {
+ let x = self.eat_number();
+ let y = self.eat_number();
+ let z = self.eat_number();
+ match (x, y, z) {
+ (Some(x), Some(y), Some(z)) => Some(Attribute::Position(x, y, z)),
+ _ => None,
+ }
+ }
+ "model-url" => self
+ .eat_string()
+ .map(|s| Attribute::ModelUrl(s.to_string())),
+ _ => None,
+ };
+
+ match result {
+ Some(attr) => {
+ if self.eat_close() {
+ Some(attr)
+ } else {
+ self.restore(cp);
+ None
+ }
+ }
+ None => {
+ self.restore(cp);
+ None
+ }
+ }
+ }
+
+ /// Parse zero or more attributes, returning the count.
+ /// Attributes are pushed contiguously into self.attributes.
+ fn parse_attributes(&mut self) -> u16 {
+ let mut count = 0u16;
+ while let Some(attr) = self.try_parse_attribute() {
+ self.attributes.push(attr);
+ count += 1;
+ }
+ count
+ }
+
+ // --- Cell parsing ---
+
+ /// Parse attributes and an optional child cell (for room/space/object).
+ fn parse_cell_attrs(&mut self, cell_type: CellType) -> Option<CellId> {
+ let first_attr = self.attributes.len() as u32;
+ let attr_count = self.parse_attributes();
+
+ // Parse optional child cell — recursion may push to child_ids
+ let opt_child = self.parse_cell();
+
+ // Capture first_child AFTER recursion so nested children don't interleave
+ let first_child = self.child_ids.len() as u32;
+ let child_count;
+ if let Some(child_id) = opt_child {
+ self.child_ids.push(child_id);
+ child_count = 1u16;
+ } else {
+ child_count = 0;
+ }
+
+ let id = self.push_cell(Cell {
+ cell_type,
+ first_attr,
+ attr_count,
+ first_child,
+ child_count,
+ parent: None,
+ });
+
+ // Set parent on children
+ for i in 0..child_count {
+ let child_id = self.child_ids[(first_child + i as u32) as usize];
+ self.cells[child_id.0 as usize].parent = Some(id);
+ }
+
+ Some(id)
+ }
+
+ fn try_parse_named_cell(&mut self, name: &str, cell_type: CellType) -> Option<CellId> {
+ let cp = self.checkpoint();
+
+ if !self.eat_symbol_match(name) {
+ self.restore(cp);
+ return None;
+ }
+
+ match self.parse_cell_attrs(cell_type) {
+ Some(id) => Some(id),
+ None => {
+ self.restore(cp);
+ None
+ }
+ }
+ }
+
+ fn try_parse_room(&mut self) -> Option<CellId> {
+ self.try_parse_named_cell("room", CellType::Room)
+ }
+
+ fn try_parse_space(&mut self) -> Option<CellId> {
+ self.try_parse_named_cell("space", CellType::Space)
+ }
+
+ fn try_parse_group(&mut self) -> Option<CellId> {
+ let cp = self.checkpoint();
+
+ if !self.eat_symbol_match("group") {
+ self.restore(cp);
+ return None;
+ }
+
+ // Collect children — each parse_cell may recursively push to child_ids,
+ // so we collect CellIds first and append ours after recursion completes
+ let mut collected = Vec::new();
+ while let Some(child_id) = self.parse_cell() {
+ collected.push(child_id);
+ }
+
+ if collected.is_empty() {
+ self.restore(cp);
+ return None;
+ }
+
+ // Now append our children contiguously
+ let first_child = self.child_ids.len() as u32;
+ let child_count = collected.len() as u16;
+ self.child_ids.extend_from_slice(&collected);
+
+ let id = self.push_cell(Cell {
+ cell_type: CellType::Group,
+ first_attr: 0,
+ attr_count: 0,
+ first_child,
+ child_count,
+ parent: None,
+ });
+
+ // Set parent on children
+ for i in 0..child_count {
+ let child_id = self.child_ids[(first_child + i as u32) as usize];
+ self.cells[child_id.0 as usize].parent = Some(id);
+ }
+
+ Some(id)
+ }
+
+ fn try_parse_object(&mut self) -> Option<CellId> {
+ let cp = self.checkpoint();
+
+ let sym = self.eat_symbol()?;
+
+ let obj_type = match sym {
+ "table" => ObjectType::Table,
+ "chair" => ObjectType::Chair,
+ "door" => ObjectType::Door,
+ "light" => ObjectType::Light,
+ _ => ObjectType::Custom(sym.to_string()),
+ };
+
+ match self.parse_cell_attrs(CellType::Object(obj_type)) {
+ Some(id) => Some(id),
+ None => {
+ self.restore(cp);
+ None
+ }
+ }
+ }
+
+ fn parse_cell(&mut self) -> Option<CellId> {
+ let cp = self.checkpoint();
+
+ if !self.eat_open() {
+ return None;
+ }
+
+ // Try each cell type
+ let id = self
+ .try_parse_group()
+ .or_else(|| self.try_parse_room())
+ .or_else(|| self.try_parse_space())
+ .or_else(|| self.try_parse_object());
+
+ match id {
+ Some(id) => {
+ if self.eat_close() {
+ Some(id)
+ } else {
+ self.restore(cp);
+ None
+ }
+ }
+ None => {
+ self.restore(cp);
+ None
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_simple_room() {
+ let space = parse("(room (name \"Test Room\") (width 10))").unwrap();
+ assert_eq!(space.cells.len(), 1);
+ let root = space.cell(space.root);
+ assert_eq!(root.cell_type, CellType::Room);
+ assert_eq!(root.attr_count, 2);
+ assert_eq!(space.name(space.root), Some("Test Room"));
+ }
+
+ #[test]
+ fn test_parse_object_with_child() {
+ let input = "(table (name \"desk\") (light (name \"lamp\")))";
+ let space = parse(input).unwrap();
+ // light is cell 0, table is cell 1
+ assert_eq!(space.cells.len(), 2);
+ let root = space.cell(space.root);
+ assert_eq!(root.cell_type, CellType::Object(ObjectType::Table));
+ assert_eq!(root.child_count, 1);
+
+ let children = space.children(space.root);
+ let child = space.cell(children[0]);
+ assert_eq!(child.cell_type, CellType::Object(ObjectType::Light));
+ assert_eq!(space.name(children[0]), Some("lamp"));
+ }
+
+ #[test]
+ fn test_parse_group() {
+ let input = "(room (group (table (name \"t1\")) (chair (name \"c1\"))))";
+ let space = parse(input).unwrap();
+ // table=0, chair=1, group=2, room=3
+ assert_eq!(space.cells.len(), 4);
+ let root = space.cell(space.root);
+ assert_eq!(root.cell_type, CellType::Room);
+
+ // room has one child (group)
+ let room_children = space.children(space.root);
+ assert_eq!(room_children.len(), 1);
+ let group = space.cell(room_children[0]);
+ assert_eq!(group.cell_type, CellType::Group);
+
+ // group has two children
+ let group_children = space.children(room_children[0]);
+ assert_eq!(group_children.len(), 2);
+ assert_eq!(
+ space.cell(group_children[0]).cell_type,
+ CellType::Object(ObjectType::Table)
+ );
+ assert_eq!(
+ space.cell(group_children[1]).cell_type,
+ CellType::Object(ObjectType::Chair)
+ );
+ }
+
+ #[test]
+ fn test_parse_location_variants() {
+ // Simple locations
+ let space = parse("(table (location center))").unwrap();
+ assert_eq!(space.location(space.root), Some(&Location::Center));
+
+ let space = parse("(table (location floor))").unwrap();
+ assert_eq!(space.location(space.root), Some(&Location::Floor));
+
+ let space = parse("(table (location ceiling))").unwrap();
+ assert_eq!(space.location(space.root), Some(&Location::Ceiling));
+
+ // Relational locations
+ let space = parse("(prop (location top-of obj1))").unwrap();
+ assert_eq!(
+ space.location(space.root),
+ Some(&Location::TopOf("obj1".to_string()))
+ );
+
+ let space = parse("(chair (location near desk))").unwrap();
+ assert_eq!(
+ space.location(space.root),
+ Some(&Location::Near("desk".to_string()))
+ );
+
+ // Custom/unknown location
+ let space = parse("(light (location somewhere))").unwrap();
+ assert_eq!(
+ space.location(space.root),
+ Some(&Location::Custom("somewhere".to_string()))
+ );
+ }
+
+ #[test]
+ fn test_location_roundtrip() {
+ use crate::serializer::serialize;
+
+ let input = r#"(room (group (table (id obj1) (position 0 0 0)) (prop (id obj2) (location top-of obj1))))"#;
+ let space1 = parse(input).unwrap();
+ let serialized = serialize(&space1);
+ let space2 = parse(&serialized).unwrap();
+
+ // Find obj2 in both
+ let group1 = space1.children(space1.root)[0];
+ let obj2_1 = space1.children(group1)[1];
+ assert_eq!(
+ space1.location(obj2_1),
+ Some(&Location::TopOf("obj1".to_string()))
+ );
+
+ let group2 = space2.children(space2.root)[0];
+ let obj2_2 = space2.children(group2)[1];
+ assert_eq!(
+ space2.location(obj2_2),
+ Some(&Location::TopOf("obj1".to_string()))
+ );
+ }
+}
diff --git a/crates/protoverse/src/serializer.rs b/crates/protoverse/src/serializer.rs
@@ -0,0 +1,120 @@
+use crate::ast::*;
+use std::fmt::Write;
+
+/// Serialize a Space back to s-expression format.
+pub fn serialize(space: &Space) -> String {
+ serialize_from(space, space.root)
+}
+
+/// Serialize a subtree starting from a specific cell.
+pub fn serialize_from(space: &Space, root: CellId) -> String {
+ let mut out = String::new();
+ write_cell(space, root, 0, &mut out);
+ out
+}
+
+fn format_number(n: f64) -> String {
+ if n == n.floor() && n.abs() < i64::MAX as f64 {
+ format!("{}", n as i64)
+ } else {
+ format!("{}", n)
+ }
+}
+
+fn write_cell(space: &Space, id: CellId, indent: usize, out: &mut String) {
+ let cell = space.cell(id);
+ let pad = " ".repeat(indent);
+ let inner_pad = " ".repeat(indent + 1);
+
+ out.push('(');
+ out.push_str(&cell.cell_type.to_string());
+
+ // Attributes
+ let attrs = space.attrs(id);
+ for attr in attrs {
+ let _ = write!(out, "\n{}", inner_pad);
+ write_attr(attr, out);
+ }
+
+ // Children
+ let children = space.children(id);
+ for &child_id in children {
+ let _ = write!(out, "\n{}", inner_pad);
+ write_cell(space, child_id, indent + 1, out);
+ }
+
+ // Closing paren on same line if no attrs/children, else on new line
+ if !attrs.is_empty() || !children.is_empty() {
+ // For readability, close on the last line
+ out.push(')');
+ } else {
+ out.push(')');
+ }
+
+ let _ = pad; // used above via inner_pad derivation
+}
+
+fn write_attr(attr: &Attribute, out: &mut String) {
+ match attr {
+ Attribute::Shape(s) => {
+ let _ = write!(out, "(shape {})", s);
+ }
+ Attribute::Id(s) => {
+ let _ = write!(out, "(id {})", s);
+ }
+ Attribute::Name(s) => {
+ let _ = write!(out, "(name \"{}\")", s);
+ }
+ Attribute::Material(s) => {
+ let _ = write!(out, "(material \"{}\")", s);
+ }
+ Attribute::Condition(s) => {
+ let _ = write!(out, "(condition \"{}\")", s);
+ }
+ Attribute::Location(loc) => {
+ let _ = write!(out, "(location {})", loc);
+ }
+ Attribute::State(s) => {
+ let _ = write!(out, "(state {})", s);
+ }
+ Attribute::Type(s) => {
+ let _ = write!(out, "(type {})", s);
+ }
+ Attribute::Width(n) => {
+ let _ = write!(out, "(width {})", format_number(*n));
+ }
+ Attribute::Height(n) => {
+ let _ = write!(out, "(height {})", format_number(*n));
+ }
+ Attribute::Depth(n) => {
+ let _ = write!(out, "(depth {})", format_number(*n));
+ }
+ Attribute::Position(x, y, z) => {
+ let _ = write!(
+ out,
+ "(position {} {} {})",
+ format_number(*x),
+ format_number(*y),
+ format_number(*z)
+ );
+ }
+ Attribute::ModelUrl(s) => {
+ let _ = write!(out, "(model-url \"{}\")", s);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::parser::parse;
+
+ #[test]
+ fn test_serialize_simple() {
+ let space = parse("(room (name \"Test\") (width 10))").unwrap();
+ let output = serialize(&space);
+ assert!(output.contains("(room"));
+ assert!(output.contains("(name \"Test\")"));
+ assert!(output.contains("(width 10)"));
+ }
+}
diff --git a/crates/protoverse/src/tokenizer.rs b/crates/protoverse/src/tokenizer.rs
@@ -0,0 +1,230 @@
+use std::fmt;
+
+/// A token from the s-expression tokenizer.
+/// String references are zero-copy slices into the input.
+#[derive(Debug, Clone, PartialEq)]
+pub enum Token<'a> {
+ Open,
+ Close,
+ Symbol(&'a str),
+ Str(&'a str),
+ Number(&'a str),
+}
+
+#[derive(Debug)]
+pub struct TokenError {
+ pub msg: String,
+ pub pos: usize,
+}
+
+impl fmt::Display for TokenError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "token error at position {}: {}", self.pos, self.msg)
+ }
+}
+
+impl std::error::Error for TokenError {}
+
+fn is_symbol_start(c: u8) -> bool {
+ c.is_ascii_lowercase()
+}
+
+fn is_symbol_char(c: u8) -> bool {
+ c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-' || c == b'_'
+}
+
+fn scan_symbol(input: &[u8], start: usize) -> Result<usize, TokenError> {
+ if start >= input.len() || !is_symbol_start(input[start]) {
+ return Err(TokenError {
+ msg: "symbol must start with a-z".into(),
+ pos: start,
+ });
+ }
+ let mut end = start + 1;
+ while end < input.len() {
+ let c = input[end];
+ if c.is_ascii_whitespace() || c == b')' || c == b'(' {
+ break;
+ }
+ if !is_symbol_char(c) {
+ return Err(TokenError {
+ msg: format!("invalid symbol character '{}'", c as char),
+ pos: end,
+ });
+ }
+ end += 1;
+ }
+ Ok(end)
+}
+
+fn scan_number(input: &[u8], start: usize) -> Result<usize, TokenError> {
+ if start >= input.len() {
+ return Err(TokenError {
+ msg: "unexpected end of input in number".into(),
+ pos: start,
+ });
+ }
+ let first = input[start];
+ if !first.is_ascii_digit() && first != b'-' {
+ return Err(TokenError {
+ msg: "number must start with 0-9 or -".into(),
+ pos: start,
+ });
+ }
+ let mut end = start + 1;
+ while end < input.len() {
+ let c = input[end];
+ if c.is_ascii_whitespace() || c == b')' || c == b'(' {
+ break;
+ }
+ if !c.is_ascii_digit() && c != b'.' {
+ return Err(TokenError {
+ msg: format!("invalid number character '{}'", c as char),
+ pos: end,
+ });
+ }
+ end += 1;
+ }
+ Ok(end)
+}
+
+fn scan_string(input: &[u8], start: usize) -> Result<(usize, usize), TokenError> {
+ // start should point at the opening quote
+ if start >= input.len() || input[start] != b'"' {
+ return Err(TokenError {
+ msg: "string must start with '\"'".into(),
+ pos: start,
+ });
+ }
+ let content_start = start + 1;
+ let mut i = content_start;
+ while i < input.len() {
+ if input[i] == b'\\' {
+ i += 2; // skip escaped char
+ continue;
+ }
+ if input[i] == b'"' {
+ return Ok((content_start, i)); // i points at closing quote
+ }
+ i += 1;
+ }
+ Err(TokenError {
+ msg: "unterminated string".into(),
+ pos: start,
+ })
+}
+
+/// Tokenize an s-expression input string into a sequence of tokens.
+/// Token string/symbol/number values are zero-copy references into the input.
+pub fn tokenize(input: &str) -> Result<Vec<Token<'_>>, TokenError> {
+ let mut tokens = Vec::new();
+ let bytes = input.as_bytes();
+ let mut i = 0;
+
+ while i < bytes.len() {
+ let c = bytes[i];
+
+ if c.is_ascii_whitespace() {
+ i += 1;
+ continue;
+ }
+
+ match c {
+ b'(' => {
+ tokens.push(Token::Open);
+ i += 1;
+ }
+ b')' => {
+ tokens.push(Token::Close);
+ i += 1;
+ }
+ b'"' => {
+ let (content_start, content_end) = scan_string(bytes, i)?;
+ tokens.push(Token::Str(&input[content_start..content_end]));
+ i = content_end + 1; // skip closing quote
+ }
+ b'a'..=b'z' => {
+ let end = scan_symbol(bytes, i)?;
+ tokens.push(Token::Symbol(&input[i..end]));
+ i = end;
+ }
+ b'0'..=b'9' | b'-' => {
+ let end = scan_number(bytes, i)?;
+ tokens.push(Token::Number(&input[i..end]));
+ i = end;
+ }
+ _ => {
+ return Err(TokenError {
+ msg: format!("unexpected character '{}'", c as char),
+ pos: i,
+ });
+ }
+ }
+ }
+
+ Ok(tokens)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_tokenize_simple() {
+ let tokens = tokenize("(room (name \"hello\"))").unwrap();
+ assert_eq!(
+ tokens,
+ vec![
+ Token::Open,
+ Token::Symbol("room"),
+ Token::Open,
+ Token::Symbol("name"),
+ Token::Str("hello"),
+ Token::Close,
+ Token::Close,
+ ]
+ );
+ }
+
+ #[test]
+ fn test_tokenize_number() {
+ let tokens = tokenize("(width 10)").unwrap();
+ assert_eq!(
+ tokens,
+ vec![
+ Token::Open,
+ Token::Symbol("width"),
+ Token::Number("10"),
+ Token::Close,
+ ]
+ );
+ }
+
+ #[test]
+ fn test_tokenize_symbol_with_dash() {
+ let tokens = tokenize("(id welcome-desk)").unwrap();
+ assert_eq!(
+ tokens,
+ vec![
+ Token::Open,
+ Token::Symbol("id"),
+ Token::Symbol("welcome-desk"),
+ Token::Close,
+ ]
+ );
+ }
+
+ #[test]
+ fn test_tokenize_negative_number() {
+ let tokens = tokenize("(height -5)").unwrap();
+ assert_eq!(
+ tokens,
+ vec![
+ Token::Open,
+ Token::Symbol("height"),
+ Token::Number("-5"),
+ Token::Close,
+ ]
+ );
+ }
+}