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 }