notedeck

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

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 }