notedeck

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

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 }