diff --git a/.gitignore b/.gitignore index 0d39edea49083..1a45ffdafcd0a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ assets/scenes/load_scene_example-new.scn.ron # Generated by "examples/window/screenshot.rs" **/screenshot-*.png +**/.DS_Store diff --git a/Cargo.toml b/Cargo.toml index 261cb23632208..9ea2f051f68ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4030,6 +4030,12 @@ path = "examples/dev_tools/fps_overlay.rs" doc-scrape-examples = true required-features = ["bevy_dev_tools"] +[[example]] +name = "entity_inspector_minimal" +path = "examples/inspector/entity_inspector_minimal.rs" +doc-scrape-examples = true +required-features = ["bevy_dev_tools"] + [[example]] name = "2d_top_down_camera" path = "examples/camera/2d_top_down_camera.rs" @@ -4113,6 +4119,12 @@ description = "Demonstrates FPS overlay" category = "Dev tools" wasm = true +[package.metadata.example.entity_inspector_minimal] +name = "Entity Inspector" +description = "Remote entity inspector that connects to a running Bevy app" +category = "Dev tools" +wasm = false + [[example]] name = "visibility_range" path = "examples/3d/visibility_range.rs" diff --git a/crates/bevy_dev_tools/Cargo.toml b/crates/bevy_dev_tools/Cargo.toml index da717e2c0fb83..b7f96357132dd 100644 --- a/crates/bevy_dev_tools/Cargo.toml +++ b/crates/bevy_dev_tools/Cargo.toml @@ -9,7 +9,7 @@ license = "MIT OR Apache-2.0" keywords = ["bevy"] [features] -bevy_ci_testing = ["serde", "ron"] +bevy_ci_testing = ["ron"] [dependencies] # bevy @@ -17,24 +17,47 @@ bevy_app = { path = "../bevy_app", version = "0.17.0-dev" } bevy_asset = { path = "../bevy_asset", version = "0.17.0-dev" } bevy_camera = { path = "../bevy_camera", version = "0.17.0-dev" } bevy_color = { path = "../bevy_color", version = "0.17.0-dev" } +bevy_core_pipeline = { path = "../bevy_core_pipeline", version = "0.17.0-dev" } +bevy_core_widgets = { path = "../bevy_core_widgets", version = "0.17.0-dev" } bevy_diagnostic = { path = "../bevy_diagnostic", version = "0.17.0-dev" } bevy_ecs = { path = "../bevy_ecs", version = "0.17.0-dev" } +bevy_image = { path = "../bevy_image", version = "0.17.0-dev" } +bevy_input = { path = "../bevy_input", version = "0.17.0-dev" } +# bevy_internal removed to avoid cyclic dependency - inspector imports handled via re-exports +bevy_log = { path = "../bevy_log", version = "0.17.0-dev" } bevy_math = { path = "../bevy_math", version = "0.17.0-dev" } +bevy_pbr = { version = "0.17.0-dev", path = "../bevy_pbr" } bevy_picking = { path = "../bevy_picking", version = "0.17.0-dev" } bevy_render = { path = "../bevy_render", version = "0.17.0-dev" } bevy_reflect = { path = "../bevy_reflect", version = "0.17.0-dev" } bevy_time = { path = "../bevy_time", version = "0.17.0-dev" } bevy_text = { path = "../bevy_text", version = "0.17.0-dev" } bevy_shader = { path = "../bevy_shader", version = "0.17.0-dev" } +bevy_transform = { path = "../bevy_transform", version = "0.17.0-dev" } bevy_ui = { path = "../bevy_ui", version = "0.17.0-dev" } bevy_ui_render = { path = "../bevy_ui_render", version = "0.17.0-dev" } bevy_window = { path = "../bevy_window", version = "0.17.0-dev" } +bevy_scene = { version = "0.17.0-dev", path = "../bevy_scene" } bevy_state = { path = "../bevy_state", version = "0.17.0-dev" } +bevy_remote = { path = "../bevy_remote", version = "0.17.0-dev" } +bevy_tasks = { path = "../bevy_tasks", version = "0.17.0-dev" } +bevy_winit = { path = "../bevy_winit", version = "0.17.0-dev" } # other -serde = { version = "1.0", features = ["derive"], optional = true } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0" } +rand = { version = "0.8" } ron = { version = "0.10", optional = true } tracing = { version = "0.1", default-features = false, features = ["std"] } +ureq = { version = "2.0" } + +# remote inspector dependencies +reqwest = { version = "0.12", features = ["json", "stream"] } +async-channel = "2.3" +anyhow = "1.0" +futures = "0.3" +tokio = { version = "1.0", features = ["rt", "rt-multi-thread", "macros"] } + [lints] workspace = true diff --git a/crates/bevy_dev_tools/src/inspector/README.md b/crates/bevy_dev_tools/src/inspector/README.md new file mode 100644 index 0000000000000..852b31e7c9869 --- /dev/null +++ b/crates/bevy_dev_tools/src/inspector/README.md @@ -0,0 +1,192 @@ +# Bevy Entity Inspector + +The Bevy Entity Inspector is a powerful debugging tool that allows you to inspect and monitor entities and components in real-time, both locally and remotely. It provides two distinct modes of operation: + +1. **Local Inspector** - An embedded, in-game inspector overlay +2. **Remote Inspector** - An external application that connects to your game via `bevy_remote` + +## Local Inspector (In-Game Overlay) + +The local inspector provides an in-game overlay that can be toggled on/off during development. + +### Local Setup - TO BE COMPLETED + +Add the `InspectorPlugin` to your application: + +```rust +use bevy::dev_tools::inspector::InspectorPlugin; +use bevy::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_plugins(InspectorPlugin::debug()) // Use debug preset with F11 toggle + .run(); +} +``` + +### Local Usage - TO BE COMPLETED + +- Press **F11** to toggle the inspector overlay +- Browse entities in the left panel +- Click on entities to view their components in the right panel +- Component values update in real-time + +### Example + +Run the local inspector example: + +```bash +cargo run --example inspector --features="bevy_dev_tools" +``` + +## Remote Inspector (External Application) + +The remote inspector runs as a separate application that connects to your game over HTTP using the `bevy_remote` protocol. This is particularly useful for: + +- Inspecting headless applications +- Debugging without UI overlay interference +- External tooling and automation +- Multi-monitor setups + +### Remote Setup + +#### Target Application (Your Game) + +Add `bevy_remote` plugins to enable external connections: + +```rust +use bevy::prelude::*; +use bevy::remote::{RemotePlugin, http::RemoteHttpPlugin}; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_plugins(RemotePlugin::default()) // Enable JSON-RPC + .add_plugins(RemoteHttpPlugin::default()) // Enable HTTP transport + // Your game systems here... + .run(); +} +``` + +#### Inspector Application + +Create a separate inspector app: + +```rust +use bevy::prelude::*; +use bevy::dev_tools::inspector::InspectorPlugin; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_plugins(InspectorPlugin) // Remote inspector (no debug preset) + .run(); +} +``` + +### Remote Usage + +1. **Start your target application** with `bevy_remote` enabled: + + ```bash + cargo run --example server --features="bevy_remote" + ``` + +2. **Start the inspector** in a separate terminal/window: + + ```bash + cargo run --example entity_inspector_minimal --features="bevy_dev_tools" + ``` + +The inspector will automatically connect to `localhost:15702` and display your entities. + +### Features + +- **Real-time Updates**: Component values update live as they change +- **Interactive UI**: Click to select entities, text selection with copy/paste +- **Connection Resilience**: Auto-retry logic handles connection failures gracefully +- **Performance**: Virtual scrolling efficiently handles large numbers of entities +- **All Components**: Automatically discovers and displays all component types + +### Connection Details + +- **Default Address**: `localhost:15702` +- **Protocol**: HTTP with JSON-RPC 2.0 +- **Endpoints**: + - `/health` - Connection health check + - `/jsonrpc` - Main JSON-RPC interface + +## Component Registration + +For components to be visible in the inspector, they must implement `Reflect`: + +```rust +use bevy::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Component, Reflect, Serialize, Deserialize)] +#[reflect(Component, Serialize, Deserialize)] +struct Player { + health: i32, + speed: f32, +} + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .register_type::() // Required for reflection + .run(); +} +``` + +## Troubleshooting + +### Remote Inspector Issues + +**Inspector shows "Awaiting connection":** + +- Ensure target app is running with `bevy_remote` plugins enabled +- Verify target app is listening on port 15702 +- Check firewall/network connectivity + +**Components not visible:** + +- Ensure components implement `Reflect` +- Register component types with `.register_type::()` +- Verify components implement `Serialize`/`Deserialize` for remote inspection + +**Connection drops frequently:** + +- Check target application stability +- Monitor network connectivity +- The inspector will automatically retry connections + +### Local Inspector Issues + +**F11 doesn't toggle inspector:** + +- Ensure you're using `InspectorPlugin::debug()` not `InspectorPlugin` +- Check if another system is handling F11 key input + +**UI elements not visible:** + +- Verify UI camera is present in your scene +- Check for UI layer conflicts + +## Architecture + +The inspector uses several key components: + +- **HTTP Client** (`crates/bevy_dev_tools/src/inspector/http_client.rs`): Manages remote connections and JSON-RPC communication +- **UI Components** (`crates/bevy_dev_tools/src/inspector/ui/`): Entity list, component viewer, connection status +- **Virtual Scrolling**: Efficient rendering for large entity lists +- **Live Updates**: Real-time component value streaming + +## Examples + +| Example | Purpose | Command | +|---------|---------|---------| +| `inspector` | Local in-game overlay | `cargo run --example inspector --features="bevy_dev_tools"` | +| `server` | Target app for remote inspection | `cargo run --example server --features="bevy_remote"` | +| `entity_inspector_minimal` | Remote inspector client | `cargo run --example entity_inspector_minimal --features="bevy_dev_tools"` | diff --git a/crates/bevy_dev_tools/src/inspector/http_client.rs b/crates/bevy_dev_tools/src/inspector/http_client.rs new file mode 100644 index 0000000000000..49c45a5da07ba --- /dev/null +++ b/crates/bevy_dev_tools/src/inspector/http_client.rs @@ -0,0 +1,588 @@ +//! HTTP client for `bevy_remote` protocol with connection resilience +//! +//! This client implements the `bevy_remote` JSON-RPC protocol with comprehensive support for: +//! - **world.query**: Query entities and components with flexible filtering +//! - **world.get_components+watch**: Stream live component updates via Server-Sent Events (SSE) +//! - **Connection Management**: Auto-retry logic with exponential backoff +//! - **Error Recovery**: Robust error handling and reconnection strategies +//! +//! The client automatically handles connection failures and provides real-time updates +//! for component values in remote Bevy applications. + +use anyhow::{anyhow, Result}; +use async_channel::{Receiver, Sender}; +use bevy_ecs::prelude::*; +use bevy_log::prelude::*; +use futures::TryStreamExt; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +/// JSON-RPC request structure for `bevy_remote` protocol +#[derive(Serialize, Debug)] +pub struct JsonRpcRequest { + /// JSON-RPC protocol version (always "2.0") + pub jsonrpc: String, + /// Unique request identifier + pub id: u32, + /// Method name (e.g., "bevy/query", "bevy/get+watch") + pub method: String, + /// Optional method parameters + pub params: Option, +} + +/// JSON-RPC response structure +#[derive(Deserialize, Debug)] +#[expect(dead_code, reason = "All fields are used in deserialization")] +struct JsonRpcResponse { + jsonrpc: String, + id: u32, + result: Option, + error: Option, +} + +/// JSON-RPC error structure +#[derive(Deserialize, Debug)] +#[expect(dead_code, reason = "All fields are used in deserialization")] +struct JsonRpcError { + code: i32, + message: String, + data: Option, +} + +/// Remote entity representation from `bevy_remote` +#[derive(Debug, Clone, Deserialize)] +pub struct RemoteEntity { + /// Entity ID from the remote Bevy application + pub id: u32, + /// Optional entity name (from `bevy_core::Name` component) + pub name: Option, + /// Map of component type names to their serialized values + pub components: HashMap, +} + +/// Configuration for HTTP remote client +#[derive(Resource, Debug)] +pub struct HttpRemoteConfig { + /// Remote server hostname (default: "localhost") + pub host: String, + /// Remote server port (default: 15702) + pub port: u16, +} + +impl Default for HttpRemoteConfig { + fn default() -> Self { + Self { + host: "localhost".to_string(), + port: 15702, + } + } +} + +/// HTTP client for `bevy_remote` communication with connection resilience +/// +/// This client manages communication with remote Bevy applications via the `bevy_remote` +/// JSON-RPC protocol. It provides automatic connection retry logic, live component +/// streaming, and robust error handling. +/// +/// # Features +/// - Auto-retry connection logic with exponential backoff +/// - Live component value streaming via bevy/get+watch +/// - Comprehensive entity and component querying +/// - Connection status monitoring and recovery +/// - Async channel-based communication for non-blocking updates +#[derive(Resource)] +pub struct HttpRemoteClient { + /// HTTP client for making requests + pub client: Client, + /// Base URL for the remote server (e.g., ) + pub base_url: String, + /// Counter for generating unique request IDs + pub request_id: u32, + /// Legacy channel for receiving live updates (for backward compatibility) + pub update_receiver: Option>, + /// Sender for streaming component updates via bevy/get+watch + pub component_update_sender: Option>, + /// Receiver for streaming component updates via bevy/get+watch + pub component_update_receiver: Option>, + /// Sender for connection status updates from async tasks + pub connection_status_sender: Option>, + /// Receiver for connection status updates from async tasks + pub connection_status_receiver: Option>, + /// Map of entity IDs to their watched component lists + pub watched_entities: HashMap>, + /// Cached entities retrieved from the remote server + pub entities: HashMap, + /// Current connection status + pub is_connected: bool, + /// Last connection error message + pub last_error: Option, + /// Current retry attempt count + pub retry_count: u32, + /// Maximum number of retry attempts before giving up + pub max_retries: u32, + /// Delay between retry attempts in seconds + pub retry_delay: f32, + /// Timestamp of the last retry attempt + pub last_retry_time: f64, + /// Interval for periodic connection checks in seconds + pub connection_check_interval: f64, + /// Timestamp of the last connection check + pub last_connection_check: f64, +} + +/// Live update from streaming endpoint +#[derive(Debug, Clone)] +pub struct RemoteUpdate { + /// Entity ID that was updated + pub entity_id: u32, + /// Updated component data + pub components: HashMap, +} + +/// Enhanced component update structure for live streaming +#[derive(Debug, Clone)] +pub struct ComponentUpdate { + /// Entity ID that was updated + pub entity_id: u32, + /// Components that were added or changed + pub changed_components: HashMap, + /// Components that were removed + pub removed_components: Vec, + /// Timestamp when the update occurred + pub timestamp: f64, +} + +/// Response from bevy/get+watch endpoint +#[derive(Debug, Deserialize)] +pub struct BrpGetWatchingResponse { + /// Updated component data + pub components: Option>, + /// List of removed component type names + pub removed: Option>, + /// Error information for failed components + pub errors: Option>, +} + +/// Connection status update from async tasks to Bevy systems +#[derive(Debug, Clone)] +pub struct ConnectionStatusUpdate { + /// Whether the connection is currently active + pub is_connected: bool, + /// Error message if connection failed + pub error_message: Option, + /// Entities fetched during successful connection + pub entities: HashMap, +} + +impl HttpRemoteClient { + /// Create a new HTTP remote client with the given configuration + pub fn new(config: &HttpRemoteConfig) -> Self { + let base_url = format!("http://{}:{}", config.host, config.port); + + Self { + client: Client::new(), + base_url, + request_id: 1, + update_receiver: None, + // Initialize new streaming fields + component_update_sender: None, + component_update_receiver: None, + // Initialize connection status communication + connection_status_sender: None, + connection_status_receiver: None, + watched_entities: HashMap::new(), + entities: HashMap::new(), + is_connected: false, + last_error: None, + // Initialize retry logic + retry_count: 0, + max_retries: 10, // Try 10 times before giving up + retry_delay: 2.0, // Wait 2 seconds between retries + last_retry_time: 0.0, + connection_check_interval: 5.0, // Check every 5 seconds if disconnected + last_connection_check: 0.0, + } + } + + /// Test connection to `bevy_remote` server + pub async fn connect(&mut self) -> Result<()> { + debug!( + "Attempting connection to {} (attempt {}/{})", + self.base_url, + self.retry_count + 1, + self.max_retries + ); + + // Try a simple list request to test connectivity + match self.list_entities().await { + Ok(_) => { + self.is_connected = true; + self.last_error = None; + self.retry_count = 0; // Reset retry counter on successful connection + info!("Connected to bevy_remote at {}", self.base_url); + Ok(()) + } + Err(e) => { + self.is_connected = false; + self.last_error = Some(e.to_string()); + self.retry_count += 1; + + if self.retry_count <= self.max_retries { + warn!( + "Connection failed (attempt {}/{}): {}", + self.retry_count, self.max_retries, e + ); + debug!("Will retry in {} seconds", self.retry_delay); + } else { + error!( + "Failed to connect after {} attempts: {}", + self.max_retries, e + ); + error!("Ensure target app is running with bevy_remote enabled"); + } + Err(e) + } + } + } + + /// Query all entities with any components via bevy/query + pub async fn list_entities(&mut self) -> Result> { + let request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: self.next_id(), + method: "world.query".to_string(), + params: Some(serde_json::json!({ + "data": { + "components": [], + "option": [], + "has": [] + }, + "filter": { + "with": [], + "without": [] + }, + "strict": false + })), + }; + + let response = self.send_request(request).await?; + + if let Some(result) = response.result { + // Parse the query response which is an array of entity objects + let entities: Vec = serde_json::from_value(result) + .map_err(|e| anyhow!("Failed to parse entity query: {}", e))?; + + let mut entity_ids = Vec::new(); + for entity_obj in entities { + if let Some(entity_id) = entity_obj.get("entity").and_then(Value::as_u64) { + entity_ids.push(entity_id as u32); + } + } + + debug!("Listed {} entities via query", entity_ids.len()); + Ok(entity_ids) + } else if let Some(error) = response.error { + Err(anyhow!("bevy/query error: {}", error.message)) + } else { + Err(anyhow!("Invalid response format")) + } + } + + /// Get component data for all entities via bevy/query with full component data + pub async fn get_entities(&mut self, _entity_ids: &[u32]) -> Result> { + let request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: self.next_id(), + method: "world.query".to_string(), + params: Some(serde_json::json!({ + "data": { + "components": [], + "option": "all", + "has": [] + }, + "filter": { + "with": [], + "without": [] + }, + "strict": false + })), + }; + + let response = self.send_request(request).await?; + + if let Some(result) = response.result { + // Parse the query response + let query_results: Vec = serde_json::from_value(result) + .map_err(|e| anyhow!("Failed to parse query results: {}", e))?; + + let mut entities = Vec::new(); + + for query_result in query_results.iter() { + if let (Some(entity_id), Some(components_obj)) = ( + query_result.get("entity").and_then(Value::as_u64), + query_result.get("components").and_then(|v| v.as_object()), + ) { + let mut components = HashMap::new(); + + // Convert components object to HashMap + for (component_name, component_data) in components_obj { + components.insert(component_name.clone(), component_data.clone()); + } + + // Try to extract name from Name component if it exists + // Name is a tuple struct, so it should be in "0" field or direct string + let name = components.get("bevy_core::name::Name").and_then(|v| { + // Try as direct string first + if let Some(s) = v.as_str() { + Some(s.to_string()) + } else { + // Try as tuple struct with "0" field + v.as_object() + .and_then(|obj| obj.get("0")) + .and_then(Value::as_str) + .map(ToString::to_string) + } + }); + + let entity = RemoteEntity { + id: entity_id as u32, + name, + components, + }; + + entities.push(entity); + } + } + + // Update local cache + self.entities.clear(); + for entity in &entities { + self.entities.insert(entity.id, entity.clone()); + } + + debug!("Retrieved {} entities with component data", entities.len()); + Ok(entities) + } else if let Some(error) = response.error { + Err(anyhow!("bevy/query error: {}", error.message)) + } else { + Err(anyhow!("Invalid response format")) + } + } + + /// Start streaming updates for entities via bevy/get+watch + pub async fn start_watching(&mut self, entity_ids: &[u32]) -> Result<()> { + debug!("Starting watch stream for {} entities", entity_ids.len()); + + // Note: This method is now deprecated in favor of start_component_watching + // which uses the new bevy_remote streaming API properly + warn!("start_watching is deprecated, use start_component_watching instead"); + + Ok(()) + } + + /// Start watching components for an entity using world.get_components+watch + pub fn start_component_watching( + &mut self, + entity_id: u32, + components: Vec, + tokio_handle: &tokio::runtime::Handle, + ) -> Result<()> { + // Create channel for component updates + let (tx, rx) = async_channel::unbounded(); + self.component_update_sender = Some(tx.clone()); + self.component_update_receiver = Some(rx); + + let base_url = self.base_url.clone(); + let client = self.client.clone(); + let components_clone = components.clone(); + + // Use tokio runtime handle for reqwest compatibility + tokio_handle.spawn(async move { + // Use world.get_components+watch with continuous polling + let request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: 1, // We'll use a fixed ID for watching requests + method: "world.get_components+watch".to_string(), + params: Some(serde_json::json!({ + "entity": entity_id, + "components": components_clone, + "strict": false + })), + }; + + // Use streaming SSE connection for real-time updates + let url = format!("{base_url}/jsonrpc"); + + loop { + match client.post(&url).json(&request).send().await { + Ok(response) => { + // Process the streaming response + let mut stream = response.bytes_stream(); + let mut buffer = String::new(); + + while let Ok(Some(chunk)) = stream.try_next().await { + if let Ok(text) = core::str::from_utf8(&chunk) { + buffer.push_str(text); + + // Process complete lines + while let Some(newline_pos) = buffer.find('\n') { + let line = buffer[..newline_pos].trim().to_string(); + buffer.drain(..newline_pos + 1); + + // Process SSE data lines + if let Some(json_str) = line.strip_prefix("data: ") { + match serde_json::from_str::(json_str) { + Ok(json_response) => { + if let Some(result) = json_response.result + && let Ok(watch_response) = + serde_json::from_value::< + BrpGetWatchingResponse, + >( + result + ) + { + let update = ComponentUpdate { + entity_id, + changed_components: watch_response + .components + .unwrap_or_default(), + removed_components: watch_response + .removed + .unwrap_or_default(), + timestamp: current_time(), + }; + + if (!update.changed_components.is_empty() + || !update.removed_components.is_empty()) + && tx.send(update).await.is_err() + { + return; // Exit the task + } + } + } + Err(_e) => {} + } + } + } + } + } + + // Simple async delay without tokio + let sleep_future = async { + use core::time::Duration; + use std::time::Instant; + let start = Instant::now(); + let target_duration = Duration::from_secs(1); + + loop { + if start.elapsed() >= target_duration { + break; + } + futures::future::ready(()).await; + } + }; + sleep_future.await; + } + Err(_e) => { + // Simple async delay without tokio + let sleep_future = async { + use core::time::Duration; + use std::time::Instant; + let start = Instant::now(); + let target_duration = Duration::from_secs(1); + + loop { + if start.elapsed() >= target_duration { + break; + } + futures::future::ready(()).await; + } + }; + sleep_future.await; + // Retry connection + } + } + } + }); + + self.watched_entities.insert(entity_id, components); + Ok(()) + } + + /// Stop watching components for an entity + pub fn stop_component_watching(&mut self, entity_id: u32) { + if self.watched_entities.remove(&entity_id).is_some() {} + } + + /// Check for live component updates from streaming endpoint + pub fn check_component_updates(&mut self) -> Vec { + let mut updates = Vec::new(); + + if let Some(ref receiver) = self.component_update_receiver { + while let Ok(update) = receiver.try_recv() { + updates.push(update); + } + } + + updates + } + + /// Check for live updates from streaming endpoint + pub fn check_updates(&mut self) -> Vec { + let mut updates = Vec::new(); + + if let Some(ref mut receiver) = self.update_receiver { + while let Ok(update) = receiver.try_recv() { + updates.push(update); + } + } + + updates + } + + /// Get entity by ID from cache + pub fn get_entity(&self, entity_id: u32) -> Option<&RemoteEntity> { + self.entities.get(&entity_id) + } + + /// Get all cached entity IDs + pub fn get_entity_ids(&self) -> Vec { + self.entities.keys().copied().collect() + } + + /// Send JSON-RPC request + async fn send_request(&mut self, request: JsonRpcRequest) -> Result { + let url = format!("{}/jsonrpc", self.base_url); + + let response = self + .client + .post(&url) + .json(&request) + .send() + .await + .map_err(|e| anyhow!("HTTP request failed: {}", e))?; + + let response: JsonRpcResponse = response + .json() + .await + .map_err(|e| anyhow!("Failed to parse JSON response: {}", e))?; + + Ok(response) + } + + fn next_id(&mut self) -> u32 { + let id = self.request_id; + self.request_id += 1; + id + } +} + +/// Get current timestamp as f64 +fn current_time() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs_f64() +} diff --git a/crates/bevy_dev_tools/src/inspector/mod.rs b/crates/bevy_dev_tools/src/inspector/mod.rs new file mode 100644 index 0000000000000..a4fb7451b42d7 --- /dev/null +++ b/crates/bevy_dev_tools/src/inspector/mod.rs @@ -0,0 +1,9 @@ +//! Bevy Remote Inspector +//! +//! Out-of-process entity inspector for Bevy applications using `bevy_remote`. + +pub mod http_client; +mod plugin; +pub mod ui; + +pub use plugin::InspectorPlugin; diff --git a/crates/bevy_dev_tools/src/inspector/plugin.rs b/crates/bevy_dev_tools/src/inspector/plugin.rs new file mode 100644 index 0000000000000..b559a6ad583a9 --- /dev/null +++ b/crates/bevy_dev_tools/src/inspector/plugin.rs @@ -0,0 +1,490 @@ +//! Remote Inspector Plugin for Bevy Dev Tools +//! +//! This module provides a real-time entity and component inspector that connects to +//! remote Bevy applications via the `bevy_remote` protocol. It features: +//! +//! - **Live Entity Inspection**: Real-time viewing of entities and their components +//! - **Component Value Updates**: Live streaming of changing component values +//! - **Interactive UI**: Text selection, copy/paste, and virtual scrolling +//! - **Connection Resilience**: Auto-retry logic with exponential backoff +//! - **Performance Optimized**: Efficient virtual scrolling for large entity lists + +use super::http_client::*; +use super::ui::component_viewer::{ + auto_start_component_watching, cleanup_expired_change_indicators, handle_text_selection, + process_live_component_updates, update_live_component_display, LiveComponentCache, +}; +use super::ui::entity_list::{EntityListVirtualState, SelectionDebounce}; +use super::ui::virtual_scrolling::{ + handle_infinite_scroll_input, setup_virtual_scrolling, update_infinite_scrolling_display, + update_scroll_momentum, update_scrollbar_indicator, CustomScrollPosition, VirtualScrollState, +}; +use super::ui::*; +use crate::widgets::selectable_text::TextSelectionState; +use async_channel; +use bevy_app::prelude::*; +use bevy_camera::Camera2d; +use bevy_color::Color; +use bevy_ecs::prelude::*; +use bevy_log::prelude::*; +use bevy_ui::prelude::*; +use serde_json::Value; +use std::collections::HashMap; +use tokio::runtime::Handle; + +/// Tokio runtime handle for async HTTP operations +/// +/// This resource provides access to the Tokio runtime for performing +/// async HTTP requests to the remote Bevy application via `bevy_remote` protocol. +#[derive(Resource)] +pub struct TokioRuntimeHandle(pub Handle); + +/// Remote Inspector Plugin +/// +/// Enables real-time inspection of entities and components in remote Bevy applications. +/// Automatically connects to `bevy_remote` servers and provides an interactive UI for +/// browsing entity data with live updates. +/// +/// # Usage +/// +/// Add this plugin to your inspector application (not the target application): +/// +/// ```rust +/// App::new() +/// .add_plugins(DefaultPlugins) +/// .add_plugins(InspectorPlugin) +/// .run(); +/// ``` +/// +/// The target application should enable `bevy_remote`: +/// +/// ```rust +/// App::new() +/// .add_plugins(DefaultPlugins) +/// .add_plugins(bevy::remote::RemotePlugin::default()) +/// .run(); +/// ``` +pub struct InspectorPlugin; + +impl Plugin for InspectorPlugin { + fn build(&self, app: &mut App) { + // Initialize Tokio runtime for HTTP operations + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let handle = rt.handle().clone(); + + // Keep runtime alive by spawning it in a background thread + std::thread::spawn(move || { + rt.block_on(async { + // Keep runtime alive indefinitely + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await; + } + }) + }); + + app + // Resources + .insert_resource(TokioRuntimeHandle(handle)) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + // Startup systems + .add_systems( + Startup, + (setup_http_client, setup_virtual_scrolling, setup_ui).chain(), + ) + // First update system to populate UI immediately + .add_systems(PostStartup, initial_ui_population) + // Update systems + .add_systems( + Update, + ( + // HTTP client systems + update_entity_list_from_http, + handle_http_updates, + // Infinite scrolling systems + handle_infinite_scroll_input, + update_infinite_scrolling_display, + update_scroll_momentum, + update_scrollbar_indicator, + // UI interaction systems + handle_entity_selection, + handle_collapsible_interactions, + cleanup_old_component_content.before(update_component_viewer), + update_component_viewer, + update_connection_status, + // New live update systems + process_live_component_updates, + cleanup_expired_change_indicators, + update_live_component_display.after(process_live_component_updates), + // Text selection and copying + handle_text_selection, + // Auto-start watching for selected entity + auto_start_component_watching.after(handle_entity_selection), + ), + ); + } +} + +/// Set up the main UI layout +fn setup_ui(mut commands: Commands) { + // Spawn UI camera first + commands.spawn(Camera2d); + + + // Root UI container with absolute positioning + let root = commands + .spawn(( + Node { + width: Val::Vw(100.0), + height: Val::Vh(100.0), + flex_direction: FlexDirection::Row, + position_type: PositionType::Absolute, + left: Val::Px(0.0), + top: Val::Px(0.0), + ..Default::default() + }, + BackgroundColor(Color::srgb(0.05, 0.05, 0.05)), + )) + .id(); + + // Left panel: Entity list + let entity_list = spawn_entity_list(&mut commands, root); + + // Right panel: Component viewer + let component_viewer = spawn_component_viewer(&mut commands, root); + + // Connection status indicator + spawn_connection_status(&mut commands, root); + + info!("Remote inspector UI initialized successfully"); + debug!("Entity list widget: {:?}", entity_list); + debug!("Component viewer widget: {:?}", component_viewer); +} + +/// Force initial population attempts HTTP connection +fn initial_ui_population( + _commands: Commands, + http_client: Res, + _entity_cache: ResMut, + _selected_entity: ResMut, + container_query: Query>, +) { + info!("Initializing inspector UI - attempting remote connection"); + + let Ok(_container_entity) = container_query.single() else { + warn!("Entity list container not found - UI initialization incomplete"); + return; + }; + + // Show connection status - entities will be populated when connection succeeds + if !http_client.is_connected { + info!("Awaiting connection to remote Bevy application..."); + // Entity list will be populated by update_entity_list_from_http once connected + } +} + +/// Set up the HTTP client +fn setup_http_client(mut commands: Commands, config: Res) { + let mut http_client = HttpRemoteClient::new(&config); + + // Initialize connection status communication channel + let (status_tx, status_rx) = async_channel::unbounded(); + http_client.connection_status_sender = Some(status_tx); + http_client.connection_status_receiver = Some(status_rx); + + // Note: Initial connection will be handled by the retry system in handle_http_updates + // This avoids blocking the startup and allows proper resource management + info!("Remote client initialized - auto-connection enabled"); + + commands.insert_resource(http_client); +} + +/// Update entity list from HTTP client - now just updates cache, virtual scrolling handles rendering +fn update_entity_list_from_http( + http_client: Res, + mut entity_cache: ResMut, + mut selected_entity: ResMut, +) { + if !http_client.is_connected { + return; + } + + // Check if we have new entity data + if http_client.entities.is_empty() { + return; + } + + // Check if entities changed by comparing counts only (more stable) + let current_count = http_client.entities.len(); + let cached_count = entity_cache.entities.len(); + + // Only update if count changed - avoid constant updates from ID order changes + if current_count != cached_count { + // Update cache - virtual scrolling will handle the UI updates + entity_cache.entities.clear(); + for (id, remote_entity) in &http_client.entities { + // Convert HttpRemoteEntity to the UI format we need + let ui_entity = RemoteEntity { + id: *id, + name: remote_entity.name.clone(), + components: remote_entity.components.clone(), + }; + entity_cache.entities.insert(*id, ui_entity); + } + + // Select first entity if none selected + if selected_entity.entity_id.is_none() { + if let Some(first_entity) = entity_cache.entities.values().next() { + selected_entity.entity_id = Some(first_entity.id); + debug!("Auto-selected first entity: {}", first_entity.id); + } + } + + debug!( + "Updated entity cache with {} entities from remote", + entity_cache.entities.len() + ); + } +} + +/// Handle HTTP updates from remote client and auto-retry connection +fn handle_http_updates( + mut http_client: ResMut, + time: Res, + tokio_handle: Res, +) { + let current_time = time.elapsed_secs_f64(); + + // Process connection status updates from async tasks + let mut status_updates = Vec::new(); + if let Some(receiver) = &http_client.connection_status_receiver { + while let Ok(status_update) = receiver.try_recv() { + status_updates.push(status_update); + } + } + + // Process all collected status updates + for status_update in status_updates { + http_client.is_connected = status_update.is_connected; + http_client.last_error = status_update.error_message; + + if status_update.is_connected { + // Update entity cache with fetched entities + http_client.entities = status_update.entities; + info!( + "Remote connection established - loaded {} entities", + http_client.entities.len() + ); + } else { + warn!( + "Remote connection failed: {}", + http_client.last_error.as_deref().unwrap_or("Unknown error") + ); + } + } + + // Auto-retry connection if not connected and enough time has passed + if !http_client.is_connected { + let should_retry = if http_client.retry_count == 0 { + // First retry attempt + true + } else if http_client.retry_count < http_client.max_retries { + // Subsequent retries with delay + current_time - http_client.last_retry_time >= http_client.retry_delay as f64 + } else { + // Periodic checks after max retries (less frequent) + current_time - http_client.last_connection_check + >= http_client.connection_check_interval + }; + + if should_retry { + // Increment retry count first + http_client.retry_count += 1; + http_client.last_retry_time = current_time; + http_client.last_connection_check = current_time; + + info!( + "Attempting reconnection (attempt {}/{})", + http_client.retry_count, http_client.max_retries + ); + + // Create a connection test + let base_url = http_client.base_url.clone(); + let client = http_client.client.clone(); + let retry_count = http_client.retry_count; + let max_retries = http_client.max_retries; + let status_sender = http_client.connection_status_sender.clone(); + + // Spawn async connection attempt using tokio runtime handle + tokio_handle.0.spawn(async move { + // Test basic connectivity first + let health_url = format!("{base_url}/health"); + let health_result = client.get(&health_url).send().await; + + match health_result { + Ok(response) if response.status().is_success() => { + // Health check successful - continue with JSON-RPC test + } + Ok(response) => { + warn!("Remote server health check returned status: {}", response.status()); + } + Err(e) => { + warn!("Remote health check failed (attempt {}/{}): {}", retry_count, max_retries, e); + } + } + + // Now test the actual JSON-RPC endpoint + let jsonrpc_url = format!("{base_url}/jsonrpc"); + let test_request = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "world.query", + "params": { + "data": { + "components": [], + "option": "all", + "has": [] + }, + "filter": { + "with": [], + "without": [] + }, + "strict": false + } + }); + + let jsonrpc_result = client.post(&jsonrpc_url) + .json(&test_request) + .timeout(core::time::Duration::from_secs(10)) + .send().await; + + match jsonrpc_result { + Ok(response) if response.status().is_success() => { + // JSON-RPC connection successful - parse entity data + + // Fetch entities on successful connection + let entities_result = response.json::().await; + match entities_result { + Ok(json_response) => { + // Parse the entities from the response + let mut entities = HashMap::new(); + if let Some(result) = json_response.get("result") { + if let Some(entities_array) = result.as_array() { + for entity_data in entities_array.iter() { + if let Some(entity_obj) = entity_data.as_object() { + // Parse Bevy entity ID (numeric format) + if let Some(entity_id_num) = entity_obj.get("entity").and_then(Value::as_u64) { + let entity_id = entity_id_num as u32; + + // Extract components from the "components" object + let components: HashMap = entity_obj + .get("components") + .and_then(|c| c.as_object()) + .map(|comp_obj| comp_obj.iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect()) + .unwrap_or_default(); + + // Try to extract name from Name component if it exists + // Name is a tuple struct, so it should be in "0" field or direct string + let name = components.get("bevy_core::name::Name").and_then(|v| { + // Try as direct string first + if let Some(s) = v.as_str() { + Some(s.to_string()) + } else { + // Try as tuple struct with "0" field + v.as_object() + .and_then(|obj| obj.get("0")) + .and_then(|v| v.as_str()) + .map(ToString::to_string) + } + }); + + let entity = RemoteEntity { + id: entity_id, + name, + components, + }; + entities.insert(entity_id, entity); + } + } + } + } + } + + // Log summary of discovered entities + info!("Successfully connected - found {} entities", entities.len()); + + // Send successful connection status with entities + if let Some(sender) = &status_sender { + let status_update = ConnectionStatusUpdate { + is_connected: true, + error_message: None, + entities, + }; + let _ = sender.send(status_update).await; + } + } + Err(e) => { + error!("Failed to parse entities response: {}", e); + // Send failed connection status + if let Some(sender) = &status_sender { + let status_update = ConnectionStatusUpdate { + is_connected: false, + error_message: Some(format!("Failed to parse entities: {e}")), + entities: HashMap::new(), + }; + let _ = sender.send(status_update).await; + } + } + } + } + Ok(response) => { + warn!("JSON-RPC endpoint returned error {} (attempt {}/{})", + response.status(), retry_count, max_retries); + // Send failed connection status + if let Some(sender) = &status_sender { + let status_update = ConnectionStatusUpdate { + is_connected: false, + error_message: Some(format!("JSON-RPC error: {}", response.status())), + entities: HashMap::new(), + }; + let _ = sender.send(status_update).await; + } + } + Err(e) => { + warn!("JSON-RPC connection failed (attempt {}/{}): {}", + retry_count, max_retries, e); + // Send failed connection status + if let Some(sender) = &status_sender { + let status_update = ConnectionStatusUpdate { + is_connected: false, + error_message: Some(format!("Connection failed: {e}")), + entities: HashMap::new(), + }; + let _ = sender.send(status_update).await; + } + if retry_count >= max_retries { + error!("Max connection retries reached. Ensure target app is running with bevy_remote enabled."); + error!("Expected endpoints: {}/health and {}/jsonrpc", base_url, base_url); + } + } + } + }); + } + } + + // Process any pending updates from HTTP client + let updates = http_client.check_updates(); + if !updates.is_empty() { + debug!("Received {} live updates from remote client", updates.len()); + } +} diff --git a/crates/bevy_dev_tools/src/inspector/tests/integration_tests.rs b/crates/bevy_dev_tools/src/inspector/tests/integration_tests.rs new file mode 100644 index 0000000000000..ff4d38216e66d --- /dev/null +++ b/crates/bevy_dev_tools/src/inspector/tests/integration_tests.rs @@ -0,0 +1,254 @@ +//! Integration tests for the bevy_remote_inspector + +use bevy_ecs::prelude::*; +use bevy_app::App; +use bevy_remote_inspector::http_client::{HttpRemoteClient, HttpRemoteConfig, RemoteEntity}; +use serde_json::{json, Value}; +use std::collections::HashMap; + +/// Create a mock HTTP client with test data for integration testing +fn create_mock_client_with_test_data() -> HttpRemoteClient { + let config = HttpRemoteConfig::default(); + let mut client = HttpRemoteClient::new(&config); + + // Add mock entities for testing + let mut entities = HashMap::new(); + + // Entity 1: Player with Transform and Player components + let mut player_components = HashMap::new(); + player_components.insert( + "bevy_transform::components::transform::Transform".to_string(), + json!({ + "translation": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "rotation": { "x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0 }, + "scale": { "x": 1.0, "y": 1.0, "z": 1.0 } + }) + ); + player_components.insert( + "Player".to_string(), + json!({ + "speed": 5.0, + "health": 100 + }) + ); + + entities.insert(1, RemoteEntity { + id: 1, + name: Some("Player".to_string()), + components: player_components, + }); + + // Entity 2: Enemy with Transform and Enemy components + let mut enemy_components = HashMap::new(); + enemy_components.insert( + "bevy_transform::components::transform::Transform".to_string(), + json!({ + "translation": { "x": 10.0, "y": 0.0, "z": 0.0 }, + "rotation": { "x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0 }, + "scale": { "x": 1.0, "y": 1.0, "z": 1.0 } + }) + ); + enemy_components.insert( + "Enemy".to_string(), + json!({ + "damage": 25, + "health": 75 + }) + ); + + entities.insert(2, RemoteEntity { + id: 2, + name: Some("Enemy".to_string()), + components: enemy_components, + }); + + // Entity 3: Camera with Transform and Camera components + let mut camera_components = HashMap::new(); + camera_components.insert( + "bevy_transform::components::transform::Transform".to_string(), + json!({ + "translation": { "x": 0.0, "y": 0.0, "z": 5.0 }, + "rotation": { "x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0 }, + "scale": { "x": 1.0, "y": 1.0, "z": 1.0 } + }) + ); + camera_components.insert( + "bevy_render::camera::camera::Camera".to_string(), + json!({ + "projection": "perspective", + "viewport": null + }) + ); + + entities.insert(3, RemoteEntity { + id: 3, + name: Some("MainCamera".to_string()), + components: camera_components, + }); + + // Entity 4: Unnamed entity with just Transform + let mut unnamed_components = HashMap::new(); + unnamed_components.insert( + "bevy_transform::components::transform::Transform".to_string(), + json!({ + "translation": { "x": -5.0, "y": 2.0, "z": 0.0 }, + "rotation": { "x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0 }, + "scale": { "x": 1.0, "y": 1.0, "z": 1.0 } + }) + ); + + entities.insert(4, RemoteEntity { + id: 4, + name: None, + components: unnamed_components, + }); + + // Set the entities in the client + client.entities = entities; + client.is_connected = true; // Mark as connected for testing + + client +} + +#[test] +fn test_mock_client_creation() { + let client = create_mock_client_with_test_data(); + + assert!(client.is_connected); + assert_eq!(client.entities.len(), 4); + assert!(client.entities.contains_key(&1)); + assert!(client.entities.contains_key(&2)); + assert!(client.entities.contains_key(&3)); + assert!(client.entities.contains_key(&4)); +} + +#[test] +fn test_entity_data_structure() { + let client = create_mock_client_with_test_data(); + + // Test Player entity + let player = client.get_entity(1).expect("Player entity should exist"); + assert_eq!(player.id, 1); + assert_eq!(player.name, Some("Player".to_string())); + assert!(player.components.contains_key("bevy_transform::components::transform::Transform")); + assert!(player.components.contains_key("Player")); + + // Test Enemy entity + let enemy = client.get_entity(2).expect("Enemy entity should exist"); + assert_eq!(enemy.id, 2); + assert_eq!(enemy.name, Some("Enemy".to_string())); + assert!(enemy.components.contains_key("Enemy")); + + // Test Camera entity + let camera = client.get_entity(3).expect("Camera entity should exist"); + assert_eq!(camera.id, 3); + assert_eq!(camera.name, Some("MainCamera".to_string())); + assert!(camera.components.contains_key("bevy_render::camera::camera::Camera")); + + // Test unnamed entity + let unnamed = client.get_entity(4).expect("Unnamed entity should exist"); + assert_eq!(unnamed.id, 4); + assert_eq!(unnamed.name, None); + assert_eq!(unnamed.components.len(), 1); // Only Transform +} + +#[test] +fn test_component_data_parsing() { + let client = create_mock_client_with_test_data(); + + let player = client.get_entity(1).expect("Player entity should exist"); + + // Test Transform component parsing + if let Some(transform) = player.components.get("bevy_transform::components::transform::Transform") { + let translation = &transform["translation"]; + assert_eq!(translation["x"], 0.0); + assert_eq!(translation["y"], 0.0); + assert_eq!(translation["z"], 0.0); + } else { + panic!("Transform component should exist"); + } + + // Test Player component parsing + if let Some(player_comp) = player.components.get("Player") { + assert_eq!(player_comp["speed"], 5.0); + assert_eq!(player_comp["health"], 100); + } else { + panic!("Player component should exist"); + } +} + +#[test] +fn test_entity_ids_retrieval() { + let client = create_mock_client_with_test_data(); + + let entity_ids = client.get_entity_ids(); + assert_eq!(entity_ids.len(), 4); + + // Should contain all our test entity IDs + assert!(entity_ids.contains(&1)); + assert!(entity_ids.contains(&2)); + assert!(entity_ids.contains(&3)); + assert!(entity_ids.contains(&4)); +} + +#[test] +fn test_nonexistent_entity() { + let client = create_mock_client_with_test_data(); + + let result = client.get_entity(999); + assert!(result.is_none(), "Should return None for non-existent entity"); +} + +/// Helper function to create evolving test data (simulates live updates) +pub fn create_evolving_test_data(time_offset: f32) -> HashMap { + let mut entities = HashMap::new(); + + // Simulate moving player + let mut player_components = HashMap::new(); + player_components.insert( + "bevy_transform::components::transform::Transform".to_string(), + json!({ + "translation": { + "x": time_offset.sin() * 2.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { "x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0 }, + "scale": { "x": 1.0, "y": 1.0, "z": 1.0 } + }) + ); + + // Simulate changing health + let health = 50 + (time_offset * 0.1).sin() as i32 * 25; + player_components.insert( + "Player".to_string(), + json!({ + "speed": 5.0, + "health": health.max(1).min(100) + }) + ); + + entities.insert(1, RemoteEntity { + id: 1, + name: Some("Player".to_string()), + components: player_components, + }); + + entities +} + +#[test] +fn test_evolving_data() { + let data1 = create_evolving_test_data(0.0); + let data2 = create_evolving_test_data(1.0); + + // Data should be different at different time offsets + let player1 = &data1[&1]; + let player2 = &data2[&1]; + + let transform1 = &player1.components["bevy_transform::components::transform::Transform"]; + let transform2 = &player2.components["bevy_transform::components::transform::Transform"]; + + // X position should be different due to sin function + assert_ne!(transform1["translation"]["x"], transform2["translation"]["x"]); +} \ No newline at end of file diff --git a/crates/bevy_dev_tools/src/inspector/ui/collapsible_section.rs b/crates/bevy_dev_tools/src/inspector/ui/collapsible_section.rs new file mode 100644 index 0000000000000..7cd0d8c3a47e3 --- /dev/null +++ b/crates/bevy_dev_tools/src/inspector/ui/collapsible_section.rs @@ -0,0 +1,187 @@ +//! Collapsible section widget - suitable for upstreaming to `bevy_ui` + +use bevy_color::Color; +use bevy_ecs::prelude::*; +use bevy_text::{TextColor, TextFont}; +use bevy_ui::prelude::*; + +/// A collapsible section widget that can expand/collapse content +#[derive(Component)] +pub struct CollapsibleSection { + /// Display title for the collapsible section + pub title: String, + /// Whether the section is currently expanded or collapsed + pub is_expanded: bool, + /// Entity reference to the clickable header element + pub header_entity: Option, + /// Entity reference to the collapsible content element + pub content_entity: Option, +} + +/// Marker component for collapsible section headers (clickable) +#[derive(Component)] +pub struct CollapsibleHeader { + /// Reference to the parent collapsible section entity + pub section_entity: Entity, +} + +/// Marker component for collapsible section content (shows/hides) +#[derive(Component)] +pub struct CollapsibleContent { + /// Reference to the parent collapsible section entity + pub section_entity: Entity, +} + +/// Marker component for the text that shows the collapse/expand arrow +#[derive(Component)] +pub struct CollapsibleArrowText { + /// Reference to the parent collapsible section entity + pub section_entity: Entity, + /// The text format template (without arrow) for updating + pub text_template: String, +} + +/// Bundle for creating a collapsible section +#[derive(Bundle)] +pub struct CollapsibleSectionBundle { + /// The collapsible section component with state and references + pub collapsible: CollapsibleSection, + /// UI node layout properties for the section + pub node: Node, + /// Background color styling for the section + pub background_color: BackgroundColor, + /// Border color styling for the section + pub border_color: BorderColor, +} + +impl Default for CollapsibleSectionBundle { + fn default() -> Self { + Self { + collapsible: CollapsibleSection { + title: String::new(), + is_expanded: true, + header_entity: None, + content_entity: None, + }, + node: Node { + width: Val::Percent(100.0), + flex_direction: FlexDirection::Column, + ..Default::default() + }, + background_color: BackgroundColor(Color::srgb(0.15, 0.15, 0.2)), + border_color: BorderColor::all(Color::srgb(0.3, 0.3, 0.4)), + } + } +} + +/// System to handle collapsible section interactions +pub fn handle_collapsible_interactions( + _commands: Commands, + mut section_query: Query<&mut CollapsibleSection>, + header_query: Query<(&Interaction, &CollapsibleHeader), Changed>, + mut content_query: Query<&mut Node, With>, + mut arrow_text_query: Query<(&mut Text, &CollapsibleArrowText)>, +) { + for (interaction, header) in header_query.iter() { + if *interaction == Interaction::Pressed { + if let Ok(mut section) = section_query.get_mut(header.section_entity) { + // Toggle expansion state + section.is_expanded = !section.is_expanded; + + // Update content visibility + if let Some(content_entity) = section.content_entity { + if let Ok(mut content_node) = content_query.get_mut(content_entity) { + content_node.display = if section.is_expanded { + Display::Flex + } else { + Display::None + }; + } + } + + // Update header arrow text + for (mut arrow_text, arrow_marker) in arrow_text_query.iter_mut() { + if arrow_marker.section_entity == header.section_entity { + let arrow = if section.is_expanded { "-" } else { "+" }; + arrow_text.0 = format!("{} {}", arrow, arrow_marker.text_template); + } + } + } + } + } +} + +/// Helper function to spawn a collapsible section +pub fn spawn_collapsible_section(commands: &mut Commands, parent: Entity, title: String) -> Entity { + let section_entity = commands + .spawn(CollapsibleSectionBundle { + collapsible: CollapsibleSection { + title: title.clone(), + is_expanded: true, + header_entity: None, + content_entity: None, + }, + ..Default::default() + }) + .id(); + + commands.entity(parent).add_child(section_entity); + + // Spawn header + let header_entity = commands + .spawn(( + Button, + Node { + width: Val::Percent(100.0), + height: Val::Px(32.0), + padding: UiRect::all(Val::Px(8.0)), + align_items: AlignItems::Center, + ..Default::default() + }, + BackgroundColor(Color::srgb(0.2, 0.2, 0.25)), + CollapsibleHeader { section_entity }, + )) + .with_children(|parent| { + parent.spawn(( + Text::new(format!("- {title}")), + TextFont { + font_size: 14.0, + ..Default::default() + }, + TextColor(Color::srgb(0.9, 0.9, 0.6)), + CollapsibleArrowText { + section_entity, + text_template: title.clone(), + }, + )); + }) + .id(); + + // Spawn content container + let content_entity = commands + .spawn(( + Node { + width: Val::Percent(100.0), + padding: UiRect::all(Val::Px(8.0)), + flex_direction: FlexDirection::Column, + ..Default::default() + }, + BackgroundColor(Color::srgb(0.1, 0.1, 0.15)), + CollapsibleContent { section_entity }, + )) + .id(); + + // Set up parent-child relationships + commands.entity(section_entity).add_child(header_entity); + commands.entity(section_entity).add_child(content_entity); + + // Update section with entity references + commands.entity(section_entity).insert(CollapsibleSection { + title, + is_expanded: true, + header_entity: Some(header_entity), + content_entity: Some(content_entity), + }); + + section_entity +} diff --git a/crates/bevy_dev_tools/src/inspector/ui/component_viewer.rs b/crates/bevy_dev_tools/src/inspector/ui/component_viewer.rs new file mode 100644 index 0000000000000..f4cdc413db37f --- /dev/null +++ b/crates/bevy_dev_tools/src/inspector/ui/component_viewer.rs @@ -0,0 +1,832 @@ +//! Component viewer UI with live data updates + +use super::collapsible_section::{ + CollapsibleArrowText, CollapsibleContent, CollapsibleHeader, CollapsibleSection, +}; +use super::entity_list::{EntityCache, SelectedEntity}; +use crate::inspector::http_client::{ComponentUpdate, HttpRemoteClient}; +use crate::widgets::selectable_text::{SelectableText, TextSelectionState}; +use bevy_color::Color; +use bevy_ecs::prelude::*; +use bevy_ecs::system::ParamSet; +use bevy_input::prelude::*; +use bevy_log::debug; +use bevy_text::{TextColor, TextFont}; +use bevy_time::Time; +use bevy_ui::prelude::*; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; + +/// Component for the component viewer panel +#[derive(Component)] +pub struct ComponentViewerPanel; + +/// Component to track component data that needs live updates +#[derive(Component)] +pub struct ComponentData { + /// The entity this component data belongs to + pub entity_id: u32, + /// The name/type of the component being tracked + pub component_name: String, +} + +/// Resource to cache component data for live updates +#[derive(Resource, Default)] +pub struct ComponentCache { + /// Currently selected entity ID + pub current_entity: Option, + /// Map of component names to their serialized values + pub components: HashMap, + /// Timestamp of the last cache update + pub last_update: f64, + /// Track which entity we've built UI for + pub ui_built_for_entity: Option, +} + +/// Enhanced resource for live component caching with change tracking +#[derive(Resource)] +pub struct LiveComponentCache { + /// Map of entity IDs to their component states + pub entity_components: HashMap>, + /// Timestamp of the last update cycle + pub last_update_time: f64, + /// Target update rate in seconds (e.g., 30 FPS = 1/30) + pub update_frequency: f64, +} + +impl Default for LiveComponentCache { + fn default() -> Self { + Self { + entity_components: HashMap::new(), + last_update_time: 0.0, + update_frequency: 30.0, // 30 FPS by default + } + } +} + +/// State tracking for individual components with change indicators +#[derive(Debug, Clone)] +pub struct ComponentState { + /// The current serialized value of the component + pub current_value: Value, + /// Timestamp when this component was last changed + pub last_changed_time: f64, + /// Visual indicator for the change state + pub change_indicator: ChangeIndicator, + /// Previous value for showing diffs + pub previous_value: Option, +} + +/// Visual change indicators for components +#[derive(Debug, Clone, PartialEq)] +pub enum ChangeIndicator { + /// Component has not changed + Unchanged, + /// Component was recently changed, with duration in seconds to show indicator + Changed { + /// How long to show the changed indicator in seconds + duration: f64, + }, + /// Component was removed from the entity + Removed, + /// Component was newly added to the entity + Added, +} + +// SelectableText and TextSelectionState are now imported from widgets::selectable_text + +/// System to update component viewer when entity selection changes +pub fn update_component_viewer( + mut commands: Commands, + _http_client: Res, + entity_cache: Res, + mut component_cache: ResMut, + selected_entity: Res, + time: Res