notedeck

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

commit 3ec5b203e903f6be11a06c26cb0677528750bc15
parent c10d594f62a33346c63d30925ed0a0eef76f2f67
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 26 Jan 2026 11:46:12 -0800

dave: add RTS-style scene view for multi-agent visualization

Implements a 2D scene with pan/zoom using egui::Scene where agents
are displayed as interactive units. Features include:

- Agent status visualization (idle/working/needs-input/error/done)
- Click/shift-click/box selection for agents
- Drag to reposition agents in scene
- Chat side panel for selected agent interaction
- Camera auto-jumps to agents needing attention (permission requests)
- Toggle between scene view and classic chat view

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
Acrates/notedeck_dave/src/agent_status.rs | 39+++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/lib.rs | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/notedeck_dave/src/session.rs | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/mod.rs | 2++
Acrates/notedeck_dave/src/ui/scene.rs | 454+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 721 insertions(+), 3 deletions(-)

diff --git a/crates/notedeck_dave/src/agent_status.rs b/crates/notedeck_dave/src/agent_status.rs @@ -0,0 +1,39 @@ +/// Represents the current status of an agent in the RTS scene +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AgentStatus { + /// Agent is idle, no active work + #[default] + Idle, + /// Agent is actively processing (receiving tokens, executing tools) + Working, + /// Agent needs user input (permission request pending) + NeedsInput, + /// Agent encountered an error + Error, + /// Agent completed its task successfully + Done, +} + +impl AgentStatus { + /// Get the color associated with this status + pub fn color(&self) -> egui::Color32 { + match self { + AgentStatus::Idle => egui::Color32::from_rgb(128, 128, 128), // Gray + AgentStatus::Working => egui::Color32::from_rgb(50, 205, 50), // Green + AgentStatus::NeedsInput => egui::Color32::from_rgb(255, 200, 0), // Yellow/amber + AgentStatus::Error => egui::Color32::from_rgb(220, 60, 60), // Red + AgentStatus::Done => egui::Color32::from_rgb(70, 130, 220), // Blue + } + } + + /// Get a human-readable label for this status + pub fn label(&self) -> &'static str { + match self { + AgentStatus::Idle => "Idle", + AgentStatus::Working => "Working", + AgentStatus::NeedsInput => "Needs Input", + AgentStatus::Error => "Error", + AgentStatus::Done => "Done", + } + } +} diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -1,3 +1,4 @@ +mod agent_status; mod avatar; mod backend; mod config; @@ -31,8 +32,8 @@ pub use tools::{ ToolResponses, }; pub use ui::{ - DaveAction, DaveResponse, DaveSettingsPanel, DaveUi, SessionListAction, SessionListUi, - SettingsPanelAction, + AgentScene, DaveAction, DaveResponse, DaveSettingsPanel, DaveUi, SceneAction, SceneResponse, + SessionListAction, SessionListUi, SettingsPanelAction, }; pub use vec3::Vec3; @@ -53,6 +54,10 @@ pub struct Dave { settings: DaveSettings, /// Settings panel UI state settings_panel: DaveSettingsPanel, + /// RTS-style scene view + scene: AgentScene, + /// Whether to show scene view (vs classic chat view) + show_scene: bool, } /// Calculate an anonymous user_id from a keypair @@ -134,6 +139,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr show_session_list: false, settings, settings_panel: DaveSettingsPanel::new(), + scene: AgentScene::new(), + show_scene: true, // Default to scene view } } @@ -259,11 +266,122 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr fn ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { if is_narrow(ui.ctx()) { self.narrow_ui(app_ctx, ui) + } else if self.show_scene { + self.scene_ui(app_ctx, ui) } else { self.desktop_ui(app_ctx, ui) } } + /// Scene view with RTS-style agent visualization and chat side panel + fn scene_ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { + let mut dave_response = DaveResponse::default(); + let available = ui.available_rect_before_wrap(); + let panel_width = 400.0; + + // Scene area (main) + let scene_rect = egui::Rect::from_min_size( + available.min, + egui::vec2(available.width() - panel_width, available.height()), + ); + + // Chat panel area (right side) + let panel_rect = egui::Rect::from_min_size( + egui::pos2(available.max.x - panel_width, available.min.y), + egui::vec2(panel_width, available.height()), + ); + + // Update all session statuses + self.session_manager.update_all_statuses(); + + // Check for agents needing attention and auto-jump to them + if let Some(attention_id) = self.scene.check_attention(&self.session_manager) { + // Also sync with session manager's active session + self.session_manager.switch_to(attention_id); + } + + // Render scene + let scene_response = ui + .allocate_new_ui(egui::UiBuilder::new().max_rect(scene_rect), |ui| { + // Scene toolbar at top + ui.horizontal(|ui| { + if ui.button("+ New Agent").clicked() { + dave_response = DaveResponse::new(DaveAction::NewChat); + } + ui.separator(); + if ui.button("Classic View").clicked() { + self.show_scene = false; + } + }); + ui.separator(); + + // Render the scene + self.scene.ui(&self.session_manager, ui) + }) + .inner; + + // Handle scene actions + if let Some(action) = scene_response.action { + match action { + SceneAction::SelectionChanged(ids) => { + // Selection updated, sync with session manager's active + if let Some(id) = ids.first() { + self.session_manager.switch_to(*id); + } + } + SceneAction::SpawnAgent => { + dave_response = DaveResponse::new(DaveAction::NewChat); + } + SceneAction::DeleteSelected => { + for id in self.scene.selected.clone() { + self.session_manager.delete_session(id); + } + self.scene.clear_selection(); + } + SceneAction::AgentMoved { id, position } => { + if let Some(session) = self.session_manager.get_mut(id) { + session.scene_position = position; + } + } + } + } + + // Render chat side panel + ui.allocate_new_ui(egui::UiBuilder::new().max_rect(panel_rect), |ui| { + egui::Frame::new() + .fill(ui.visuals().faint_bg_color) + .inner_margin(egui::Margin::symmetric(8, 12)) + .show(ui, |ui| { + if let Some(selected_id) = self.scene.primary_selection() { + if let Some(session) = self.session_manager.get_mut(selected_id) { + // Show title + ui.heading(&session.title); + ui.separator(); + + // Render chat UI for selected session + let response = DaveUi::new( + self.model_config.trial, + &session.chat, + &mut session.input, + ) + .ui(app_ctx, ui); + + if response.action.is_some() { + dave_response = response; + } + } + } else { + // No selection + ui.centered_and_justified(|ui| { + ui.label("Select an agent to view chat"); + }); + } + }); + }); + + dave_response + } + /// Desktop layout with sidebar for session list fn desktop_ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { let available = ui.available_rect_before_wrap(); @@ -282,7 +400,14 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr egui::Frame::new() .fill(ui.visuals().faint_bg_color) .inner_margin(egui::Margin::symmetric(8, 12)) - .show(ui, |ui| SessionListUi::new(&self.session_manager).ui(ui)) + .show(ui, |ui| { + // Add scene view toggle button + if ui.button("Scene View").clicked() { + self.show_scene = true; + } + ui.separator(); + SessionListUi::new(&self.session_manager).ui(ui) + }) .inner }) .inner; diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::sync::mpsc::Receiver; +use crate::agent_status::AgentStatus; use crate::messages::PermissionResponse; use crate::{DaveApiResponse, Message}; use tokio::sync::oneshot; @@ -20,6 +21,10 @@ pub struct ChatSession { /// Handle to the background task processing this session's AI requests. /// Aborted on drop to clean up the subprocess. pub task_handle: Option<tokio::task::JoinHandle<()>>, + /// Position in the RTS scene (in scene coordinates) + pub scene_position: egui::Vec2, + /// Cached status for the agent (derived from session state) + cached_status: AgentStatus, } impl Drop for ChatSession { @@ -32,6 +37,12 @@ impl Drop for ChatSession { impl ChatSession { pub fn new(id: SessionId) -> Self { + // Arrange sessions in a grid pattern + let col = (id as i32 - 1) % 4; + let row = (id as i32 - 1) / 4; + let x = col as f32 * 150.0 - 225.0; // Center around origin + let y = row as f32 * 150.0 - 75.0; + ChatSession { id, title: "New Chat".to_string(), @@ -40,6 +51,8 @@ impl ChatSession { incoming_tokens: None, pending_permissions: HashMap::new(), task_handle: None, + scene_position: egui::Vec2::new(x, y), + cached_status: AgentStatus::Idle, } } @@ -58,6 +71,49 @@ impl ChatSession { } } } + + /// Get the current status of this session/agent + pub fn status(&self) -> AgentStatus { + self.cached_status + } + + /// Update the cached status based on current session state + pub fn update_status(&mut self) { + self.cached_status = self.derive_status(); + } + + /// Derive status from the current session state + fn derive_status(&self) -> AgentStatus { + // Check for pending permission requests (needs input) + if !self.pending_permissions.is_empty() { + return AgentStatus::NeedsInput; + } + + // Check for error in last message + if let Some(Message::Error(_)) = self.chat.last() { + return AgentStatus::Error; + } + + // Check if actively working (has task handle and receiving tokens) + if self.task_handle.is_some() && self.incoming_tokens.is_some() { + return AgentStatus::Working; + } + + // Check if done (has messages and no active task) + if !self.chat.is_empty() && self.task_handle.is_none() { + // Check if the last meaningful message was from assistant + for msg in self.chat.iter().rev() { + match msg { + Message::Assistant(_) => return AgentStatus::Done, + Message::User(_) => return AgentStatus::Idle, // Waiting for response + Message::Error(_) => return AgentStatus::Error, + _ => continue, + } + } + } + + AgentStatus::Idle + } } /// Manages multiple chat sessions @@ -170,4 +226,46 @@ impl SessionManager { pub fn is_empty(&self) -> bool { self.sessions.is_empty() } + + /// Get a reference to a session by ID + pub fn get(&self, id: SessionId) -> Option<&ChatSession> { + self.sessions.get(&id) + } + + /// Get a mutable reference to a session by ID + pub fn get_mut(&mut self, id: SessionId) -> Option<&mut ChatSession> { + self.sessions.get_mut(&id) + } + + /// Iterate over all sessions + pub fn iter(&self) -> impl Iterator<Item = &ChatSession> { + self.sessions.values() + } + + /// Iterate over all sessions mutably + pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut ChatSession> { + self.sessions.values_mut() + } + + /// Update status for all sessions + pub fn update_all_statuses(&mut self) { + for session in self.sessions.values_mut() { + session.update_status(); + } + } + + /// Get the first session that needs attention (NeedsInput status) + pub fn find_needs_attention(&self) -> Option<SessionId> { + for session in self.sessions.values() { + if session.status() == AgentStatus::NeedsInput { + return Some(session.id); + } + } + None + } + + /// Get all session IDs + pub fn session_ids(&self) -> Vec<SessionId> { + self.order.clone() + } } diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -1,7 +1,9 @@ mod dave; +pub mod scene; pub mod session_list; mod settings; pub use dave::{DaveAction, DaveResponse, DaveUi}; +pub use scene::{AgentScene, SceneAction, SceneResponse}; pub use session_list::{SessionListAction, SessionListUi}; pub use settings::{DaveSettingsPanel, SettingsPanelAction}; diff --git a/crates/notedeck_dave/src/ui/scene.rs b/crates/notedeck_dave/src/ui/scene.rs @@ -0,0 +1,454 @@ +use crate::agent_status::AgentStatus; +use crate::session::{SessionId, SessionManager}; +use egui::{Color32, Pos2, Rect, Response, Sense, Vec2}; + +/// The RTS-style scene view for managing agents +pub struct AgentScene { + /// Camera/view transform state managed by egui::Scene + scene_rect: Rect, + /// Currently selected agent IDs + pub selected: Vec<SessionId>, + /// Drag selection state + drag_select: Option<DragSelect>, + /// Target camera position for smooth animation + camera_target: Option<Vec2>, + /// Animation progress (0.0 to 1.0) + animation_progress: f32, + /// Sessions that have already been alerted (to avoid re-jumping) + alerted_sessions: Vec<SessionId>, +} + +/// State for box/marquee selection +struct DragSelect { + start: Pos2, + current: Pos2, +} + +/// Action generated by the scene UI +#[derive(Debug, Clone)] +pub enum SceneAction { + /// Selection changed + SelectionChanged(Vec<SessionId>), + /// Request to spawn a new agent + SpawnAgent, + /// Request to delete selected agents + DeleteSelected, + /// Agent was dragged to new position + AgentMoved { id: SessionId, position: Vec2 }, +} + +/// Response from scene rendering +#[derive(Default)] +pub struct SceneResponse { + pub action: Option<SceneAction>, +} + +impl SceneResponse { + pub fn new(action: SceneAction) -> Self { + Self { + action: Some(action), + } + } +} + +impl Default for AgentScene { + fn default() -> Self { + Self::new() + } +} + +impl AgentScene { + pub fn new() -> Self { + Self { + scene_rect: Rect::from_min_max(Pos2::new(-500.0, -500.0), Pos2::new(500.0, 500.0)), + selected: Vec::new(), + drag_select: None, + camera_target: None, + animation_progress: 1.0, + alerted_sessions: Vec::new(), + } + } + + /// Check if an agent is selected + pub fn is_selected(&self, id: SessionId) -> bool { + self.selected.contains(&id) + } + + /// Set selection to a single agent + pub fn select(&mut self, id: SessionId) { + self.selected.clear(); + self.selected.push(id); + } + + /// Add an agent to the selection + pub fn add_to_selection(&mut self, id: SessionId) { + if !self.selected.contains(&id) { + self.selected.push(id); + } + } + + /// Clear all selection + pub fn clear_selection(&mut self) { + self.selected.clear(); + } + + /// Get the first selected agent (for chat panel) + pub fn primary_selection(&self) -> Option<SessionId> { + self.selected.first().copied() + } + + /// Animate camera to focus on a position + pub fn focus_on(&mut self, position: Vec2) { + self.camera_target = Some(position); + self.animation_progress = 0.0; + } + + /// Check for agents needing attention and jump to them if not already alerted. + /// Returns the ID of the agent that was jumped to, if any. + pub fn check_attention(&mut self, session_manager: &SessionManager) -> Option<SessionId> { + // Clean up alerted list - remove sessions that no longer need input + self.alerted_sessions.retain(|id| { + session_manager + .get(*id) + .map(|s| s.status() == AgentStatus::NeedsInput) + .unwrap_or(false) + }); + + // Find first session needing attention that we haven't alerted yet + for session in session_manager.iter() { + if session.status() == AgentStatus::NeedsInput + && !self.alerted_sessions.contains(&session.id) + { + // Mark as alerted + self.alerted_sessions.push(session.id); + + // Focus camera on this agent + self.focus_on(session.scene_position); + + // Select this agent + self.select(session.id); + + return Some(session.id); + } + } + + None + } + + /// Render the scene + pub fn ui(&mut self, session_manager: &SessionManager, ui: &mut egui::Ui) -> SceneResponse { + let mut response = SceneResponse::default(); + + // Update camera animation towards target + if let Some(target) = self.camera_target { + if self.animation_progress < 1.0 { + self.animation_progress += 0.08; + self.animation_progress = self.animation_progress.min(1.0); + + // Smoothly interpolate scene_rect center towards target + let current_center = self.scene_rect.center(); + let target_pos = Pos2::new(target.x, target.y); + let t = ease_out_cubic(self.animation_progress); + let new_center = current_center.lerp(target_pos, t); + + // Shift the scene_rect to center on new position + let offset = new_center - current_center; + self.scene_rect = self.scene_rect.translate(offset); + + ui.ctx().request_repaint(); + } else { + // Animation complete + self.camera_target = None; + } + } + + // Track interactions from inside the scene closure + let mut clicked_agent: Option<(SessionId, bool, Vec2)> = None; // (id, shift_held, position) + let mut dragged_agent: Option<(SessionId, Vec2)> = None; // (id, new_position) + let mut bg_clicked = false; + let mut bg_drag_started = false; + + // Use a local copy of scene_rect to avoid borrow conflict + let mut scene_rect = self.scene_rect; + let selected_ids = &self.selected; + + egui::Scene::new() + .zoom_range(0.1..=4.0) + .show(ui, &mut scene_rect, |ui| { + // Draw background grid + Self::draw_grid(ui); + + // Draw agents and collect interaction responses + for session in session_manager.iter() { + let id = session.id; + let position = session.scene_position; + let status = session.status(); + let title = &session.title; + let is_selected = selected_ids.contains(&id); + + let agent_response = + Self::draw_agent(ui, id, position, status, title, is_selected); + + if agent_response.clicked() { + let shift = ui.input(|i| i.modifiers.shift); + clicked_agent = Some((id, shift, position)); + } + + if agent_response.dragged() && is_selected { + let delta = agent_response.drag_delta(); + dragged_agent = Some((id, position + delta)); + } + } + + // Handle click on empty space to deselect + let bg_response = ui.interact( + ui.max_rect(), + ui.id().with("scene_bg"), + Sense::click_and_drag(), + ); + + if bg_response.clicked() && clicked_agent.is_none() { + bg_clicked = true; + } + + if bg_response.drag_started() && clicked_agent.is_none() { + bg_drag_started = true; + } + }); + + self.scene_rect = scene_rect; + + // Process agent click + if let Some((id, shift, _position)) = clicked_agent { + if shift { + self.add_to_selection(id); + } else { + self.select(id); + } + response = SceneResponse::new(SceneAction::SelectionChanged(self.selected.clone())); + } + + // Process agent drag + if let Some((id, new_pos)) = dragged_agent { + response = SceneResponse::new(SceneAction::AgentMoved { + id, + position: new_pos, + }); + } + + // Process background click + if bg_clicked && response.action.is_none() && !self.selected.is_empty() { + self.selected.clear(); + response = SceneResponse::new(SceneAction::SelectionChanged(Vec::new())); + } + + // Start drag selection + if bg_drag_started { + if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) { + self.drag_select = Some(DragSelect { + start: pos, + current: pos, + }); + } + } + + // Update drag selection position + if let Some(drag) = &mut self.drag_select { + if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) { + drag.current = pos; + } + } + + // Handle keyboard input + if ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) { + if !self.selected.is_empty() { + response = SceneResponse::new(SceneAction::DeleteSelected); + } + } + + // Handle 'n' key to spawn new agent + if ui.input(|i| i.key_pressed(egui::Key::N)) { + response = SceneResponse::new(SceneAction::SpawnAgent); + } + + // Handle box selection completion + if let Some(drag) = &self.drag_select { + if ui.input(|i| i.pointer.primary_released()) { + let selection_rect = Rect::from_two_pos(drag.start, drag.current); + self.selected.clear(); + + for session in session_manager.iter() { + let agent_pos = Pos2::new(session.scene_position.x, session.scene_position.y); + if selection_rect.contains(agent_pos) { + self.selected.push(session.id); + } + } + + if !self.selected.is_empty() { + response = + SceneResponse::new(SceneAction::SelectionChanged(self.selected.clone())); + } + + self.drag_select = None; + } + } + + // Draw box selection overlay + if let Some(drag) = &self.drag_select { + let rect = Rect::from_two_pos(drag.start, drag.current); + let painter = ui.painter(); + painter.rect_filled( + rect, + 0.0, + Color32::from_rgba_unmultiplied(100, 150, 255, 30), + ); + painter.rect_stroke( + rect, + 0.0, + egui::Stroke::new(1.0, Color32::from_rgb(100, 150, 255)), + egui::StrokeKind::Outside, + ); + } + + response + } + + /// Draw the background grid + fn draw_grid(ui: &mut egui::Ui) { + let painter = ui.painter(); + let rect = ui.max_rect(); + let grid_spacing = 50.0; + let grid_color = ui + .visuals() + .widgets + .noninteractive + .bg_stroke + .color + .gamma_multiply(0.3); + + // Vertical lines + let start_x = (rect.min.x / grid_spacing).floor() * grid_spacing; + let mut x = start_x; + while x < rect.max.x { + painter.line_segment( + [Pos2::new(x, rect.min.y), Pos2::new(x, rect.max.y)], + egui::Stroke::new(1.0, grid_color), + ); + x += grid_spacing; + } + + // Horizontal lines + let start_y = (rect.min.y / grid_spacing).floor() * grid_spacing; + let mut y = start_y; + while y < rect.max.y { + painter.line_segment( + [Pos2::new(rect.min.x, y), Pos2::new(rect.max.x, y)], + egui::Stroke::new(1.0, grid_color), + ); + y += grid_spacing; + } + + // Draw origin axes slightly brighter + let axis_color = ui + .visuals() + .widgets + .noninteractive + .bg_stroke + .color + .gamma_multiply(0.6); + if rect.min.x < 0.0 && rect.max.x > 0.0 { + painter.line_segment( + [Pos2::new(0.0, rect.min.y), Pos2::new(0.0, rect.max.y)], + egui::Stroke::new(2.0, axis_color), + ); + } + if rect.min.y < 0.0 && rect.max.y > 0.0 { + painter.line_segment( + [Pos2::new(rect.min.x, 0.0), Pos2::new(rect.max.x, 0.0)], + egui::Stroke::new(2.0, axis_color), + ); + } + } + + /// Draw a single agent unit and return the interaction Response + fn draw_agent( + ui: &mut egui::Ui, + id: SessionId, + position: Vec2, + status: AgentStatus, + title: &str, + is_selected: bool, + ) -> Response { + let agent_radius = 30.0; + let center = Pos2::new(position.x, position.y); + let agent_rect = Rect::from_center_size(center, Vec2::splat(agent_radius * 2.0)); + + // Interact with the agent + let response = ui.interact( + agent_rect, + ui.id().with(("agent", id)), + Sense::click_and_drag(), + ); + + let painter = ui.painter(); + + // Selection highlight (outer ring) + if is_selected { + painter.circle_stroke( + center, + agent_radius + 4.0, + egui::Stroke::new(3.0, Color32::from_rgb(255, 255, 100)), + ); + } + + // Status ring + let status_color = status.color(); + painter.circle_stroke(center, agent_radius, egui::Stroke::new(3.0, status_color)); + + // Fill + let fill_color = if response.hovered() { + ui.visuals().widgets.hovered.bg_fill + } else { + ui.visuals().widgets.inactive.bg_fill + }; + painter.circle_filled(center, agent_radius - 2.0, fill_color); + + // Agent icon/letter in center + let icon_char = title.chars().next().unwrap_or('?'); + let icon_text: String = icon_char.to_uppercase().collect(); + painter.text( + center, + egui::Align2::CENTER_CENTER, + &icon_text, + egui::FontId::proportional(20.0), + ui.visuals().text_color(), + ); + + // Title below + let title_pos = center + Vec2::new(0.0, agent_radius + 10.0); + painter.text( + title_pos, + egui::Align2::CENTER_TOP, + title, + egui::FontId::proportional(11.0), + ui.visuals().text_color().gamma_multiply(0.8), + ); + + // Status label + let status_pos = center + Vec2::new(0.0, agent_radius + 24.0); + painter.text( + status_pos, + egui::Align2::CENTER_TOP, + status.label(), + egui::FontId::proportional(9.0), + status_color.gamma_multiply(0.9), + ); + + response + } +} + +/// Easing function for smooth camera animation +fn ease_out_cubic(t: f32) -> f32 { + 1.0 - (1.0 - t).powi(3) +}