notedeck

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

room_view.rs (28217B)


      1 //! Space 3D rendering and editing UI for nostrverse via renderbud
      2 
      3 use egui::{Color32, Pos2, Rect, Response, Sense, Ui};
      4 use glam::{Quat, Vec3};
      5 
      6 use super::convert;
      7 use super::room_state::{
      8     DragMode, DragState, NostrverseAction, NostrverseState, ObjectLocation, RoomObject,
      9 };
     10 
     11 /// Radians of Y rotation per pixel of horizontal drag
     12 const ROTATE_SENSITIVITY: f32 = 0.01;
     13 
     14 /// Response from rendering the nostrverse view
     15 pub struct NostrverseResponse {
     16     pub response: Response,
     17     pub action: Option<NostrverseAction>,
     18 }
     19 
     20 fn snap_to_grid(pos: Vec3, grid: f32) -> Vec3 {
     21     Vec3::new(
     22         (pos.x / grid).round() * grid,
     23         pos.y,
     24         (pos.z / grid).round() * grid,
     25     )
     26 }
     27 
     28 /// Result of computing a drag update — fully owned, no borrows into state.
     29 enum DragUpdate {
     30     Move {
     31         id: String,
     32         position: Vec3,
     33     },
     34     Breakaway {
     35         id: String,
     36         world_pos: Vec3,
     37         new_grab_offset: Vec3,
     38         new_plane_y: f32,
     39     },
     40 }
     41 
     42 /// Pure computation: given current drag state and pointer, decide what to do.
     43 fn compute_drag_update(
     44     drag: &DragState,
     45     vp_x: f32,
     46     vp_y: f32,
     47     grid_snap: Option<f32>,
     48     r: &renderbud::Renderer,
     49 ) -> Option<DragUpdate> {
     50     match &drag.mode {
     51         DragMode::Free => {
     52             let hit = r.unproject_to_plane(vp_x, vp_y, drag.plane_y)?;
     53             let mut new_pos = hit + drag.grab_offset;
     54             if let Some(grid) = grid_snap {
     55                 new_pos = snap_to_grid(new_pos, grid);
     56             }
     57             Some(DragUpdate::Move {
     58                 id: drag.object_id.clone(),
     59                 position: new_pos,
     60             })
     61         }
     62         DragMode::Parented {
     63             parent_scene_id,
     64             parent_aabb,
     65             local_y,
     66             ..
     67         } => {
     68             let hit = r.unproject_to_plane(vp_x, vp_y, drag.plane_y)?;
     69             let parent_world = r.world_matrix(*parent_scene_id)?;
     70             let local_hit = parent_world.inverse().transform_point3(hit);
     71             let mut local_pos = Vec3::new(
     72                 local_hit.x + drag.grab_offset.x,
     73                 *local_y,
     74                 local_hit.z + drag.grab_offset.z,
     75             );
     76             if let Some(grid) = grid_snap {
     77                 local_pos = snap_to_grid(local_pos, grid);
     78             }
     79 
     80             if parent_aabb.xz_overshoot(local_pos) > 1.0 {
     81                 let world_pos = parent_world.transform_point3(local_pos);
     82                 Some(DragUpdate::Breakaway {
     83                     id: drag.object_id.clone(),
     84                     world_pos,
     85                     new_grab_offset: world_pos - hit,
     86                     new_plane_y: world_pos.y,
     87                 })
     88             } else {
     89                 Some(DragUpdate::Move {
     90                     id: drag.object_id.clone(),
     91                     position: parent_aabb.clamp_xz(local_pos),
     92                 })
     93             }
     94         }
     95     }
     96 }
     97 
     98 /// Try to start an object drag. Returns the action (selection) if an object was picked.
     99 fn handle_drag_start(
    100     state: &mut NostrverseState,
    101     vp_x: f32,
    102     vp_y: f32,
    103     r: &mut renderbud::Renderer,
    104 ) -> Option<NostrverseAction> {
    105     let scene_id = r.pick(vp_x, vp_y)?;
    106     let obj = state
    107         .objects
    108         .iter()
    109         .find(|o| o.scene_object_id == Some(scene_id))?;
    110 
    111     // Always select on drag start
    112     r.set_selected(Some(scene_id));
    113     state.selected_object = Some(obj.id.clone());
    114 
    115     // In rotate mode, mark this as a rotation drag (don't start a position drag)
    116     let drag_info = if state.rotate_mode {
    117         state.rotate_drag = true;
    118         None
    119     } else {
    120         compute_initial_drag(obj, state, vp_x, vp_y, r)
    121     };
    122 
    123     if let Some((mode, grab_offset, plane_y)) = drag_info {
    124         state.drag_state = Some(DragState {
    125             object_id: obj.id.clone(),
    126             grab_offset,
    127             plane_y,
    128             mode,
    129         });
    130     }
    131     None
    132 }
    133 
    134 /// Compute the initial drag mode and grab offset for an object.
    135 fn compute_initial_drag(
    136     obj: &RoomObject,
    137     state: &NostrverseState,
    138     vp_x: f32,
    139     vp_y: f32,
    140     r: &renderbud::Renderer,
    141 ) -> Option<(DragMode, Vec3, f32)> {
    142     match &obj.location {
    143         Some(ObjectLocation::TopOf(parent_id)) | Some(ObjectLocation::Near(parent_id)) => {
    144             let parent = state.objects.iter().find(|o| o.id == *parent_id)?;
    145             let parent_scene_id = parent.scene_object_id?;
    146             let parent_aabb = r.model_bounds(parent.model_handle?)?;
    147             let parent_world = r.world_matrix(parent_scene_id)?;
    148 
    149             let child_half_h = obj
    150                 .model_handle
    151                 .and_then(|m| r.model_bounds(m))
    152                 .map(|b| (b.max.y - b.min.y) * 0.5)
    153                 .unwrap_or(0.0);
    154             let local_y = if matches!(&obj.location, Some(ObjectLocation::TopOf(_))) {
    155                 parent_aabb.max.y + child_half_h
    156             } else {
    157                 0.0
    158             };
    159             let obj_world = parent_world.transform_point3(obj.position);
    160             let plane_y = obj_world.y;
    161             let hit = r
    162                 .unproject_to_plane(vp_x, vp_y, plane_y)
    163                 .unwrap_or(obj_world);
    164             let local_hit = parent_world.inverse().transform_point3(hit);
    165             let grab_offset = obj.position - local_hit;
    166             Some((
    167                 DragMode::Parented {
    168                     parent_id: parent_id.clone(),
    169                     parent_scene_id,
    170                     parent_aabb,
    171                     local_y,
    172                 },
    173                 grab_offset,
    174                 plane_y,
    175             ))
    176         }
    177         None | Some(ObjectLocation::Floor) => {
    178             let plane_y = obj.position.y;
    179             let hit = r
    180                 .unproject_to_plane(vp_x, vp_y, plane_y)
    181                 .unwrap_or(obj.position);
    182             let grab_offset = obj.position - hit;
    183             Some((DragMode::Free, grab_offset, plane_y))
    184         }
    185         _ => None, // Center/Ceiling/Custom: not draggable
    186     }
    187 }
    188 
    189 /// Apply a computed drag update to state and renderer.
    190 fn apply_drag_update(
    191     update: DragUpdate,
    192     state: &mut NostrverseState,
    193     r: &mut renderbud::Renderer,
    194 ) -> Option<NostrverseAction> {
    195     match update {
    196         DragUpdate::Move { id, position } => Some(NostrverseAction::MoveObject { id, position }),
    197         DragUpdate::Breakaway {
    198             id,
    199             world_pos,
    200             new_grab_offset,
    201             new_plane_y,
    202         } => {
    203             if let Some(obj) = state.objects.iter_mut().find(|o| o.id == id) {
    204                 if let Some(sid) = obj.scene_object_id {
    205                     r.set_parent(sid, None);
    206                 }
    207                 obj.position = world_pos;
    208                 obj.location = None;
    209                 obj.location_base = None;
    210                 state.dirty = true;
    211             }
    212             state.drag_state = Some(DragState {
    213                 object_id: id,
    214                 grab_offset: new_grab_offset,
    215                 plane_y: new_plane_y,
    216                 mode: DragMode::Free,
    217             });
    218             None
    219         }
    220     }
    221 }
    222 
    223 /// Handle keyboard shortcuts and WASD movement. Returns an action if triggered.
    224 fn handle_keyboard_input(
    225     ui: &Ui,
    226     state: &mut NostrverseState,
    227     r: &mut renderbud::Renderer,
    228 ) -> Option<NostrverseAction> {
    229     let mut action = None;
    230 
    231     // G key: toggle grid snap
    232     if ui.input(|i| i.key_pressed(egui::Key::G)) {
    233         state.grid_snap_enabled = !state.grid_snap_enabled;
    234     }
    235 
    236     // R key: toggle rotate mode
    237     if ui.input(|i| i.key_pressed(egui::Key::R)) {
    238         state.rotate_mode = !state.rotate_mode;
    239     }
    240 
    241     // Ctrl+D: duplicate selected object
    242     if ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::D))
    243         && let Some(id) = state.selected_object.clone()
    244     {
    245         action = Some(NostrverseAction::DuplicateObject(id));
    246     }
    247 
    248     // WASD + QE movement: always available
    249     let dt = ui.input(|i| i.stable_dt);
    250     let mut forward = 0.0_f32;
    251     let mut right = 0.0_f32;
    252     let mut up = 0.0_f32;
    253 
    254     ui.input(|i| {
    255         if i.key_down(egui::Key::W) {
    256             forward -= 1.0;
    257         }
    258         if i.key_down(egui::Key::S) {
    259             forward += 1.0;
    260         }
    261         if i.key_down(egui::Key::D) {
    262             right += 1.0;
    263         }
    264         if i.key_down(egui::Key::A) {
    265             right -= 1.0;
    266         }
    267         if i.key_down(egui::Key::E) || i.key_down(egui::Key::Space) {
    268             up += 1.0;
    269         }
    270         if i.key_down(egui::Key::Q) {
    271             up -= 1.0;
    272         }
    273     });
    274 
    275     if forward != 0.0 || right != 0.0 || up != 0.0 {
    276         r.process_movement(forward, right, up, dt);
    277         ui.ctx().request_repaint();
    278     }
    279 
    280     action
    281 }
    282 
    283 /// Render the nostrverse room view with 3D scene
    284 pub fn show_room_view(
    285     ui: &mut Ui,
    286     state: &mut NostrverseState,
    287     renderer: &renderbud::egui::EguiRenderer,
    288 ) -> NostrverseResponse {
    289     let available_size = ui.available_size();
    290     let (rect, response) = ui.allocate_exact_size(available_size, Sense::click_and_drag());
    291 
    292     let mut action: Option<NostrverseAction> = None;
    293 
    294     // Update renderer target size and handle input
    295     {
    296         let mut r = renderer.renderer.lock().unwrap();
    297         r.set_target_size((rect.width() as u32, rect.height() as u32));
    298 
    299         if state.edit_mode {
    300             // --- Edit mode: click-to-select, drag-to-move objects ---
    301 
    302             // Drag start: pick to decide object-drag vs camera
    303             if response.drag_started()
    304                 && let Some(pos) = response.interact_pointer_pos()
    305             {
    306                 let vp = pos - rect.min.to_vec2();
    307                 if let Some(a) = handle_drag_start(state, vp.x, vp.y, &mut r) {
    308                     action = Some(a);
    309                 }
    310             }
    311 
    312             // Dragging: rotate or move object, or control camera
    313             if response.dragged() {
    314                 // Rotation drag: only when drag started on an object in rotate mode
    315                 if state.rotate_drag
    316                     && let Some(sel_id) = state.selected_object.clone()
    317                     && let Some(obj) = state.objects.iter().find(|o| o.id == sel_id)
    318                 {
    319                     let delta_x = response.drag_delta().x;
    320                     let angle = delta_x * ROTATE_SENSITIVITY;
    321                     let new_rotation = Quat::from_rotation_y(angle) * obj.rotation;
    322                     let new_rotation = if state.grid_snap_enabled {
    323                         let (y, _, _) = new_rotation.to_euler(glam::EulerRot::YXZ);
    324                         let snap_rad = state.rotation_snap.to_radians();
    325                         let snapped_y = (y / snap_rad).round() * snap_rad;
    326                         Quat::from_rotation_y(snapped_y)
    327                     } else {
    328                         new_rotation
    329                     };
    330                     action = Some(NostrverseAction::RotateObject {
    331                         id: sel_id,
    332                         rotation: new_rotation,
    333                     });
    334                     ui.ctx().request_repaint();
    335                 } else if let Some(drag) = state.drag_state.as_ref() {
    336                     if let Some(pos) = response.interact_pointer_pos() {
    337                         let vp = pos - rect.min.to_vec2();
    338                         let grid = state.grid_snap_enabled.then_some(state.grid_snap);
    339                         let update = compute_drag_update(drag, vp.x, vp.y, grid, &r);
    340 
    341                         if let Some(update) = update
    342                             && let Some(a) = apply_drag_update(update, state, &mut r)
    343                         {
    344                             action = Some(a);
    345                         }
    346                     }
    347                     ui.ctx().request_repaint();
    348                 } else {
    349                     let delta = response.drag_delta();
    350                     r.on_mouse_drag(delta.x, delta.y);
    351                 }
    352             }
    353 
    354             // Drag end: clear state
    355             if response.drag_stopped() {
    356                 state.drag_state = None;
    357                 state.rotate_drag = false;
    358             }
    359 
    360             // Click (no drag): select/deselect
    361             if response.clicked()
    362                 && let Some(pos) = response.interact_pointer_pos()
    363             {
    364                 let vp = pos - rect.min.to_vec2();
    365                 if let Some(scene_id) = r.pick(vp.x, vp.y) {
    366                     if let Some(obj) = state
    367                         .objects
    368                         .iter()
    369                         .find(|o| o.scene_object_id == Some(scene_id))
    370                     {
    371                         action = Some(NostrverseAction::SelectObject(Some(obj.id.clone())));
    372                     }
    373                 } else {
    374                     action = Some(NostrverseAction::SelectObject(None));
    375                 }
    376             }
    377         } else {
    378             // --- View mode: camera only ---
    379             if response.dragged() {
    380                 let delta = response.drag_delta();
    381                 r.on_mouse_drag(delta.x, delta.y);
    382             }
    383         }
    384 
    385         // Scroll: always routes to camera (zoom/speed)
    386         if response.hover_pos().is_some() {
    387             let scroll = ui.input(|i| i.raw_scroll_delta.y);
    388             if scroll.abs() > 0.0 {
    389                 r.on_scroll(scroll * 0.01);
    390             }
    391         }
    392 
    393         if let Some(a) = handle_keyboard_input(ui, state, &mut r) {
    394             action = Some(a);
    395         }
    396     }
    397 
    398     // Register the 3D scene paint callback
    399     ui.painter().add(egui_wgpu::Callback::new_paint_callback(
    400         rect,
    401         renderbud::egui::SceneRender,
    402     ));
    403 
    404     // Draw 2D overlays on top of the 3D scene
    405     let painter = ui.painter_at(rect);
    406     draw_info_overlay(&painter, state, rect);
    407 
    408     NostrverseResponse { response, action }
    409 }
    410 
    411 fn draw_info_overlay(painter: &egui::Painter, state: &NostrverseState, rect: Rect) {
    412     let space_name = state
    413         .space
    414         .as_ref()
    415         .map(|s| s.name.as_str())
    416         .unwrap_or("Loading...");
    417 
    418     let mut info_text = format!("{} | Objects: {}", space_name, state.objects.len());
    419     if state.rotate_mode {
    420         info_text.push_str(" | Rotate (R)");
    421     }
    422 
    423     // Measure text to size the background
    424     let font_id = egui::FontId::proportional(14.0);
    425     let text_pos = Pos2::new(rect.left() + 10.0, rect.top() + 10.0);
    426     let galley = painter.layout_no_wrap(
    427         info_text,
    428         font_id,
    429         Color32::from_rgba_unmultiplied(200, 200, 210, 220),
    430     );
    431     let padding = egui::vec2(12.0, 6.0);
    432     painter.rect_filled(
    433         Rect::from_min_size(
    434             Pos2::new(rect.left() + 4.0, rect.top() + 4.0),
    435             galley.size() + padding,
    436         ),
    437         4.0,
    438         Color32::from_rgba_unmultiplied(0, 0, 0, 160),
    439     );
    440     painter.galley(text_pos, galley, Color32::PLACEHOLDER);
    441 }
    442 
    443 /// Render the object list and add-object button. Returns an action if triggered.
    444 fn render_object_list(ui: &mut Ui, state: &NostrverseState) -> Option<NostrverseAction> {
    445     ui.strong("Objects");
    446     ui.separator();
    447 
    448     let mut action = None;
    449     let num_objects = state.objects.len();
    450     for i in 0..num_objects {
    451         let is_selected = state
    452             .selected_object
    453             .as_ref()
    454             .map(|s| s == &state.objects[i].id)
    455             .unwrap_or(false);
    456 
    457         let label = format!("{} ({})", state.objects[i].name, state.objects[i].id);
    458         if ui.selectable_label(is_selected, label).clicked() {
    459             let selected = if is_selected {
    460                 None
    461             } else {
    462                 Some(state.objects[i].id.clone())
    463             };
    464             action = Some(NostrverseAction::SelectObject(selected));
    465         }
    466     }
    467 
    468     // Add object button
    469     ui.add_space(4.0);
    470     if ui.button("+ Add Object").clicked() {
    471         let new_id = format!("obj-{}", state.objects.len() + 1);
    472         let obj = RoomObject::new(new_id.clone(), "New Object".to_string(), Vec3::ZERO);
    473         action = Some(NostrverseAction::AddObject(obj));
    474     }
    475 
    476     action
    477 }
    478 
    479 /// Render the object inspector panel for the selected object.
    480 /// Returns an action and whether any property changed.
    481 fn render_object_inspector(
    482     ui: &mut Ui,
    483     selected_id: &str,
    484     obj: &mut RoomObject,
    485     grid_snap_enabled: bool,
    486     rotation_snap: f32,
    487 ) -> (Option<NostrverseAction>, bool) {
    488     let mut action = None;
    489 
    490     ui.strong("Inspector");
    491     ui.separator();
    492 
    493     ui.small(format!("ID: {}", obj.id));
    494     ui.add_space(4.0);
    495 
    496     // Editable name
    497     let name_changed = ui
    498         .horizontal(|ui| {
    499             ui.label("Name:");
    500             ui.text_edit_singleline(&mut obj.name).changed()
    501         })
    502         .inner;
    503 
    504     // Edit offset (relative to location base) or absolute position
    505     let base = obj.location_base.unwrap_or(Vec3::ZERO);
    506     let offset = obj.position - base;
    507     let mut ox = offset.x;
    508     let mut oy = offset.y;
    509     let mut oz = offset.z;
    510     let has_location = obj.location.is_some();
    511     let pos_label = if has_location { "Offset:" } else { "Pos:" };
    512     let pos_changed = ui
    513         .horizontal(|ui| {
    514             ui.label(pos_label);
    515             let x = ui
    516                 .add(egui::DragValue::new(&mut ox).speed(0.1).prefix("x:"))
    517                 .changed();
    518             let y = ui
    519                 .add(egui::DragValue::new(&mut oy).speed(0.1).prefix("y:"))
    520                 .changed();
    521             let z = ui
    522                 .add(egui::DragValue::new(&mut oz).speed(0.1).prefix("z:"))
    523                 .changed();
    524             x || y || z
    525         })
    526         .inner;
    527     obj.position = base + Vec3::new(ox, oy, oz);
    528 
    529     // Editable scale
    530     let mut sx = obj.scale.x;
    531     let mut sy = obj.scale.y;
    532     let mut sz = obj.scale.z;
    533     let scale_changed = ui
    534         .horizontal(|ui| {
    535             ui.label("Scale:");
    536             let x = ui
    537                 .add(
    538                     egui::DragValue::new(&mut sx)
    539                         .speed(0.05)
    540                         .prefix("x:")
    541                         .range(0.01..=100.0),
    542                 )
    543                 .changed();
    544             let y = ui
    545                 .add(
    546                     egui::DragValue::new(&mut sy)
    547                         .speed(0.05)
    548                         .prefix("y:")
    549                         .range(0.01..=100.0),
    550                 )
    551                 .changed();
    552             let z = ui
    553                 .add(
    554                     egui::DragValue::new(&mut sz)
    555                         .speed(0.05)
    556                         .prefix("z:")
    557                         .range(0.01..=100.0),
    558                 )
    559                 .changed();
    560             x || y || z
    561         })
    562         .inner;
    563     obj.scale = Vec3::new(sx, sy, sz);
    564 
    565     // Editable Y rotation (degrees)
    566     let (angle_y, _, _) = obj.rotation.to_euler(glam::EulerRot::YXZ);
    567     let mut deg = angle_y.to_degrees();
    568     let rot_changed = ui
    569         .horizontal(|ui| {
    570             ui.label("Rot Y:");
    571             let speed = if grid_snap_enabled {
    572                 rotation_snap
    573             } else {
    574                 1.0
    575             };
    576             ui.add(egui::DragValue::new(&mut deg).speed(speed).suffix("°"))
    577                 .changed()
    578         })
    579         .inner;
    580     if rot_changed {
    581         if grid_snap_enabled {
    582             deg = (deg / rotation_snap).round() * rotation_snap;
    583         }
    584         obj.rotation = Quat::from_rotation_y(deg.to_radians());
    585     }
    586 
    587     // Model URL (read-only for now)
    588     if let Some(url) = &obj.model_url {
    589         ui.add_space(4.0);
    590         ui.small(format!("Model: {}", url));
    591     }
    592 
    593     let changed = name_changed || pos_changed || scale_changed || rot_changed;
    594 
    595     ui.add_space(8.0);
    596     ui.horizontal(|ui| {
    597         if ui.button("Duplicate").clicked() {
    598             action = Some(NostrverseAction::DuplicateObject(selected_id.to_owned()));
    599         }
    600         if ui.button("Delete").clicked() {
    601             action = Some(NostrverseAction::RemoveObject(selected_id.to_owned()));
    602         }
    603     });
    604 
    605     (action, changed)
    606 }
    607 
    608 /// Render grid snap and rotation snap controls.
    609 fn render_grid_snap_controls(ui: &mut Ui, state: &mut NostrverseState) {
    610     ui.horizontal(|ui| {
    611         ui.checkbox(&mut state.grid_snap_enabled, "Grid Snap (G)");
    612         if state.grid_snap_enabled {
    613             ui.add(
    614                 egui::DragValue::new(&mut state.grid_snap)
    615                     .speed(0.05)
    616                     .range(0.05..=10.0)
    617                     .suffix("m"),
    618             );
    619         }
    620     });
    621     if state.grid_snap_enabled {
    622         ui.horizontal(|ui| {
    623             ui.label("  Rot snap:");
    624             ui.add(
    625                 egui::DragValue::new(&mut state.rotation_snap)
    626                     .speed(1.0)
    627                     .range(1.0..=90.0)
    628                     .suffix("°"),
    629             );
    630         });
    631     }
    632 }
    633 
    634 /// Render the syntax-highlighted scene source preview.
    635 fn render_scene_preview(ui: &mut Ui, state: &mut NostrverseState) {
    636     // Only re-serialize when not actively dragging an object
    637     if state.drag_state.is_none()
    638         && let Some(info) = &state.space
    639     {
    640         let space = convert::build_space(info, &state.objects);
    641         state.cached_scene_text = protoverse::serialize(&space);
    642     }
    643 
    644     ui.add_space(12.0);
    645     ui.strong("Scene");
    646     ui.separator();
    647     if !state.cached_scene_text.is_empty() {
    648         let layout_job = highlight_sexp(&state.cached_scene_text, ui);
    649         let code_bg = if ui.visuals().dark_mode {
    650             Color32::from_rgb(0x1E, 0x1C, 0x19)
    651         } else {
    652             Color32::from_rgb(0xF5, 0xF0, 0xEB)
    653         };
    654         egui::Frame::default()
    655             .fill(code_bg)
    656             .inner_margin(6.0)
    657             .corner_radius(4.0)
    658             .show(ui, |ui| {
    659                 ui.add(egui::Label::new(layout_job).wrap());
    660             });
    661     }
    662 }
    663 
    664 /// Render the side panel with space editing, object list, and object inspector.
    665 pub fn render_editing_panel(ui: &mut Ui, state: &mut NostrverseState) -> Option<NostrverseAction> {
    666     let mut action = None;
    667 
    668     // --- Space Properties ---
    669     if let Some(info) = &mut state.space {
    670         ui.strong("Space");
    671         ui.separator();
    672 
    673         let name_changed = ui
    674             .horizontal(|ui| {
    675                 ui.label("Name:");
    676                 ui.text_edit_singleline(&mut info.name).changed()
    677             })
    678             .inner;
    679 
    680         if name_changed {
    681             state.dirty = true;
    682         }
    683 
    684         ui.add_space(8.0);
    685     }
    686 
    687     // --- Object List ---
    688     if let Some(a) = render_object_list(ui, state) {
    689         action = Some(a);
    690     }
    691 
    692     ui.add_space(12.0);
    693 
    694     // --- Object Inspector ---
    695     if let Some(selected_id) = state.selected_object.clone()
    696         && let Some(obj) = state.objects.iter_mut().find(|o| o.id == selected_id)
    697     {
    698         let (inspector_action, changed) = render_object_inspector(
    699             ui,
    700             &selected_id,
    701             obj,
    702             state.grid_snap_enabled,
    703             state.rotation_snap,
    704         );
    705         if let Some(a) = inspector_action {
    706             action = Some(a);
    707         }
    708         if changed {
    709             state.dirty = true;
    710         }
    711     }
    712 
    713     // --- Grid Snap ---
    714     ui.add_space(8.0);
    715     render_grid_snap_controls(ui, state);
    716 
    717     // --- Save / Reset buttons ---
    718     ui.add_space(12.0);
    719     ui.separator();
    720     ui.horizontal(|ui| {
    721         let save_label = if state.dirty { "Save *" } else { "Save" };
    722         if ui
    723             .add_enabled(state.dirty, egui::Button::new(save_label))
    724             .clicked()
    725         {
    726             action = Some(NostrverseAction::SaveSpace);
    727         }
    728         if ui.button("Reset").clicked() {
    729             action = Some(NostrverseAction::ResetSpace);
    730         }
    731     });
    732 
    733     // --- Scene body ---
    734     render_scene_preview(ui, state);
    735 
    736     action
    737 }
    738 
    739 // --- S-expression syntax highlighting ---
    740 
    741 #[derive(Clone, Copy)]
    742 enum SexpToken {
    743     Paren,
    744     Keyword,
    745     Symbol,
    746     String,
    747     Number,
    748     Whitespace,
    749 }
    750 
    751 /// Tokenize S-expression text for highlighting, preserving all characters.
    752 fn tokenize_sexp(input: &str) -> Vec<(SexpToken, &str)> {
    753     let bytes = input.as_bytes();
    754     let mut tokens = Vec::new();
    755     let mut i = 0;
    756 
    757     while i < bytes.len() {
    758         let start = i;
    759         match bytes[i] {
    760             b'(' | b')' => {
    761                 tokens.push((SexpToken::Paren, &input[i..i + 1]));
    762                 i += 1;
    763             }
    764             b'"' => {
    765                 i += 1;
    766                 while i < bytes.len() && bytes[i] != b'"' {
    767                     if bytes[i] == b'\\' {
    768                         i += 1;
    769                     }
    770                     i += 1;
    771                 }
    772                 if i < bytes.len() {
    773                     i += 1; // closing quote
    774                 }
    775                 tokens.push((SexpToken::String, &input[start..i]));
    776             }
    777             c if c.is_ascii_whitespace() => {
    778                 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
    779                     i += 1;
    780                 }
    781                 tokens.push((SexpToken::Whitespace, &input[start..i]));
    782             }
    783             c if c.is_ascii_digit()
    784                 || (c == b'-' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit()) =>
    785             {
    786                 while i < bytes.len()
    787                     && (bytes[i].is_ascii_digit() || bytes[i] == b'.' || bytes[i] == b'-')
    788                 {
    789                     i += 1;
    790                 }
    791                 tokens.push((SexpToken::Number, &input[start..i]));
    792             }
    793             c if c.is_ascii_alphabetic() || c == b'-' || c == b'_' => {
    794                 while i < bytes.len()
    795                     && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
    796                 {
    797                     i += 1;
    798                 }
    799                 let word = &input[start..i];
    800                 let kind = if is_sexp_keyword(word) {
    801                     SexpToken::Keyword
    802                 } else {
    803                     SexpToken::Symbol
    804                 };
    805                 tokens.push((kind, word));
    806             }
    807             _ => {
    808                 tokens.push((SexpToken::Symbol, &input[i..i + 1]));
    809                 i += 1;
    810             }
    811         }
    812     }
    813     tokens
    814 }
    815 
    816 fn is_sexp_keyword(word: &str) -> bool {
    817     matches!(
    818         word,
    819         "room"
    820             | "space"
    821             | "group"
    822             | "table"
    823             | "chair"
    824             | "door"
    825             | "light"
    826             | "prop"
    827             | "tilemap"
    828             | "tileset"
    829             | "data"
    830             | "name"
    831             | "id"
    832             | "shape"
    833             | "width"
    834             | "height"
    835             | "depth"
    836             | "position"
    837             | "rotation"
    838             | "location"
    839             | "model-url"
    840             | "material"
    841             | "condition"
    842             | "state"
    843             | "type"
    844     )
    845 }
    846 
    847 /// Build a syntax-highlighted LayoutJob from S-expression text.
    848 fn highlight_sexp(code: &str, ui: &Ui) -> egui::text::LayoutJob {
    849     let font_id = ui
    850         .style()
    851         .override_font_id
    852         .clone()
    853         .unwrap_or_else(|| egui::TextStyle::Monospace.resolve(ui.style()));
    854 
    855     let dark = ui.visuals().dark_mode;
    856 
    857     let paren_color = if dark {
    858         Color32::from_rgb(0xA0, 0x96, 0x88)
    859     } else {
    860         Color32::from_rgb(0x6E, 0x64, 0x56)
    861     };
    862     let keyword_color = if dark {
    863         Color32::from_rgb(0xD4, 0xA5, 0x74)
    864     } else {
    865         Color32::from_rgb(0x9A, 0x60, 0x2A)
    866     };
    867     let symbol_color = if dark {
    868         Color32::from_rgb(0xD5, 0xCE, 0xC4)
    869     } else {
    870         Color32::from_rgb(0x3A, 0x35, 0x2E)
    871     };
    872     let string_color = if dark {
    873         Color32::from_rgb(0xC6, 0xB4, 0x6A)
    874     } else {
    875         Color32::from_rgb(0x6B, 0x5C, 0x1A)
    876     };
    877     let number_color = if dark {
    878         Color32::from_rgb(0xC4, 0x8A, 0x6A)
    879     } else {
    880         Color32::from_rgb(0x8B, 0x4C, 0x30)
    881     };
    882 
    883     let mut job = egui::text::LayoutJob::default();
    884     for (token, text) in tokenize_sexp(code) {
    885         let color = match token {
    886             SexpToken::Paren => paren_color,
    887             SexpToken::Keyword => keyword_color,
    888             SexpToken::Symbol => symbol_color,
    889             SexpToken::String => string_color,
    890             SexpToken::Number => number_color,
    891             SexpToken::Whitespace => Color32::TRANSPARENT,
    892         };
    893         job.append(text, 0.0, egui::TextFormat::simple(font_id.clone(), color));
    894     }
    895     job
    896 }