commit ed31aa61534a66de02d6bb4c06b6fa4edcdb5af1
parent 2531541424c7535792699db72882a8fab72d4332
Author: William Casarin <jb55@jb55.com>
Date: Sat, 21 Feb 2026 19:54:17 -0800
Merge branch 'nostrverse', remote-tracking branch 'github/pr/1300'
Diffstat:
11 files changed, 943 insertions(+), 10 deletions(-)
diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl
@@ -0,0 +1,24 @@
+{"id":"notedeck-0mh","title":"Remote NIP-50 search","description":"GitHub #1110: Implement remote search using NIP-50 protocol. See https://github.com/damus-io/notedeck/issues/1110","status":"open","priority":2,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:50:41.013086749-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:50:41.013086749-08:00","labels":["columns"]}
+{"id":"notedeck-27x","title":"Auto-steal focus should return to original session","description":"In crates/notedeck_dave, when an agent steals focus from another agent to ask a question, I want it to focus back to where it was after the interaction completes.","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:38:44.526107651-08:00","created_by":"William Casarin","updated_at":"2026-01-30T22:35:00.825936422-08:00","closed_at":"2026-01-30T22:35:00.825936422-08:00","close_reason":"Auto-steal focus now returns to original session after interaction completes","labels":["dave"]}
+{"id":"notedeck-2yj","title":"Profile search","description":"GitHub #1111: Extend search functionality to include profile lookups. See https://github.com/damus-io/notedeck/issues/1111","status":"open","priority":2,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:50:36.780164431-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:50:36.780164431-08:00","labels":["columns"]}
+{"id":"notedeck-3ns","title":"Approve/deny view text not wrapping - goes off screen","description":"The approve/deny view for tool calls doesn't wrap text properly. Long descriptions go all the way off the screen, making them unreadable.","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:52:24.878602874-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:52:24.878602874-08:00","labels":["dave"]}
+{"id":"notedeck-4no","title":"Follow packs show blank profiles","description":"GitHub #1107: Some profiles in follow packs display blank. See https://github.com/damus-io/notedeck/issues/1107","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:46:44.374295002-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:46:44.374295002-08:00","labels":["columns"]}
+{"id":"notedeck-84e","title":"Link previews (OpenGraph)","description":"GitHub #992: Display link previews using OpenGraph metadata. See https://github.com/damus-io/notedeck/issues/992","status":"open","priority":3,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:51:46.117752018-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:51:46.117752018-08:00","labels":["columns"]}
+{"id":"notedeck-ajn","title":"Contact lists aren't updated periodically","description":"GitHub #575: Contact lists don't refresh periodically as they should. See https://github.com/damus-io/notedeck/issues/575","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:49:46.741346469-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:49:46.741346469-08:00","labels":["columns"]}
+{"id":"notedeck-azp","title":"Column requires opening app twice to show new notes","description":"GitHub #780: Columns require opening notedeck twice to display new notes on initial launch. See https://github.com/damus-io/notedeck/issues/780","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:49:39.093911034-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:49:39.093911034-08:00","labels":["columns"]}
+{"id":"notedeck-bce","title":"Handle ExitPlanMode tool call","description":"Handle ExitPlanMode which simply exits plan mode. Claude-code sends this when it's done its planning phase.","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:40:17.311242243-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:38:19.839039601-08:00","closed_at":"2026-01-30T12:38:19.839039601-08:00","close_reason":"Implemented ExitPlanMode UI with Approve/Reject buttons. When approved, exits plan mode and allows the tool call. UI shows PLAN badge with 'Plan ready for approval' message.","labels":["dave"]}
+{"id":"notedeck-c3p","title":"Implement multiline message composer (Signal-style)","description":"Replaced singleline TextEdit with multiline, using Signal-style keybindings: Enter to send, Shift+Enter for newline. Based on existing dave.rs implementation.","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T12:32:45.563930191-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:33:07.061369966-08:00","closed_at":"2026-01-30T12:33:07.061369966-08:00","close_reason":"Implemented: Changed TextEdit from singleline to multiline with Signal-style keybindings (Enter=send, Shift+Enter=newline) in convo.rs"}
+{"id":"notedeck-cf0","title":"Preserve edit view after approval/denial","description":"When approving or denying an edit, keep the diff visible instead of making it disappear. Allows reviewing what was changed even after responding.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:41:04.789975491-08:00","created_by":"William Casarin","updated_at":"2026-01-30T11:41:04.789975491-08:00","labels":["dave"]}
+{"id":"notedeck-dx2","title":"Multi column image reply bug","description":"GitHub #1104: Images in multi-column replies appear in wrong column. See https://github.com/damus-io/notedeck/issues/1104","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:47:15.806023697-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:47:15.806023697-08:00","labels":["columns"]}
+{"id":"notedeck-fs8","title":"Ctrl+P not dropping from NeedsInput to Done","description":"Commit c6a96d8dbfef is supposed to enable dropping from NeedsInput to Done with Ctrl+P, but it's not working. The commit message says Ctrl+P should navigate backward within a priority group and drop to the next lower priority level when at the first item (NeedsInput → Error → Done).","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:42:27.63658035-08:00","created_by":"William Casarin","updated_at":"2026-01-30T11:57:51.25992885-08:00","closed_at":"2026-01-30T11:57:51.25992885-08:00","close_reason":"Fixed swapped keybindings in keybindings.rs:82-90 - Ctrl+N now returns FocusQueueNext (higher priority) and Ctrl+P returns FocusQueuePrev (lower priority)","labels":["dave"]}
+{"id":"notedeck-gj0","title":"Timeline carousel does not work","description":"GitHub #1006: Image carousel swiping functionality needs implementation or repair for navigating images. See https://github.com/damus-io/notedeck/issues/1006","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:47:49.42410683-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:47:49.42410683-08:00","labels":["columns"]}
+{"id":"notedeck-h9s","title":"Note not ingesting when sending locally (offline)","description":"GitHub #1050: Offline notes fail to sync when device reconnects; messages aren't appearing even after network restored. See https://github.com/damus-io/notedeck/issues/1050","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:47:34.188084601-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:47:34.188084601-08:00","labels":["columns"]}
+{"id":"notedeck-hav","title":"Add auto-accept mode for agent tool calls","description":"Add a toggle that automatically approves agent tool calls without requiring manual confirmation. Useful for trusted tasks or batch mode. Could be global or per-agent.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:40:00.952701242-08:00","created_by":"William Casarin","updated_at":"2026-01-30T11:40:00.952701242-08:00","labels":["dave"]}
+{"id":"notedeck-i40","title":"Quoted note target changes depending on wide or selected mode","description":"GitHub #1117: Quoted note references inconsistent based on view mode. See https://github.com/damus-io/notedeck/issues/1117","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:45:42.264025111-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:45:42.264025111-08:00","labels":["columns"]}
+{"id":"notedeck-j8c","title":"Chat sidebar should show last message from user or AI","description":"Chat sidebar text should show the user's or AI's last message, not our last message.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:39:11.482262998-08:00","created_by":"William Casarin","updated_at":"2026-01-30T11:39:11.482262998-08:00","labels":["dave"]}
+{"id":"notedeck-kpa","title":"Zap notification","description":"GitHub #1037: Notify users when zapped, showing zap amount, sender, and zapped content details. See https://github.com/damus-io/notedeck/issues/1037","status":"open","priority":3,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:51:29.181749931-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:51:29.181749931-08:00","labels":["columns"]}
+{"id":"notedeck-p1n","title":"NIP-05 validation not working as intended","description":"GitHub #1274: NIP-05 validation feature is malfunctioning. See https://github.com/damus-io/notedeck/issues/1274","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:38:30.276030933-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:38:30.276030933-08:00","labels":["columns"]}
+{"id":"notedeck-pj7","title":"Like button not visible in light theme","description":"GitHub #1246: Like button rendering visibility issue when switching to light theme mode. See https://github.com/damus-io/notedeck/issues/1246","status":"closed","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:41:16.601075159-08:00","created_by":"William Casarin","updated_at":"2026-01-30T13:38:50.529278612-08:00","closed_at":"2026-01-30T13:38:50.529278612-08:00","close_reason":"Fixed by applying text color tint unconditionally in like_button()","labels":["columns"]}
+{"id":"notedeck-war","title":"MacOS crash on PFP → Side menu Accounts","description":"GitHub #1270: Application crashes when navigating to Accounts via profile picture menu due to 'layer_id change panic'. See https://github.com/damus-io/notedeck/issues/1270","status":"open","priority":1,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:41:09.082890703-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:41:09.082890703-08:00","labels":["columns"]}
+{"id":"notedeck-xer","title":"Persist conversation across app restarts","description":"Save and restore conversation state so it survives app restarts.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:40:11.196068397-08:00","created_by":"William Casarin","updated_at":"2026-01-30T11:40:11.196068397-08:00","labels":["dave"]}
+{"id":"notedeck-zg7","title":"Quote notification","description":"GitHub #1041: Implement notifications when users' posts are quoted by others. See https://github.com/damus-io/notedeck/issues/1041","status":"open","priority":3,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:50:58.370192754-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:50:58.370192754-08:00","labels":["columns"]}
diff --git a/Cargo.lock b/Cargo.lock
@@ -633,6 +633,12 @@ dependencies = [
[[package]]
name = "base64"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+
+[[package]]
+name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
@@ -2481,6 +2487,15 @@ dependencies = [
]
[[package]]
+name = "glam"
+version = "0.30.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
name = "glob"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2511,6 +2526,45 @@ dependencies = [
]
[[package]]
+name = "gltf"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7"
+dependencies = [
+ "base64 0.13.1",
+ "byteorder",
+ "gltf-json",
+ "image",
+ "lazy_static",
+ "serde_json",
+ "urlencoding",
+]
+
+[[package]]
+name = "gltf-derive"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14070e711538afba5d6c807edb74bcb84e5dbb9211a3bf5dea0dfab5b24f4c51"
+dependencies = [
+ "inflections",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.104",
+]
+
+[[package]]
+name = "gltf-json"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6176f9d60a7eab0a877e8e96548605dedbde9190a7ae1e80bbcc1c9af03ab14"
+dependencies = [
+ "gltf-derive",
+ "serde",
+ "serde_derive",
+ "serde_json",
+]
+
+[[package]]
name = "glutin"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2596,6 +2650,18 @@ dependencies = [
]
[[package]]
+name = "gpu-allocator"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd"
+dependencies = [
+ "log",
+ "presser",
+ "thiserror 1.0.69",
+ "windows 0.58.0",
+]
+
+[[package]]
name = "gpu-descriptor"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2636,12 +2702,13 @@ dependencies = [
[[package]]
name = "half"
-version = "2.6.0"
+version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9"
+checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
+ "zerocopy",
]
[[package]]
@@ -3103,6 +3170,12 @@ dependencies = [
]
[[package]]
+name = "inflections"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a"
+
+[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4048,6 +4121,7 @@ dependencies = [
"notedeck_dashboard",
"notedeck_dave",
"notedeck_messages",
+ "notedeck_nostrverse",
"notedeck_notebook",
"notedeck_ui",
"objc2 0.5.2",
@@ -4216,6 +4290,20 @@ dependencies = [
]
[[package]]
+name = "notedeck_nostrverse"
+version = "0.7.1"
+dependencies = [
+ "egui",
+ "egui-wgpu",
+ "enostr",
+ "glam",
+ "nostrdb",
+ "notedeck",
+ "renderbud",
+ "tracing",
+]
+
+[[package]]
name = "notedeck_notebook"
version = "0.7.1"
dependencies = [
@@ -5092,6 +5180,12 @@ dependencies = [
]
[[package]]
+name = "presser"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
+
+[[package]]
name = "pretty_assertions"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5343,6 +5437,12 @@ dependencies = [
]
[[package]]
+name = "range-alloc"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde"
+
+[[package]]
name = "rav1e"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5572,6 +5672,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
+name = "renderbud"
+version = "0.1.0"
+dependencies = [
+ "bytemuck",
+ "egui",
+ "egui-wgpu",
+ "glam",
+ "gltf",
+ "half",
+ "image",
+ "rayon",
+ "wgpu",
+ "winit 0.30.11",
+]
+
+[[package]]
name = "renderdoc-sys"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7652,6 +7768,7 @@ dependencies = [
"android_system_properties",
"arrayvec",
"ash",
+ "bit-set",
"bitflags 2.9.1",
"block",
"bytemuck",
@@ -7660,6 +7777,7 @@ dependencies = [
"glow",
"glutin_wgl_sys",
"gpu-alloc",
+ "gpu-allocator",
"gpu-descriptor",
"js-sys",
"khronos-egl",
@@ -7674,6 +7792,7 @@ dependencies = [
"ordered-float",
"parking_lot",
"profiling",
+ "range-alloc",
"raw-window-handle",
"renderdoc-sys",
"rustc-hash 1.1.0",
@@ -7683,6 +7802,7 @@ dependencies = [
"web-sys",
"wgpu-types",
"windows 0.58.0",
+ "windows-core 0.58.0",
]
[[package]]
@@ -8349,6 +8469,7 @@ dependencies = [
"raw-window-handle",
"redox_syscall 0.4.1",
"rustix 0.38.44",
+ "sctk-adwaita",
"smithay-client-toolkit",
"smol_str",
"tracing",
diff --git a/Cargo.toml b/Cargo.toml
@@ -8,6 +8,7 @@ members = [
"crates/notedeck_dave",
"crates/notedeck_messages",
"crates/notedeck_notebook",
+ "crates/notedeck_nostrverse",
"crates/notedeck_ui",
"crates/notedeck_clndash",
"crates/notedeck_dashboard",
@@ -55,6 +56,7 @@ ewebsock = { version = "0.2.0", features = ["tls"] }
fluent = "0.17.0"
fluent-resmgr = "0.0.8"
fluent-langneg = "0.13"
+glam = { version = "0.30", features = ["bytemuck"] }
hex = { version = "0.4.3", features = ["serde"] }
image = { version = "0.25", features = ["jpeg", "png", "webp"] }
indexmap = "2.6.0"
@@ -73,6 +75,8 @@ notedeck_columns = { path = "crates/notedeck_columns" }
notedeck_dave = { path = "crates/notedeck_dave" }
notedeck_messages = { path = "crates/notedeck_messages" }
notedeck_notebook = { path = "crates/notedeck_notebook" }
+notedeck_nostrverse = { path = "crates/notedeck_nostrverse" }
+renderbud = { path = "../github/jb55/renderbud", features = ["egui"] }
notedeck_ui = { path = "crates/notedeck_ui" }
tokenator = { path = "crates/tokenator" }
md-stream = { path = "crates/md-stream" }
diff --git a/crates/notedeck_chrome/Cargo.toml b/crates/notedeck_chrome/Cargo.toml
@@ -20,6 +20,7 @@ notedeck_ui = { workspace = true }
notedeck_dave = { workspace = true }
notedeck_messages = { workspace = true, optional = true }
notedeck_notebook = { workspace = true, optional = true }
+notedeck_nostrverse = { workspace = true, optional = true }
notedeck_clndash = { workspace = true, optional = true }
notedeck_dashboard = { workspace = true, optional = true }
notedeck = { workspace = true }
@@ -57,6 +58,7 @@ puffin = ["profiling/profile-with-puffin", "dep:puffin"]
tracy = ["profiling/profile-with-tracy"]
messages = ["notedeck_messages"]
notebook = ["notedeck_notebook"]
+nostrverse = ["notedeck_nostrverse"]
clndash = ["notedeck_clndash"]
dashboard = ["notedeck_dashboard"]
diff --git a/crates/notedeck_chrome/src/app.rs b/crates/notedeck_chrome/src/app.rs
@@ -15,6 +15,9 @@ use notedeck_dashboard::Dashboard;
#[cfg(feature = "notebook")]
use notedeck_notebook::Notebook;
+#[cfg(feature = "nostrverse")]
+use notedeck_nostrverse::NostrverseApp;
+
#[allow(clippy::large_enum_variant)]
pub enum NotedeckApp {
Dave(Box<Dave>),
@@ -32,6 +35,8 @@ pub enum NotedeckApp {
#[cfg(feature = "dashboard")]
Dashboard(Box<Dashboard>),
+ #[cfg(feature = "nostrverse")]
+ Nostrverse(Box<NostrverseApp>),
Other(Box<dyn notedeck::App>),
}
@@ -54,6 +59,9 @@ impl notedeck::App for NotedeckApp {
#[cfg(feature = "dashboard")]
NotedeckApp::Dashboard(db) => db.update(ctx, ui),
+ #[cfg(feature = "nostrverse")]
+ NotedeckApp::Nostrverse(nostrverse) => nostrverse.update(ctx, ui),
+
NotedeckApp::Other(other) => other.update(ctx, ui),
}
}
diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs
@@ -183,6 +183,11 @@ impl Chrome {
#[cfg(feature = "clndash")]
chrome.add_app(NotedeckApp::ClnDash(Box::default()));
+ #[cfg(feature = "nostrverse")]
+ chrome.add_app(NotedeckApp::Nostrverse(Box::new(
+ notedeck_nostrverse::NostrverseApp::demo(cc.wgpu_render_state.as_ref()),
+ )));
+
chrome.set_active(0);
Ok(chrome)
@@ -840,6 +845,12 @@ fn topdown_sidebar(
#[cfg(feature = "clndash")]
NotedeckApp::ClnDash(_) => tr!(loc, "ClnDash", "Button to go to the ClnDash app"),
+
+ #[cfg(feature = "nostrverse")]
+ NotedeckApp::Nostrverse(_) => {
+ tr!(loc, "Nostrverse", "Button to go to the Nostrverse app")
+ }
+
NotedeckApp::Other(_) => tr!(loc, "Other", "Button to go to the Other app"),
};
@@ -887,6 +898,11 @@ fn topdown_sidebar(
notebook_button(ui);
}
+ #[cfg(feature = "nostrverse")]
+ NotedeckApp::Nostrverse(_nostrverse) => {
+ ui.add(app_images::universe_image());
+ }
+
NotedeckApp::Other(_other) => {
// app provides its own button rendering ui?
panic!("TODO: implement other apps")
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -66,6 +66,15 @@ pub use vec3::Vec3;
/// Default relay URL used for PNS event publishing and subscription.
const DEFAULT_PNS_RELAY: &str = "ws://relay.jb55.com/";
+/// Normalize a relay URL to always have a trailing slash.
+fn normalize_relay_url(url: String) -> String {
+ if url.ends_with('/') {
+ url
+ } else {
+ url + "/"
+ }
+}
+
/// Extract a 32-byte secret key from a keypair.
fn secret_key_bytes(keypair: KeypairUnowned<'_>) -> Option<[u8; 32]> {
keypair.secret_key.map(|sk| {
@@ -350,10 +359,12 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
tools.insert(tool.name().to_string(), tool);
}
- let pns_relay_url = model_config
- .pns_relay
- .clone()
- .unwrap_or_else(|| DEFAULT_PNS_RELAY.to_string());
+ let pns_relay_url = normalize_relay_url(
+ model_config
+ .pns_relay
+ .clone()
+ .unwrap_or_else(|| DEFAULT_PNS_RELAY.to_string()),
+ );
let directory_picker = DirectoryPicker::new();
@@ -422,10 +433,12 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
/// Note: Provider changes require app restart to take effect.
pub fn apply_settings(&mut self, settings: DaveSettings) {
self.model_config = ModelConfig::from_settings(&settings);
- self.pns_relay_url = settings
- .pns_relay
- .clone()
- .unwrap_or_else(|| DEFAULT_PNS_RELAY.to_string());
+ self.pns_relay_url = normalize_relay_url(
+ settings
+ .pns_relay
+ .clone()
+ .unwrap_or_else(|| DEFAULT_PNS_RELAY.to_string()),
+ );
self.settings_serializer.try_save(settings.clone());
self.settings = settings;
}
diff --git a/crates/notedeck_nostrverse/Cargo.toml b/crates/notedeck_nostrverse/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "notedeck_nostrverse"
+edition = "2024"
+version.workspace = true
+
+[dependencies]
+notedeck = { workspace = true }
+egui = { workspace = true }
+egui-wgpu = { workspace = true }
+enostr = { workspace = true }
+glam = { workspace = true }
+nostrdb = { workspace = true }
+renderbud = { workspace = true }
+tracing = { workspace = true }
diff --git a/crates/notedeck_nostrverse/src/lib.rs b/crates/notedeck_nostrverse/src/lib.rs
@@ -0,0 +1,349 @@
+//! Nostrverse: Virtual rooms as Nostr events
+//!
+//! This app implements spatial views for nostrverse - a protocol where
+//! rooms and objects are Nostr events (kinds 37555, 37556, 10555).
+//!
+//! Rooms are rendered as 3D scenes using renderbud's PBR pipeline,
+//! embedded in egui via wgpu paint callbacks.
+
+mod room_state;
+mod room_view;
+
+pub use room_state::{
+ NostrverseAction, NostrverseState, Presence, Room, RoomObject, RoomRef, RoomShape, RoomUser,
+};
+pub use room_view::{NostrverseResponse, render_inspection_panel, show_room_view};
+
+use enostr::Pubkey;
+use glam::Vec3;
+use notedeck::{AppContext, AppResponse};
+use renderbud::Transform;
+
+use egui_wgpu::wgpu;
+
+/// Event kinds for nostrverse
+pub mod kinds {
+ /// Room event kind (addressable)
+ pub const ROOM: u16 = 37555;
+ /// Object event kind (addressable)
+ pub const OBJECT: u16 = 37556;
+ /// Presence event kind (user-replaceable)
+ pub const PRESENCE: u16 = 10555;
+}
+
+/// Nostrverse app - a 3D spatial canvas for virtual rooms
+pub struct NostrverseApp {
+ /// Current room state
+ state: NostrverseState,
+ /// 3D renderer (None if wgpu unavailable)
+ renderer: Option<renderbud::egui::EguiRenderer>,
+ /// GPU device for model loading (Arc-wrapped internally by wgpu)
+ 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
+ initialized: bool,
+}
+
+impl NostrverseApp {
+ /// Create a new nostrverse app with a room reference
+ pub fn new(room_ref: RoomRef, render_state: Option<&egui_wgpu::RenderState>) -> Self {
+ let renderer = render_state.map(|rs| renderbud::egui::EguiRenderer::new(rs, (800, 600)));
+
+ let device = render_state.map(|rs| rs.device.clone());
+ let queue = render_state.map(|rs| rs.queue.clone());
+
+ Self {
+ state: NostrverseState::new(room_ref),
+ renderer,
+ device,
+ queue,
+ initialized: false,
+ }
+ }
+
+ /// 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);
+ Self::new(room_ref, render_state)
+ }
+
+ /// Load a glTF model and return its handle
+ fn load_model(&self, path: &str) -> Option<renderbud::Model> {
+ let renderer = self.renderer.as_ref()?;
+ let device = self.device.as_ref()?;
+ let queue = self.queue.as_ref()?;
+ let mut r = renderer.renderer.lock().unwrap();
+ match r.load_gltf_model(device, queue, path) {
+ Ok(model) => Some(model),
+ Err(e) => {
+ tracing::warn!("Failed to load model {}: {}", path, e);
+ None
+ }
+ }
+ }
+
+ /// Initialize with demo data (for testing)
+ fn init_demo_data(&mut self) {
+ 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?)
+ };
+
+ 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()
+ });
+
+ let user2_pubkey =
+ Pubkey::from_hex("fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52")
+ .unwrap_or_else(|_| {
+ Pubkey::from_hex(
+ "0000000000000000000000000000000000000000000000000000000000000002",
+ )
+ .unwrap()
+ });
+
+ let agent_pubkey =
+ Pubkey::from_hex("ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49")
+ .unwrap_or_else(|_| {
+ Pubkey::from_hex(
+ "0000000000000000000000000000000000000000000000000000000000000003",
+ )
+ .unwrap()
+ });
+
+ self.state.users = vec![
+ RoomUser::new(user1_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 {
+ for user in &mut self.state.users {
+ user.model_handle = Some(model);
+ }
+ }
+
+ // Switch to third-person camera mode centered on the self-user
+ if let Some(renderer) = &self.renderer {
+ let self_pos = self
+ .state
+ .users
+ .iter()
+ .find(|u| u.is_self)
+ .map(|u| u.position)
+ .unwrap_or(Vec3::ZERO);
+ let mut r = renderer.renderer.lock().unwrap();
+ r.set_third_person_mode(self_pos);
+ }
+
+ self.initialized = true;
+ }
+
+ /// Sync room objects and user avatars to the renderbud scene
+ fn sync_scene(&mut self) {
+ let Some(renderer) = &self.renderer else {
+ return;
+ };
+ let mut r = renderer.renderer.lock().unwrap();
+
+ // Sync room objects
+ for obj in &mut self.state.objects {
+ let transform = Transform {
+ translation: obj.position,
+ rotation: obj.rotation,
+ scale: obj.scale,
+ };
+
+ if let Some(scene_id) = obj.scene_object_id {
+ r.update_object_transform(scene_id, transform);
+ } else if let Some(model) = obj.model_handle {
+ let scene_id = r.place_object(model, transform);
+ obj.scene_object_id = Some(scene_id);
+ }
+ }
+
+ // Read avatar position/yaw from the third-person controller
+ let avatar_pos = r.avatar_position();
+ let avatar_yaw = r.avatar_yaw();
+
+ // Update self-user's position from the controller
+ if let Some(pos) = avatar_pos {
+ if let Some(self_user) = self.state.users.iter_mut().find(|u| u.is_self) {
+ self_user.position = pos;
+ }
+ }
+
+ // Sync all user avatars to the scene
+ // Water bottle is ~0.26m; scale to human height (~1.8m)
+ let avatar_scale = 7.0_f32;
+ for user in &mut self.state.users {
+ let yaw = if user.is_self {
+ avatar_yaw.unwrap_or(0.0)
+ } else {
+ 0.0
+ };
+
+ let transform = Transform {
+ translation: user.position,
+ rotation: glam::Quat::from_rotation_y(yaw),
+ scale: Vec3::splat(avatar_scale),
+ };
+
+ if let Some(scene_id) = user.scene_object_id {
+ r.update_object_transform(scene_id, transform);
+ } else if let Some(model) = user.model_handle {
+ let scene_id = r.place_object(model, transform);
+ user.scene_object_id = Some(scene_id);
+ }
+ }
+ }
+
+ /// Get the current state
+ pub fn state(&self) -> &NostrverseState {
+ &self.state
+ }
+
+ /// Get mutable state
+ pub fn state_mut(&mut self) -> &mut NostrverseState {
+ &mut self.state
+ }
+}
+
+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();
+
+ // Sync state to 3D scene
+ self.sync_scene();
+
+ // Get available size before layout
+ let available = ui.available_size();
+
+ // Main layout with room view and optional inspection 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
+ } else {
+ available.x
+ };
+
+ ui.allocate_ui(egui::vec2(room_width, available.y), |ui| {
+ 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
+ }
+ }
+ }
+ } else {
+ ui.centered_and_justified(|ui| {
+ ui.label("3D rendering unavailable (no wgpu)");
+ });
+ }
+ });
+
+ // 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;
+ }
+ });
+ }
+ });
+ });
+
+ AppResponse::none()
+ }
+}
diff --git a/crates/notedeck_nostrverse/src/room_state.rs b/crates/notedeck_nostrverse/src/room_state.rs
@@ -0,0 +1,207 @@
+//! Room state management for nostrverse views
+
+use enostr::Pubkey;
+use glam::{Quat, Vec3};
+use renderbud::{Model, ObjectId};
+
+/// Actions that can be triggered from the nostrverse view
+#[derive(Clone, Debug)]
+pub enum NostrverseAction {
+ /// Object was moved to a new position (id, new_pos)
+ MoveObject { id: String, position: Vec3 },
+ /// Object was selected
+ SelectObject(Option<String>),
+ /// Request to open add object UI
+ OpenAddObject,
+}
+
+/// Reference to a nostrverse room
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+pub struct RoomRef {
+ /// Room identifier (d-tag)
+ pub id: String,
+ /// Room owner pubkey
+ pub pubkey: Pubkey,
+}
+
+impl RoomRef {
+ pub fn new(id: String, pubkey: Pubkey) -> Self {
+ Self { id, pubkey }
+ }
+
+ /// Get the NIP-33 "a" tag format
+ pub fn to_naddr(&self) -> String {
+ format!("{}:{}:{}", super::kinds::ROOM, self.pubkey.hex(), self.id)
+ }
+}
+
+/// Parsed room data from event
+#[derive(Clone, Debug)]
+pub struct Room {
+ pub name: String,
+ pub shape: RoomShape,
+ pub width: f32,
+ pub height: f32,
+ pub depth: f32,
+}
+
+impl Default for Room {
+ fn default() -> Self {
+ Self {
+ name: "Untitled Room".to_string(),
+ shape: RoomShape::Rectangle,
+ width: 20.0,
+ height: 15.0,
+ depth: 10.0,
+ }
+ }
+}
+
+/// Room shape types
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub enum RoomShape {
+ #[default]
+ Rectangle,
+ Circle,
+ Custom,
+}
+
+/// Object in a room - references a 3D model
+#[derive(Clone, Debug)]
+pub struct RoomObject {
+ pub id: String,
+ pub name: String,
+ /// URL to a glTF model (None = use placeholder geometry)
+ pub model_url: Option<String>,
+ /// 3D position in world space
+ pub position: Vec3,
+ /// 3D rotation
+ pub rotation: Quat,
+ /// 3D scale
+ pub scale: Vec3,
+ /// Runtime: renderbud scene object handle
+ pub scene_object_id: Option<ObjectId>,
+ /// Runtime: loaded model handle
+ pub model_handle: Option<Model>,
+}
+
+impl RoomObject {
+ pub fn new(id: String, name: String, position: Vec3) -> Self {
+ Self {
+ id,
+ name,
+ model_url: None,
+ position,
+ rotation: Quat::IDENTITY,
+ scale: Vec3::ONE,
+ scene_object_id: None,
+ model_handle: None,
+ }
+ }
+
+ pub fn with_model_url(mut self, url: String) -> Self {
+ self.model_url = Some(url);
+ 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 {
+ pub pubkey: Pubkey,
+ pub display_name: String,
+ pub position: Vec3,
+ /// Whether this is the current user
+ pub is_self: bool,
+ /// Whether this user is an AI agent
+ pub is_agent: bool,
+ /// Runtime: renderbud scene object handle for avatar
+ pub scene_object_id: Option<ObjectId>,
+ /// Runtime: loaded model handle for avatar
+ pub model_handle: Option<Model>,
+}
+
+impl RoomUser {
+ pub fn new(pubkey: Pubkey, display_name: String, position: Vec3) -> Self {
+ Self {
+ pubkey,
+ display_name,
+ position,
+ is_self: false,
+ is_agent: false,
+ scene_object_id: None,
+ model_handle: None,
+ }
+ }
+
+ pub fn with_self(mut self, is_self: bool) -> Self {
+ 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
+pub struct NostrverseState {
+ /// Reference to the room being viewed
+ pub room_ref: RoomRef,
+ /// Parsed room data (if loaded)
+ pub room: Option<Room>,
+ /// Objects in the room
+ pub objects: Vec<RoomObject>,
+ /// Users currently in the room
+ pub users: Vec<RoomUser>,
+ /// Currently selected object ID
+ pub selected_object: Option<String>,
+ /// Whether we're in edit mode
+ pub edit_mode: bool,
+}
+
+impl NostrverseState {
+ pub fn new(room_ref: RoomRef) -> Self {
+ Self {
+ room_ref,
+ room: None,
+ objects: Vec::new(),
+ users: Vec::new(),
+ selected_object: None,
+ edit_mode: false,
+ }
+ }
+
+ /// Add or update a user in the room
+ pub fn update_user(&mut self, user: RoomUser) {
+ if let Some(existing) = self.users.iter_mut().find(|u| u.pubkey == user.pubkey) {
+ *existing = user;
+ } else {
+ self.users.push(user);
+ }
+ }
+
+ /// Remove a user from the room
+ pub fn remove_user(&mut self, pubkey: &Pubkey) {
+ self.users.retain(|u| &u.pubkey != pubkey);
+ }
+
+ /// Get a mutable reference to an object by ID
+ pub fn get_object_mut(&mut self, id: &str) -> Option<&mut RoomObject> {
+ self.objects.iter_mut().find(|o| o.id == id)
+ }
+}
diff --git a/crates/notedeck_nostrverse/src/room_view.rs b/crates/notedeck_nostrverse/src/room_view.rs
@@ -0,0 +1,175 @@
+//! Room 3D rendering for nostrverse via renderbud
+
+use egui::{Color32, Pos2, Rect, Response, Sense, Stroke, Ui};
+
+use super::room_state::{NostrverseAction, NostrverseState};
+
+/// Response from rendering the nostrverse view
+pub struct NostrverseResponse {
+ pub response: Response,
+ pub action: Option<NostrverseAction>,
+}
+
+/// Render the nostrverse room view with 3D scene
+pub fn show_room_view(
+ ui: &mut Ui,
+ state: &mut NostrverseState,
+ renderer: &renderbud::egui::EguiRenderer,
+) -> NostrverseResponse {
+ let available_size = ui.available_size();
+ let (rect, response) = ui.allocate_exact_size(available_size, Sense::click_and_drag());
+
+ // Update renderer target size and handle input
+ {
+ let mut r = renderer.renderer.lock().unwrap();
+ r.set_target_size((rect.width() as u32, rect.height() as u32));
+
+ // Handle mouse drag for camera look
+ if response.dragged() {
+ let delta = response.drag_delta();
+ r.on_mouse_drag(delta.x, delta.y);
+ }
+
+ // Handle scroll for speed adjustment
+ if response.hover_pos().is_some() {
+ let scroll = ui.input(|i| i.raw_scroll_delta.y);
+ if scroll.abs() > 0.0 {
+ r.on_scroll(scroll * 0.01);
+ }
+ }
+
+ // WASD + QE movement
+ let dt = ui.input(|i| i.stable_dt);
+ let mut forward = 0.0_f32;
+ let mut right = 0.0_f32;
+ let mut up = 0.0_f32;
+
+ ui.input(|i| {
+ if i.key_down(egui::Key::W) {
+ forward += 1.0;
+ }
+ if i.key_down(egui::Key::S) {
+ forward -= 1.0;
+ }
+ if i.key_down(egui::Key::D) {
+ right += 1.0;
+ }
+ if i.key_down(egui::Key::A) {
+ right -= 1.0;
+ }
+ if i.key_down(egui::Key::E) || i.key_down(egui::Key::Space) {
+ up += 1.0;
+ }
+ if i.key_down(egui::Key::Q) {
+ up -= 1.0;
+ }
+ });
+
+ if forward != 0.0 || right != 0.0 || up != 0.0 {
+ r.process_movement(forward, right, up, dt);
+ ui.ctx().request_repaint();
+ }
+ }
+
+ // Register the 3D scene paint callback
+ ui.painter().add(egui_wgpu::Callback::new_paint_callback(
+ rect,
+ renderbud::egui::SceneRender,
+ ));
+
+ // Draw 2D overlays on top of the 3D scene
+ let painter = ui.painter_at(rect);
+ draw_info_overlay(&painter, state, rect);
+
+ NostrverseResponse {
+ response,
+ action: None,
+ }
+}
+
+fn draw_info_overlay(painter: &egui::Painter, state: &NostrverseState, rect: Rect) {
+ let room_name = state
+ .room
+ .as_ref()
+ .map(|r| r.name.as_str())
+ .unwrap_or("Loading...");
+
+ let info_text = format!("{} | Objects: {}", room_name, state.objects.len());
+
+ // Background for readability
+ let text_pos = Pos2::new(rect.left() + 10.0, rect.top() + 10.0);
+ painter.rect_filled(
+ Rect::from_min_size(
+ Pos2::new(rect.left() + 4.0, rect.top() + 4.0),
+ egui::vec2(200.0, 24.0),
+ ),
+ 4.0,
+ Color32::from_rgba_unmultiplied(0, 0, 0, 160),
+ );
+
+ painter.text(
+ text_pos,
+ egui::Align2::LEFT_TOP,
+ info_text,
+ egui::FontId::proportional(14.0),
+ Color32::from_rgba_unmultiplied(200, 200, 210, 220),
+ );
+}
+
+/// 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)?;
+
+ let mut action = None;
+
+ 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));
+ }
+ });
+ });
+
+ ui.separator();
+
+ 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 {
+ ui.separator();
+ ui.small(format!("Model: {}", url));
+ }
+
+ ui.separator();
+
+ let id_display = if obj.id.len() > 16 {
+ format!("{}...", &obj.id[..16])
+ } else {
+ obj.id.clone()
+ };
+ ui.small(format!("ID: {}", id_display));
+ });
+
+ action
+}