notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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:
MCargo.lock | 7++++++-
MCargo.toml | 3++-
Mcrates/notedeck/src/app.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/notedeck/src/args.rs | 16+++++++++++++++-
Acrates/notedeck/src/compact.rs | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck/src/lib.rs | 1+
Mcrates/notedeck_columns/src/nav.rs | 30+++++++++++++++---------------
Mcrates/notedeck_columns/src/timeline/mod.rs | 4+++-
Mcrates/notedeck_columns/src/ui/settings.rs | 219++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/notedeck_columns/src/view_state.rs | 4++++
Mcrates/notedeck_dave/src/backend/claude.rs | 12++++--------
Mcrates/notedeck_dave/src/lib.rs | 70+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/notedeck_dave/src/messages.rs | 11+++++++++++
Mcrates/notedeck_dave/src/session.rs | 42++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/session_events.rs | 1+
Mcrates/notedeck_dave/src/session_loader.rs | 17++++++++++++-----
Mcrates/notedeck_dave/src/ui/dave.rs | 19+++++++++++++++++++
Mcrates/notedeck_dave/src/ui/mod.rs | 20++++++++++++++++++++
Mcrates/notedeck_nostrverse/Cargo.toml | 1+
Acrates/notedeck_nostrverse/src/convert.rs | 404+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_nostrverse/src/lib.rs | 516++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Acrates/notedeck_nostrverse/src/nostr_events.rs | 233+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_nostrverse/src/presence.rs | 262+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_nostrverse/src/room_state.rs | 77++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mcrates/notedeck_nostrverse/src/room_view.rs | 265++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Acrates/notedeck_nostrverse/src/subscriptions.rs | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/protoverse/Cargo.toml | 5+++++
Acrates/protoverse/src/ast.rs | 250+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/protoverse/src/describe.rs | 204+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/protoverse/src/lib.rs | 272+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/protoverse/src/parser.rs | 526+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/protoverse/src/serializer.rs | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/protoverse/src/tokenizer.rs | 230+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 &notes { + 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 &notes { + 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(&note) 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(&note).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(&note), 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(&note), Some("37555:abc123:my-room")); + + let parsed_pos = parse_presence_position(&note).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(&note, "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 &notes { + // 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, + ] + ); + } +}