notedeck

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

commit 26d2b8166e9f19c765ece97c5a3a560fa846f950
parent cc40f0a30aab2d295e393334b71c5ce165999bf7
Author: William Casarin <jb55@jb55.com>
Date:   Sun, 22 Feb 2026 14:06:30 -0800

nostrverse: add room editing UI and save-to-nostrdb flow

Add editing panel with room properties (name, dimensions, shape),
object list, object inspector (editable name/position/scale),
add/delete object, and save button that round-trips through
protoverse serialization back to nostrdb.

Key changes:
- build_space(): reverse conversion from Room+objects to Space AST
- RoomObjectType: preserves protoverse cell type through round-trips
- render_editing_panel(): replaces read-only inspection panel
- save_room(): rebuilds Space, serializes, re-ingests as nostr event
- apply_space(): deduplicates room-loading logic
- Dirty guard on poll_room_updates to prevent overwriting unsaved edits

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_nostrverse/src/convert.rs | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/notedeck_nostrverse/src/lib.rs | 110++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mcrates/notedeck_nostrverse/src/room_state.rs | 31++++++++++++++++++++++++++++++-
Mcrates/notedeck_nostrverse/src/room_view.rs | 261++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
4 files changed, 483 insertions(+), 81 deletions(-)

diff --git a/crates/notedeck_nostrverse/src/convert.rs b/crates/notedeck_nostrverse/src/convert.rs @@ -1,8 +1,8 @@ //! Convert protoverse Space AST to renderer room state. -use crate::room_state::{Room, RoomObject, RoomShape}; +use crate::room_state::{Room, RoomObject, RoomObjectType, RoomShape}; use glam::Vec3; -use protoverse::{CellId, CellType, Shape, Space}; +use protoverse::{Attribute, Cell, CellId, CellType, ObjectType, Shape, Space}; /// Convert a parsed protoverse Space into a Room and its objects. pub fn convert_space(space: &Space) -> (Room, Vec<RoomObject>) { @@ -34,10 +34,21 @@ fn extract_room(space: &Space, id: CellId) -> Room { } } +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(_) = &cell.cell_type { + 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 @@ -59,7 +70,8 @@ fn collect_objects(space: &Space, id: CellId, objects: &mut Vec<RoomObject>) { let model_url = space.model_url(id).map(|s| s.to_string()); - let mut obj = RoomObject::new(obj_id, name, position); + 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); } @@ -72,6 +84,99 @@ fn collect_objects(space: &Space, id: CellId, objects: &mut Vec<RoomObject>) { } } +/// 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())); + } + let pos = obj.position; + if pos != Vec3::ZERO { + 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::*; @@ -100,10 +205,12 @@ mod tests { 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] @@ -138,6 +245,53 @@ mod tests { } #[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); diff --git a/crates/notedeck_nostrverse/src/lib.rs b/crates/notedeck_nostrverse/src/lib.rs @@ -13,9 +13,10 @@ mod room_view; mod subscriptions; pub use room_state::{ - NostrverseAction, NostrverseState, 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; @@ -186,12 +187,20 @@ 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 { - // Find the room matching our room_ref let Some(room_id) = nostr_events::get_room_id(note) else { continue; }; @@ -204,18 +213,29 @@ impl NostrverseApp { continue; }; - let (room, mut objects) = convert::convert_space(&space); - self.state.room = Some(room); - - // Load models and compute placement - self.load_object_models(&mut objects); - self.state.objects = objects; - + 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_room_event(builder, ctx.ndb, kp); + tracing::info!("Saved room '{}'", self.state.room_ref.id); + } + /// Load 3D models for objects and handle AABB-based placement. fn load_object_models(&self, objects: &mut Vec<RoomObject>) { let renderer = self.renderer.as_ref(); @@ -256,7 +276,11 @@ impl NostrverseApp { } /// 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; }; @@ -275,10 +299,7 @@ impl NostrverseApp { continue; }; - let (room, mut objects) = convert::convert_space(&space); - self.state.room = Some(room); - self.load_object_models(&mut objects); - self.state.objects = objects; + self.apply_space(&space); tracing::info!("Room '{}' updated from nostrdb", room_id); } } @@ -381,13 +402,13 @@ impl notedeck::App for NostrverseApp { // 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 }; @@ -396,16 +417,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; - } - } + self.handle_action(action, ctx); } } else { ui.centered_and_justified(|ui| { @@ -414,13 +427,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); } }); } @@ -430,3 +441,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/room_state.rs b/crates/notedeck_nostrverse/src/room_state.rs @@ -11,6 +11,12 @@ pub enum NostrverseAction { MoveObject { id: String, position: Vec3 }, /// Object was selected SelectObject(Option<String>), + /// 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 @@ -64,11 +70,25 @@ pub enum RoomShape { Custom, } +/// 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>, /// 3D position in world space @@ -88,6 +108,7 @@ impl RoomObject { Self { id, name, + object_type: RoomObjectType::Prop, model_url: None, position, rotation: Quat::IDENTITY, @@ -97,6 +118,11 @@ 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 @@ -156,6 +182,8 @@ pub struct NostrverseState { pub edit_mode: bool, /// Smoothed avatar yaw for lerped rotation pub smooth_avatar_yaw: f32, + /// Room has unsaved edits + pub dirty: bool, } impl NostrverseState { @@ -166,8 +194,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 { @@ -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 }