scene.rs (14854B)
1 use std::path::Path; 2 3 use crate::agent_status::AgentStatus; 4 use crate::focus_queue::{FocusPriority, FocusQueue}; 5 use crate::session::{SessionId, SessionManager}; 6 use crate::ui::paint_keybind_hint; 7 use egui::{Color32, Pos2, Rect, Response, Sense, Vec2}; 8 9 /// The RTS-style scene view for managing agents 10 pub struct AgentScene { 11 /// Camera/view transform state managed by egui::Scene 12 scene_rect: Rect, 13 /// Currently selected agent IDs 14 pub selected: Vec<SessionId>, 15 /// Drag selection state 16 drag_select: Option<DragSelect>, 17 /// Target camera position for smooth animation 18 camera_target: Option<Vec2>, 19 /// Animation progress (0.0 to 1.0) 20 animation_progress: f32, 21 } 22 23 /// State for box/marquee selection 24 struct DragSelect { 25 start: Pos2, 26 current: Pos2, 27 } 28 29 /// Action generated by the scene UI 30 #[derive(Debug, Clone)] 31 pub enum SceneAction { 32 /// Selection changed 33 SelectionChanged(Vec<SessionId>), 34 /// Request to spawn a new agent 35 SpawnAgent, 36 /// Request to delete selected agents 37 DeleteSelected, 38 /// Agent was dragged to new position 39 AgentMoved { id: SessionId, position: Vec2 }, 40 } 41 42 /// Response from scene rendering 43 #[derive(Default)] 44 pub struct SceneResponse { 45 pub action: Option<SceneAction>, 46 } 47 48 impl SceneResponse { 49 pub fn new(action: SceneAction) -> Self { 50 Self { 51 action: Some(action), 52 } 53 } 54 } 55 56 impl Default for AgentScene { 57 fn default() -> Self { 58 Self::new() 59 } 60 } 61 62 impl AgentScene { 63 pub fn new() -> Self { 64 Self { 65 scene_rect: Rect::from_min_max(Pos2::new(-500.0, -500.0), Pos2::new(500.0, 500.0)), 66 selected: Vec::new(), 67 drag_select: None, 68 camera_target: None, 69 animation_progress: 1.0, 70 } 71 } 72 73 /// Check if an agent is selected 74 pub fn is_selected(&self, id: SessionId) -> bool { 75 self.selected.contains(&id) 76 } 77 78 /// Set selection to a single agent 79 pub fn select(&mut self, id: SessionId) { 80 self.selected.clear(); 81 self.selected.push(id); 82 } 83 84 /// Add an agent to the selection 85 pub fn add_to_selection(&mut self, id: SessionId) { 86 if !self.selected.contains(&id) { 87 self.selected.push(id); 88 } 89 } 90 91 /// Clear all selection 92 pub fn clear_selection(&mut self) { 93 self.selected.clear(); 94 } 95 96 /// Get the first selected agent (for chat panel) 97 pub fn primary_selection(&self) -> Option<SessionId> { 98 self.selected.first().copied() 99 } 100 101 /// Animate camera to focus on a position 102 pub fn focus_on(&mut self, position: Vec2) { 103 self.camera_target = Some(position); 104 self.animation_progress = 0.0; 105 } 106 107 /// Render the scene 108 pub fn ui( 109 &mut self, 110 session_manager: &SessionManager, 111 focus_queue: &FocusQueue, 112 ui: &mut egui::Ui, 113 ctrl_held: bool, 114 ) -> SceneResponse { 115 let mut response = SceneResponse::default(); 116 117 // Update camera animation towards target 118 if let Some(target) = self.camera_target { 119 if self.animation_progress < 1.0 { 120 self.animation_progress += 0.08; 121 self.animation_progress = self.animation_progress.min(1.0); 122 123 // Smoothly interpolate scene_rect center towards target 124 let current_center = self.scene_rect.center(); 125 let target_pos = Pos2::new(target.x, target.y); 126 let t = ease_out_cubic(self.animation_progress); 127 let new_center = current_center.lerp(target_pos, t); 128 129 // Shift the scene_rect to center on new position 130 let offset = new_center - current_center; 131 self.scene_rect = self.scene_rect.translate(offset); 132 133 ui.ctx().request_repaint(); 134 } else { 135 // Animation complete 136 self.camera_target = None; 137 } 138 } 139 140 // Track interactions from inside the scene closure 141 let mut clicked_agent: Option<(SessionId, bool, Vec2)> = None; // (id, shift_held, position) 142 let mut dragged_agent: Option<(SessionId, Vec2)> = None; // (id, new_position) 143 let mut bg_clicked = false; 144 let mut bg_drag_started = false; 145 146 // Use a local copy of scene_rect to avoid borrow conflict 147 let mut scene_rect = self.scene_rect; 148 let selected_ids = &self.selected; 149 150 let scene_response = 151 egui::Scene::new() 152 .zoom_range(0.1..=1.0) 153 .show(ui, &mut scene_rect, |ui| { 154 // Draw agents and collect interaction responses 155 // Use sessions_ordered() to match keybinding order (Ctrl+1 = first in order, etc.) 156 for (keybind_idx, session) in 157 session_manager.sessions_ordered().into_iter().enumerate() 158 { 159 // Scene view only makes sense for agentic sessions 160 let Some(agentic) = &session.agentic else { 161 continue; 162 }; 163 164 let id = session.id; 165 let keybind_number = keybind_idx + 1; // 1-indexed for display 166 let position = agentic.scene_position; 167 let status = session.status(); 168 let title = &session.title; 169 let is_selected = selected_ids.contains(&id); 170 let queue_priority = focus_queue.get_session_priority(id); 171 172 let agent_response = Self::draw_agent( 173 ui, 174 id, 175 keybind_number, 176 position, 177 status, 178 title, 179 &agentic.cwd, 180 is_selected, 181 ctrl_held, 182 queue_priority, 183 ); 184 185 if agent_response.clicked() { 186 let shift = ui.input(|i| i.modifiers.shift); 187 clicked_agent = Some((id, shift, position)); 188 } 189 190 if agent_response.dragged() && is_selected { 191 let delta = agent_response.drag_delta(); 192 dragged_agent = Some((id, position + delta)); 193 } 194 } 195 196 // Handle click on empty space to deselect 197 let bg_response = ui.interact( 198 ui.max_rect(), 199 ui.id().with("scene_bg"), 200 Sense::click_and_drag(), 201 ); 202 203 if bg_response.clicked() && clicked_agent.is_none() { 204 bg_clicked = true; 205 } 206 207 if bg_response.drag_started() && clicked_agent.is_none() { 208 bg_drag_started = true; 209 } 210 }); 211 212 // Get the viewport rect for coordinate transforms 213 let viewport_rect = scene_response.response.rect; 214 215 self.scene_rect = scene_rect; 216 217 // Process agent click 218 if let Some((id, shift, _position)) = clicked_agent { 219 if shift { 220 self.add_to_selection(id); 221 } else { 222 self.select(id); 223 } 224 response = SceneResponse::new(SceneAction::SelectionChanged(self.selected.clone())); 225 } 226 227 // Process agent drag 228 if let Some((id, new_pos)) = dragged_agent { 229 response = SceneResponse::new(SceneAction::AgentMoved { 230 id, 231 position: new_pos, 232 }); 233 } 234 235 // Process background click 236 if bg_clicked && response.action.is_none() && !self.selected.is_empty() { 237 self.selected.clear(); 238 response = SceneResponse::new(SceneAction::SelectionChanged(Vec::new())); 239 } 240 241 // Start drag selection 242 if bg_drag_started { 243 if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) { 244 self.drag_select = Some(DragSelect { 245 start: pos, 246 current: pos, 247 }); 248 } 249 } 250 251 // Update drag selection position 252 if let Some(drag) = &mut self.drag_select { 253 if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) { 254 drag.current = pos; 255 } 256 } 257 258 // Handle keyboard input (only when no text input has focus) 259 // Note: N key for spawning agents is handled globally in keybindings.rs 260 if !ui.ctx().wants_keyboard_input() 261 && ui.input(|i| i.key_pressed(egui::Key::Delete)) 262 && !self.selected.is_empty() 263 { 264 response = SceneResponse::new(SceneAction::DeleteSelected); 265 } 266 267 // Handle box selection completion 268 if let Some(drag) = &self.drag_select { 269 if ui.input(|i| i.pointer.primary_released()) { 270 // Convert screen-space drag coordinates to scene-space 271 // Screen -> Scene: scene_pos = scene_rect.min + (screen_pos - viewport.min) / viewport.size() * scene_rect.size() 272 let screen_to_scene = |screen_pos: Pos2| -> Pos2 { 273 let rel = (screen_pos - viewport_rect.min) / viewport_rect.size(); 274 scene_rect.min + rel * scene_rect.size() 275 }; 276 277 let scene_start = screen_to_scene(drag.start); 278 let scene_current = screen_to_scene(drag.current); 279 let selection_rect = Rect::from_two_pos(scene_start, scene_current); 280 281 self.selected.clear(); 282 283 for session in session_manager.iter() { 284 if let Some(agentic) = &session.agentic { 285 let agent_pos = 286 Pos2::new(agentic.scene_position.x, agentic.scene_position.y); 287 if selection_rect.contains(agent_pos) { 288 self.selected.push(session.id); 289 } 290 } 291 } 292 293 if !self.selected.is_empty() { 294 response = 295 SceneResponse::new(SceneAction::SelectionChanged(self.selected.clone())); 296 } 297 298 self.drag_select = None; 299 } 300 } 301 302 // Draw box selection overlay 303 if let Some(drag) = &self.drag_select { 304 let rect = Rect::from_two_pos(drag.start, drag.current); 305 let painter = ui.painter(); 306 painter.rect_filled( 307 rect, 308 0.0, 309 Color32::from_rgba_unmultiplied(100, 150, 255, 30), 310 ); 311 painter.rect_stroke( 312 rect, 313 0.0, 314 egui::Stroke::new(1.0, Color32::from_rgb(100, 150, 255)), 315 egui::StrokeKind::Outside, 316 ); 317 } 318 319 response 320 } 321 322 /// Draw a single agent unit and return the interaction Response 323 /// `keybind_number` is the 1-indexed number displayed when Ctrl is held (matches Ctrl+N keybindings) 324 #[allow(clippy::too_many_arguments)] 325 fn draw_agent( 326 ui: &mut egui::Ui, 327 id: SessionId, 328 keybind_number: usize, 329 position: Vec2, 330 status: AgentStatus, 331 title: &str, 332 cwd: &Path, 333 is_selected: bool, 334 show_keybinding: bool, 335 queue_priority: Option<FocusPriority>, 336 ) -> Response { 337 let agent_radius = 30.0; 338 let center = Pos2::new(position.x, position.y); 339 let agent_rect = Rect::from_center_size(center, Vec2::splat(agent_radius * 2.0)); 340 341 // Interact with the agent 342 let response = ui.interact( 343 agent_rect, 344 ui.id().with(("agent", id)), 345 Sense::click_and_drag(), 346 ); 347 348 let painter = ui.painter(); 349 350 // Selection highlight (outer ring) 351 if is_selected { 352 painter.circle_stroke( 353 center, 354 agent_radius + 4.0, 355 egui::Stroke::new(3.0, Color32::from_rgb(255, 255, 100)), 356 ); 357 } 358 359 // Status ring 360 let status_color = status.color(); 361 painter.circle_stroke(center, agent_radius, egui::Stroke::new(3.0, status_color)); 362 363 // Fill 364 let fill_color = if response.hovered() { 365 ui.visuals().widgets.hovered.bg_fill 366 } else { 367 ui.visuals().widgets.inactive.bg_fill 368 }; 369 painter.circle_filled(center, agent_radius - 2.0, fill_color); 370 371 // Focus queue indicator dot (top-right of the agent circle) 372 if let Some(priority) = queue_priority { 373 let dot_radius = 6.0; 374 let dot_offset = Vec2::new(agent_radius * 0.7, -agent_radius * 0.7); 375 let dot_center = center + dot_offset; 376 painter.circle_filled(dot_center, dot_radius, priority.color()); 377 } 378 379 // Agent icon in center: show keybind frame when Ctrl held, otherwise first letter 380 if show_keybinding { 381 paint_keybind_hint(ui, center, &keybind_number.to_string(), 24.0); 382 } else { 383 let icon_text: String = title.chars().next().unwrap_or('?').to_uppercase().collect(); 384 painter.text( 385 center, 386 egui::Align2::CENTER_CENTER, 387 &icon_text, 388 egui::FontId::proportional(20.0), 389 ui.visuals().text_color(), 390 ); 391 } 392 393 // Title below 394 let title_pos = center + Vec2::new(0.0, agent_radius + 10.0); 395 painter.text( 396 title_pos, 397 egui::Align2::CENTER_TOP, 398 title, 399 egui::FontId::proportional(11.0), 400 ui.visuals().text_color().gamma_multiply(0.8), 401 ); 402 403 // Status label 404 let status_pos = center + Vec2::new(0.0, agent_radius + 24.0); 405 painter.text( 406 status_pos, 407 egui::Align2::CENTER_TOP, 408 status.label(), 409 egui::FontId::proportional(9.0), 410 status_color.gamma_multiply(0.9), 411 ); 412 413 // Cwd label (monospace, weak+small) 414 let cwd_text = cwd.to_string_lossy(); 415 let cwd_pos = center + Vec2::new(0.0, agent_radius + 38.0); 416 painter.text( 417 cwd_pos, 418 egui::Align2::CENTER_TOP, 419 &cwd_text, 420 egui::FontId::monospace(8.0), 421 ui.visuals().weak_text_color(), 422 ); 423 424 response 425 } 426 } 427 428 /// Easing function for smooth camera animation 429 fn ease_out_cubic(t: f32) -> f32 { 430 1.0 - (1.0 - t).powi(3) 431 }