scene.rs (15099B)
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.details.display_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 &session.details.home_dir, 181 is_selected, 182 ctrl_held, 183 queue_priority, 184 ); 185 186 if agent_response.clicked() { 187 let shift = ui.input(|i| i.modifiers.shift); 188 clicked_agent = Some((id, shift, position)); 189 } 190 191 if agent_response.dragged() && is_selected { 192 let delta = agent_response.drag_delta(); 193 dragged_agent = Some((id, position + delta)); 194 } 195 } 196 197 // Handle click on empty space to deselect 198 let bg_response = ui.interact( 199 ui.max_rect(), 200 ui.id().with("scene_bg"), 201 Sense::click_and_drag(), 202 ); 203 204 if bg_response.clicked() && clicked_agent.is_none() { 205 bg_clicked = true; 206 } 207 208 if bg_response.drag_started() && clicked_agent.is_none() { 209 bg_drag_started = true; 210 } 211 }); 212 213 // Get the viewport rect for coordinate transforms 214 let viewport_rect = scene_response.response.rect; 215 216 self.scene_rect = scene_rect; 217 218 // Process agent click 219 if let Some((id, shift, _position)) = clicked_agent { 220 if shift { 221 self.add_to_selection(id); 222 } else { 223 self.select(id); 224 } 225 response = SceneResponse::new(SceneAction::SelectionChanged(self.selected.clone())); 226 } 227 228 // Process agent drag 229 if let Some((id, new_pos)) = dragged_agent { 230 response = SceneResponse::new(SceneAction::AgentMoved { 231 id, 232 position: new_pos, 233 }); 234 } 235 236 // Process background click 237 if bg_clicked && response.action.is_none() && !self.selected.is_empty() { 238 self.selected.clear(); 239 response = SceneResponse::new(SceneAction::SelectionChanged(Vec::new())); 240 } 241 242 // Start drag selection 243 if bg_drag_started { 244 if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) { 245 self.drag_select = Some(DragSelect { 246 start: pos, 247 current: pos, 248 }); 249 } 250 } 251 252 // Update drag selection position 253 if let Some(drag) = &mut self.drag_select { 254 if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) { 255 drag.current = pos; 256 } 257 } 258 259 // Handle keyboard input (only when no text input has focus) 260 // Note: N key for spawning agents is handled globally in keybindings.rs 261 if !ui.ctx().wants_keyboard_input() 262 && ui.input(|i| i.key_pressed(egui::Key::Delete)) 263 && !self.selected.is_empty() 264 { 265 response = SceneResponse::new(SceneAction::DeleteSelected); 266 } 267 268 // Handle box selection completion 269 if let Some(drag) = &self.drag_select { 270 if ui.input(|i| i.pointer.primary_released()) { 271 // Convert screen-space drag coordinates to scene-space 272 // Screen -> Scene: scene_pos = scene_rect.min + (screen_pos - viewport.min) / viewport.size() * scene_rect.size() 273 let screen_to_scene = |screen_pos: Pos2| -> Pos2 { 274 let rel = (screen_pos - viewport_rect.min) / viewport_rect.size(); 275 scene_rect.min + rel * scene_rect.size() 276 }; 277 278 let scene_start = screen_to_scene(drag.start); 279 let scene_current = screen_to_scene(drag.current); 280 let selection_rect = Rect::from_two_pos(scene_start, scene_current); 281 282 self.selected.clear(); 283 284 for session in session_manager.iter() { 285 if let Some(agentic) = &session.agentic { 286 let agent_pos = 287 Pos2::new(agentic.scene_position.x, agentic.scene_position.y); 288 if selection_rect.contains(agent_pos) { 289 self.selected.push(session.id); 290 } 291 } 292 } 293 294 if !self.selected.is_empty() { 295 response = 296 SceneResponse::new(SceneAction::SelectionChanged(self.selected.clone())); 297 } 298 299 self.drag_select = None; 300 } 301 } 302 303 // Draw box selection overlay 304 if let Some(drag) = &self.drag_select { 305 let rect = Rect::from_two_pos(drag.start, drag.current); 306 let painter = ui.painter(); 307 painter.rect_filled( 308 rect, 309 0.0, 310 Color32::from_rgba_unmultiplied(100, 150, 255, 30), 311 ); 312 painter.rect_stroke( 313 rect, 314 0.0, 315 egui::Stroke::new(1.0, Color32::from_rgb(100, 150, 255)), 316 egui::StrokeKind::Outside, 317 ); 318 } 319 320 response 321 } 322 323 /// Draw a single agent unit and return the interaction Response 324 /// `keybind_number` is the 1-indexed number displayed when Ctrl is held (matches Ctrl+N keybindings) 325 #[allow(clippy::too_many_arguments)] 326 fn draw_agent( 327 ui: &mut egui::Ui, 328 id: SessionId, 329 keybind_number: usize, 330 position: Vec2, 331 status: AgentStatus, 332 title: &str, 333 cwd: &Path, 334 home_dir: &str, 335 is_selected: bool, 336 show_keybinding: bool, 337 queue_priority: Option<FocusPriority>, 338 ) -> Response { 339 let agent_radius = 30.0; 340 let center = Pos2::new(position.x, position.y); 341 let agent_rect = Rect::from_center_size(center, Vec2::splat(agent_radius * 2.0)); 342 343 // Interact with the agent 344 let response = ui.interact( 345 agent_rect, 346 ui.id().with(("agent", id)), 347 Sense::click_and_drag(), 348 ); 349 350 let painter = ui.painter(); 351 352 // Selection highlight (outer ring) 353 if is_selected { 354 painter.circle_stroke( 355 center, 356 agent_radius + 4.0, 357 egui::Stroke::new(3.0, Color32::from_rgb(255, 255, 100)), 358 ); 359 } 360 361 // Status ring 362 let status_color = status.color(); 363 painter.circle_stroke(center, agent_radius, egui::Stroke::new(3.0, status_color)); 364 365 // Fill 366 let fill_color = if response.hovered() { 367 ui.visuals().widgets.hovered.bg_fill 368 } else { 369 ui.visuals().widgets.inactive.bg_fill 370 }; 371 painter.circle_filled(center, agent_radius - 2.0, fill_color); 372 373 // Focus queue indicator dot (top-right of the agent circle) 374 if let Some(priority) = queue_priority { 375 let dot_radius = 6.0; 376 let dot_offset = Vec2::new(agent_radius * 0.7, -agent_radius * 0.7); 377 let dot_center = center + dot_offset; 378 painter.circle_filled(dot_center, dot_radius, priority.color()); 379 } 380 381 // Agent icon in center: show keybind frame when Ctrl held, otherwise first letter 382 if show_keybinding { 383 paint_keybind_hint(ui, center, &keybind_number.to_string(), 24.0); 384 } else { 385 let icon_text: String = title.chars().next().unwrap_or('?').to_uppercase().collect(); 386 painter.text( 387 center, 388 egui::Align2::CENTER_CENTER, 389 &icon_text, 390 egui::FontId::proportional(20.0), 391 ui.visuals().text_color(), 392 ); 393 } 394 395 // Title below 396 let title_pos = center + Vec2::new(0.0, agent_radius + 10.0); 397 painter.text( 398 title_pos, 399 egui::Align2::CENTER_TOP, 400 title, 401 egui::FontId::proportional(11.0), 402 ui.visuals().text_color().gamma_multiply(0.8), 403 ); 404 405 // Status label 406 let status_pos = center + Vec2::new(0.0, agent_radius + 24.0); 407 painter.text( 408 status_pos, 409 egui::Align2::CENTER_TOP, 410 status.label(), 411 egui::FontId::proportional(9.0), 412 status_color.gamma_multiply(0.9), 413 ); 414 415 // Cwd label (monospace, weak+small) 416 let cwd_text = if home_dir.is_empty() { 417 crate::path_utils::abbreviate_path(cwd) 418 } else { 419 crate::path_utils::abbreviate_with_home(cwd, home_dir) 420 }; 421 let cwd_pos = center + Vec2::new(0.0, agent_radius + 38.0); 422 painter.text( 423 cwd_pos, 424 egui::Align2::CENTER_TOP, 425 &cwd_text, 426 egui::FontId::monospace(8.0), 427 ui.visuals().weak_text_color(), 428 ); 429 430 response 431 } 432 } 433 434 /// Easing function for smooth camera animation 435 fn ease_out_cubic(t: f32) -> f32 { 436 1.0 - (1.0 - t).powi(3) 437 }