From e4045d372a3bbda9c6c8b7aad937b4a16c306098 Mon Sep 17 00:00:00 2001 From: Seregon <109359355+seregonwar@users.noreply.github.com> Date: Wed, 17 Dec 2025 22:09:09 +0100 Subject: [PATCH] Add inspector overlay and instrumentation --- crates/strato-core/src/inspector.rs | 216 +++++++++++++++++++ crates/strato-core/src/lib.rs | 38 ++-- crates/strato-core/src/state.rs | 97 ++++++--- crates/strato-renderer/src/profiler.rs | 283 ++++++++++++++----------- crates/strato-widgets/src/inspector.rs | 271 +++++++++++++++++++++++ crates/strato-widgets/src/lib.rs | 65 +++--- docs/INSPECTOR.md | 29 +++ 7 files changed, 795 insertions(+), 204 deletions(-) create mode 100644 crates/strato-core/src/inspector.rs create mode 100644 crates/strato-widgets/src/inspector.rs create mode 100644 docs/INSPECTOR.md diff --git a/crates/strato-core/src/inspector.rs b/crates/strato-core/src/inspector.rs new file mode 100644 index 0000000..fd8f5a4 --- /dev/null +++ b/crates/strato-core/src/inspector.rs @@ -0,0 +1,216 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; +use std::time::{Duration, SystemTime}; + +use parking_lot::RwLock; + +use crate::state::StateId; +use crate::types::Rect; +use crate::widget::WidgetId; + +/// Configuration for the runtime inspector. +#[derive(Debug, Clone)] +pub struct InspectorConfig { + /// Whether the inspector is allowed to capture runtime information. + pub enabled: bool, + /// Whether layout bounds should be captured on every frame. + pub capture_layout: bool, + /// Whether state mutations should be snapshotted. + pub capture_state: bool, + /// Whether performance timelines should be tracked. + pub capture_performance: bool, +} + +impl Default for InspectorConfig { + fn default() -> Self { + Self { + enabled: cfg!(debug_assertions), + capture_layout: true, + capture_state: true, + capture_performance: true, + } + } +} + +/// A single component record in the captured hierarchy. +#[derive(Debug, Clone)] +pub struct ComponentNodeSnapshot { + pub id: WidgetId, + pub name: String, + pub depth: usize, + pub props: HashMap, + pub state: HashMap, +} + +/// Captured layout box for a widget. +#[derive(Debug, Clone, Copy)] +pub struct LayoutBoxSnapshot { + pub widget_id: WidgetId, + pub bounds: Rect, +} + +/// Captured state change metadata. +#[derive(Debug, Clone)] +pub struct StateSnapshot { + pub state_id: StateId, + pub detail: String, + pub recorded_at: SystemTime, +} + +/// Performance timeline entry (per frame). +#[derive(Debug, Clone)] +pub struct FrameTimelineSnapshot { + pub frame_id: u64, + pub cpu_time_ms: f32, + pub gpu_time_ms: f32, + pub notes: Option, +} + +/// Complete snapshot of inspector data for rendering in the overlay. +#[derive(Debug, Clone)] +pub struct InspectorSnapshot { + pub components: Vec, + pub layout_boxes: Vec, + pub state_snapshots: Vec, + pub frame_timelines: Vec, +} + +impl Default for InspectorSnapshot { + fn default() -> Self { + Self { + components: Vec::new(), + layout_boxes: Vec::new(), + state_snapshots: Vec::new(), + frame_timelines: Vec::new(), + } + } +} + +/// Runtime inspector for StratoSDK that aggregates data from multiple layers. +pub struct Inspector { + config: RwLock, + enabled: AtomicBool, + components: RwLock>, + layout_boxes: RwLock>, + state_snapshots: RwLock>, + frame_timelines: RwLock>, +} + +impl Inspector { + fn new() -> Self { + let config = InspectorConfig::default(); + Self { + enabled: AtomicBool::new(config.enabled), + components: RwLock::new(Vec::new()), + layout_boxes: RwLock::new(Vec::new()), + state_snapshots: RwLock::new(HashMap::new()), + frame_timelines: RwLock::new(Vec::new()), + config: RwLock::new(config), + } + } + + /// Update the inspector configuration at runtime. + pub fn configure(&self, config: InspectorConfig) { + *self.config.write() = config.clone(); + self.enabled.store(config.enabled, Ordering::Relaxed); + } + + /// Get the current configuration. + pub fn config(&self) -> InspectorConfig { + self.config.read().clone() + } + + /// Check if the inspector is enabled. + pub fn is_enabled(&self) -> bool { + self.enabled.load(Ordering::Relaxed) + } + + /// Enable or disable the inspector without replacing the full configuration. + pub fn set_enabled(&self, enabled: bool) { + self.enabled.store(enabled, Ordering::Relaxed); + self.config.write().enabled = enabled; + } + + /// Toggle inspector visibility. + pub fn toggle(&self) -> bool { + let next = !self.is_enabled(); + self.set_enabled(next); + next + } + + /// Reset transient per-frame information so the overlay always shows the latest data. + pub fn begin_frame(&self) { + self.layout_boxes.write().clear(); + self.components.write().clear(); + } + + /// Replace the captured widget hierarchy for the current frame. + pub fn record_component_tree(&self, nodes: Vec) { + if !self.is_enabled() { + return; + } + *self.components.write() = nodes; + } + + /// Record a layout box for a widget. + pub fn record_layout_box(&self, snapshot: LayoutBoxSnapshot) { + if !self.is_enabled() || !self.config().capture_layout { + return; + } + self.layout_boxes.write().push(snapshot); + } + + /// Record a state mutation/snapshot. + pub fn record_state_snapshot(&self, state_id: StateId, detail: impl Into) { + if !self.is_enabled() || !self.config().capture_state { + return; + } + + self.state_snapshots.write().insert( + state_id, + StateSnapshot { + state_id, + detail: detail.into(), + recorded_at: SystemTime::now(), + }, + ); + } + + /// Record a per-frame performance timeline entry. + pub fn record_frame_timeline( + &self, + frame_id: u64, + cpu_time: Duration, + gpu_time: Duration, + notes: Option, + ) { + if !self.is_enabled() || !self.config().capture_performance { + return; + } + + self.frame_timelines.write().push(FrameTimelineSnapshot { + frame_id, + cpu_time_ms: (cpu_time.as_secs_f64() * 1000.0) as f32, + gpu_time_ms: (gpu_time.as_secs_f64() * 1000.0) as f32, + notes, + }); + } + + /// Get the full snapshot used by the inspector overlay widget. + pub fn snapshot(&self) -> InspectorSnapshot { + InspectorSnapshot { + components: self.components.read().clone(), + layout_boxes: self.layout_boxes.read().clone(), + state_snapshots: self.state_snapshots.read().values().cloned().collect(), + frame_timelines: self.frame_timelines.read().clone(), + } + } +} + +static INSPECTOR: OnceLock = OnceLock::new(); + +/// Access the global inspector instance used by all layers. +pub fn inspector() -> &'static Inspector { + INSPECTOR.get_or_init(Inspector::new) +} diff --git a/crates/strato-core/src/lib.rs b/crates/strato-core/src/lib.rs index e501b19..5c5148e 100644 --- a/crates/strato-core/src/lib.rs +++ b/crates/strato-core/src/lib.rs @@ -3,41 +3,43 @@ //! This crate provides the fundamental building blocks for the StratoUI framework, //! including state management, event handling, and layout calculations. +pub mod config; +pub mod error; pub mod event; +pub mod hot_reload; +pub mod inspector; pub mod layout; -pub mod state; +pub mod logging; +pub mod plugin; pub mod reactive; +pub mod state; +pub mod text; +pub mod theme; pub mod types; -pub mod error; +pub mod ui_node; pub mod vdom; pub mod widget; pub mod window; -pub mod hot_reload; -pub mod theme; -pub mod plugin; -pub mod text; -pub mod logging; -pub mod config; -pub mod ui_node; +pub use error::{Result, StratoError, StratoResult}; pub use event::{Event, EventHandler, EventResult}; -pub use layout::{Constraints, Layout, LayoutEngine, LayoutConstraints, Size}; -pub use state::{Signal, State}; +pub use layout::{Constraints, Layout, LayoutConstraints, LayoutEngine, Size}; +pub use logging::{LogCategory, LogLevel}; pub use reactive::{Computed, Effect, Reactive}; +pub use state::{Signal, State}; pub use types::{Color, Point, Rect, Transform}; -pub use error::{StratoError, StratoResult, Result}; -pub use logging::{LogLevel, LogCategory}; /// Re-export commonly used types pub mod prelude { pub use crate::{ + error::{Result, StratoError}, event::{Event, EventHandler, EventResult}, + inspector::{inspector, InspectorConfig, InspectorSnapshot}, layout::{Constraints, Layout, Size}, - state::{Signal, State}, + logging::LogLevel, reactive::{Computed, Effect}, + state::{Signal, State}, types::{Color, Point, Rect}, - error::{StratoError, Result}, - logging::{LogLevel}, }; } @@ -49,12 +51,12 @@ pub fn init() -> Result<()> { // Initialize logging system with default config let config = config::LoggingConfig::default(); if let Err(e) = logging::init(&config) { - return Err(StratoError::Initialization { + return Err(StratoError::Initialization { message: format!("Failed to initialize logging: {}", e), context: None, }); } - + // Initialize tracing tracing::info!("StratoUI Core v{} initialized", VERSION); Ok(()) diff --git a/crates/strato-core/src/state.rs b/crates/strato-core/src/state.rs index c5f694d..0bb26f1 100644 --- a/crates/strato-core/src/state.rs +++ b/crates/strato-core/src/state.rs @@ -3,13 +3,13 @@ //! Provides reactive state primitives with signals, stores, computed values, //! effects, and automatic dependency tracking similar to modern reactive frameworks -use std::sync::Arc; -use std::any::Any; -use std::collections::HashMap; -use parking_lot::{RwLock, Mutex}; use dashmap::DashMap; +use parking_lot::{Mutex, RwLock}; use smallvec::SmallVec; +use std::any::Any; +use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; /// Unique identifier for state values pub type StateId = slotmap::DefaultKey; @@ -69,7 +69,7 @@ impl ReactiveContext { .entry(computation_id) .or_default() .push(state_id); - + self.dependents .write() .entry(state_id) @@ -137,9 +137,9 @@ impl Signal { pub fn with_context(initial: T, context: Arc) -> Self { use slotmap::SlotMap; use std::sync::OnceLock; - + static SLOT_MAP: OnceLock>> = OnceLock::new(); - + let slot_map = SLOT_MAP.get_or_init(|| Mutex::new(SlotMap::new())); let id = slot_map.lock().insert(()); @@ -168,6 +168,19 @@ impl Signal { let mut guard = self.value.write(); *guard = value.clone(); } + #[cfg(feature = "serde")] + { + // Record inspector snapshot if available. + let detail = + serde_json::to_string(&value).unwrap_or_else(|_| "".into()); + crate::inspector::inspector().record_state_snapshot(self.id, detail); + } + #[cfg(not(feature = "serde"))] + { + let type_name = std::any::type_name::(); + crate::inspector::inspector() + .record_state_snapshot(self.id, format!("Updated {}", type_name)); + } self.notify(&value); self.context.invalidate_dependents(self.id); } @@ -179,6 +192,18 @@ impl Signal { f(&mut *guard); guard.clone() }; + #[cfg(feature = "serde")] + { + let detail = + serde_json::to_string(&value).unwrap_or_else(|_| "".into()); + crate::inspector::inspector().record_state_snapshot(self.id, detail); + } + #[cfg(not(feature = "serde"))] + { + let type_name = std::any::type_name::(); + crate::inspector::inspector() + .record_state_snapshot(self.id, format!("Updated {}", type_name)); + } self.notify(&value); self.context.invalidate_dependents(self.id); } @@ -209,20 +234,21 @@ impl Signal { { let computation_id = ComputationId::new(); let computed = Signal::with_context( - self.context.run_with_tracking(computation_id, || f(&self.get())), + self.context + .run_with_tracking(computation_id, || f(&self.get())), Arc::clone(&self.context), ); - + let computed_clone = computed.clone(); let f = Arc::new(f); - + self.subscribe(Box::new(move |value: &dyn Any| { if let Some(typed_value) = value.downcast_ref::() { let new_value = f(typed_value); computed_clone.set(new_value); } })); - + computed } @@ -233,7 +259,7 @@ impl Signal { { // Run effect immediately f(&self.get()); - + // Subscribe to future changes self.subscribe(Box::new(move |value: &dyn Any| { if let Some(typed_value) = value.downcast_ref::() { @@ -312,15 +338,16 @@ impl Store { /// Add a signal to the store pub fn add_signal(&self, key: &str, initial: T) -> Signal { let signal = Signal::with_context(initial, Arc::clone(&self.context)); - self.states.insert(key.to_string(), Box::new(signal.clone())); + self.states + .insert(key.to_string(), Box::new(signal.clone())); signal } /// Get a signal from the store pub fn get_signal(&self, key: &str) -> Option> { - self.states.get(key).and_then(|entry| { - entry.value().downcast_ref::>().cloned() - }) + self.states + .get(key) + .and_then(|entry| entry.value().downcast_ref::>().cloned()) } /// Create a computed value that depends on multiple signals in the store @@ -331,7 +358,7 @@ impl Store { { let computation_id = ComputationId::new(); let initial_value = self.context.run_with_tracking(computation_id, || f(self)); - + Signal::with_context(initial_value, Arc::clone(&self.context)) } @@ -421,7 +448,7 @@ where let computation_id = ComputationId::new(); let context = global_context(); let initial_value = context.run_with_tracking(computation_id, f); - + Signal::with_context(initial_value, context) } @@ -432,9 +459,9 @@ where { // Run effect immediately f(); - + // Return a disposable that does nothing for now - + Disposable::new(|| {}) } @@ -463,7 +490,7 @@ mod tests { fn test_signal_basic() { let signal = Signal::new(42); assert_eq!(signal.get(), 42); - + signal.set(100); assert_eq!(signal.get(), 100); } @@ -473,13 +500,13 @@ mod tests { let signal = Signal::new(0); let counter = Arc::new(AtomicI32::new(0)); let counter_clone = Arc::clone(&counter); - + let _disposable = signal.subscribe(Box::new(move |value: &dyn Any| { if let Some(&val) = value.downcast_ref::() { counter_clone.store(val, Ordering::Relaxed); } })); - + signal.set(42); assert_eq!(counter.load(Ordering::Relaxed), 42); } @@ -488,9 +515,9 @@ mod tests { fn test_computed_signal() { let base = Signal::new(10); let doubled = base.computed(|&x| x * 2); - + assert_eq!(doubled.get(), 20); - + base.set(15); assert_eq!(doubled.get(), 30); } @@ -500,10 +527,10 @@ mod tests { let store = Store::new(); let counter = store.add_signal("counter", 0); let name = store.add_signal("name", "test".to_string()); - + assert_eq!(counter.get(), 0); assert_eq!(name.get(), "test"); - + counter.set(42); assert_eq!(store.get_signal::("counter").unwrap().get(), 42); } @@ -512,16 +539,16 @@ mod tests { fn test_batch_updates() { let signal1 = Signal::new(0); let signal2 = Signal::new(0); - + let mut batch = Batch::new(); let s1 = signal1.clone(); let s2 = signal2.clone(); - + batch.add(move || s1.set(10)); batch.add(move || s2.set(20)); - + batch.execute(); - + assert_eq!(signal1.get(), 10); assert_eq!(signal2.get(), 20); } @@ -530,9 +557,9 @@ mod tests { fn test_signal_map() { let signal = Signal::new(5); let mapped = signal.map(|&x| x.to_string()); - + assert_eq!(mapped.get(), "5"); - + signal.set(10); assert_eq!(mapped.get(), "10"); } @@ -541,9 +568,9 @@ mod tests { fn test_signal_filter() { let signal = Signal::new(5); let filtered = signal.filter(|&x| x > 10); - + assert_eq!(filtered.get(), None); - + signal.set(15); assert_eq!(filtered.get(), Some(15)); } diff --git a/crates/strato-renderer/src/profiler.rs b/crates/strato-renderer/src/profiler.rs index ccdefd7..96649af 100644 --- a/crates/strato-renderer/src/profiler.rs +++ b/crates/strato-renderer/src/profiler.rs @@ -10,15 +10,22 @@ //! - Historical performance data analysis //! - Multi-threaded profiling support +use anyhow::Result; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; use std::collections::{HashMap, VecDeque}; -use std::sync::{Arc, Mutex, atomic::{AtomicU32, AtomicU64, AtomicBool, Ordering}}; +use std::sync::{ + atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}, + Arc, Mutex, +}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use parking_lot::RwLock; -use anyhow::Result; -use tracing::{info, warn, debug, instrument}; -use serde::{Serialize, Deserialize}; -use wgpu::{QuerySetDescriptor, QueryType, QuerySet, Buffer, CommandEncoder, BufferDescriptor, BufferUsages, MapMode, Maintain, Features}; +use strato_core::inspector; use thread_local::ThreadLocal; +use tracing::{debug, info, instrument, warn}; +use wgpu::{ + Buffer, BufferDescriptor, BufferUsages, CommandEncoder, Features, Maintain, MapMode, QuerySet, + QuerySetDescriptor, QueryType, +}; use crate::device::ManagedDevice; use crate::resources::ResourceHandle; @@ -127,12 +134,12 @@ pub struct MemoryProfiler { peak_allocated: AtomicU64, allocation_count: AtomicU64, deallocation_count: AtomicU64, - + // Memory tracking by type buffer_memory: AtomicU64, texture_memory: AtomicU64, pipeline_memory: AtomicU64, - + // Historical data memory_history: RwLock>, leak_detection: RwLock>, @@ -273,22 +280,22 @@ pub enum OptimizationCategory { /// Main profiler system pub struct Profiler { device: Arc, - + // Sub-profilers pub gpu_timer: Option>, pub cpu_profiler: Arc, pub memory_profiler: Arc, performance_analyzer: Arc, - + // Configuration enabled: AtomicBool, detailed_profiling: AtomicBool, auto_analysis: AtomicBool, - + // Current frame tracking current_frame: AtomicU64, frame_start_time: RwLock>, - + // Statistics total_frames: AtomicU64, average_frame_time: RwLock, @@ -304,14 +311,14 @@ impl GpuTimer { ty: QueryType::Timestamp, count: capacity * 2, // Start and end queries }); - + let query_buffer = device.device.create_buffer(&BufferDescriptor { label: Some("GpuTimerBuffer"), size: (capacity * 2 * 8) as u64, // 8 bytes per timestamp usage: BufferUsages::QUERY_RESOLVE | BufferUsages::COPY_SRC, mapped_at_creation: false, }); - + Ok(Self { device, query_set, @@ -321,63 +328,60 @@ impl GpuTimer { pending_queries: RwLock::new(HashMap::new()), }) } - + /// Begin GPU timing pub fn begin_timing(&self, encoder: &mut CommandEncoder, label: &str) -> Option { let query_id = self.current_query.fetch_add(2, Ordering::Relaxed); - + if query_id + 1 >= self.capacity * 2 { return None; // Out of queries } - + encoder.write_timestamp(&self.query_set, query_id); - self.pending_queries.write().insert(query_id, label.to_string()); - + self.pending_queries + .write() + .insert(query_id, label.to_string()); + Some(query_id) } - + /// End GPU timing pub fn end_timing(&self, encoder: &mut CommandEncoder, query_id: u32) { if query_id + 1 < self.capacity * 2 { encoder.write_timestamp(&self.query_set, query_id + 1); } } - + /// Resolve timing queries pub fn resolve_queries(&self, encoder: &mut CommandEncoder) { let current = self.current_query.load(Ordering::Relaxed); if current > 0 { - encoder.resolve_query_set( - &self.query_set, - 0..current, - &self.query_buffer, - 0, - ); + encoder.resolve_query_set(&self.query_set, 0..current, &self.query_buffer, 0); } } - + /// Get timing results (async) pub async fn get_results(&self) -> Result> { let mut results = HashMap::new(); let current = self.current_query.load(Ordering::Relaxed); - + if current == 0 { return Ok(results); } - + let buffer_slice = self.query_buffer.slice(0..(current * 8) as u64); let (sender, receiver) = futures::channel::oneshot::channel(); - + buffer_slice.map_async(MapMode::Read, move |result| { sender.send(result).ok(); }); - + self.device.device.poll(Maintain::Wait); receiver.await??; - + let data = buffer_slice.get_mapped_range(); let timestamps: &[u64] = bytemuck::cast_slice(&data); - + let pending = self.pending_queries.read(); for (&query_id, label) in pending.iter() { if query_id + 1 < current { @@ -387,14 +391,14 @@ impl GpuTimer { results.insert(label.clone(), duration); } } - + drop(data); self.query_buffer.unmap(); - + // Reset for next frame self.current_query.store(0, Ordering::Relaxed); self.pending_queries.write().clear(); - + Ok(results) } } @@ -410,37 +414,37 @@ impl CpuProfiler { thread_local_data: ThreadLocal::new(), } } - + /// Begin timing a section pub fn begin_section(&self, name: &str) { if !self.enabled.load(Ordering::Relaxed) { return; } - + let thread_data = self.thread_local_data.get_or(|| { Mutex::new(ThreadProfileData { thread_id: 0, // Simplified - thread ID tracking removed ..Default::default() }) }); - + let mut data = thread_data.lock().unwrap(); data.active_timers.insert(name.to_string(), Instant::now()); } - + /// End timing a section pub fn end_section(&self, name: &str) { if !self.enabled.load(Ordering::Relaxed) { return; } - + let thread_data = self.thread_local_data.get_or(|| { Mutex::new(ThreadProfileData { thread_id: 0, // Simplified - thread ID tracking removed ..Default::default() }) }); - + let mut data = thread_data.lock().unwrap(); if let Some(start_time) = data.active_timers.remove(name) { let duration = start_time.elapsed(); @@ -454,20 +458,20 @@ impl CpuProfiler { thread_id: data.thread_id, frame_id: 0, // Will be set by profiler }; - + data.samples.push(sample); } } - + /// Collect samples from all threads pub fn collect_samples(&self) -> Vec { let mut all_samples: Vec = Vec::new(); - + for thread_data in self.thread_local_data.iter() { let mut data = thread_data.lock().unwrap(); all_samples.extend(data.samples.drain(..)); } - + // Add to global samples let mut samples = self.samples.write(); for sample in &all_samples { @@ -476,10 +480,10 @@ impl CpuProfiler { samples.pop_front(); } } - + all_samples } - + /// Get average timing for a section pub fn get_average_time(&self, _name: &str) -> Option { let samples = self.samples.read(); @@ -488,7 +492,7 @@ impl CpuProfiler { .filter(|s| s.metric_type == MetricType::FrameTime) .map(|s| s.value) .collect(); - + if matching_samples.is_empty() { None } else { @@ -513,31 +517,37 @@ impl MemoryProfiler { leak_detection: RwLock::new(HashMap::new()), } } - + /// Record allocation pub fn record_allocation(&self, handle: ResourceHandle, size: u64, resource_type: &str) { if !self.enabled.load(Ordering::Relaxed) { return; } - + self.total_allocated.fetch_add(size, Ordering::Relaxed); self.allocation_count.fetch_add(1, Ordering::Relaxed); - + // Update peak let current = self.total_allocated.load(Ordering::Relaxed); let peak = self.peak_allocated.load(Ordering::Relaxed); if current > peak { self.peak_allocated.store(current, Ordering::Relaxed); } - + // Update type-specific counters match resource_type { - "buffer" => { self.buffer_memory.fetch_add(size, Ordering::Relaxed); } - "texture" => { self.texture_memory.fetch_add(size, Ordering::Relaxed); } - "pipeline" => { self.pipeline_memory.fetch_add(size, Ordering::Relaxed); } + "buffer" => { + self.buffer_memory.fetch_add(size, Ordering::Relaxed); + } + "texture" => { + self.texture_memory.fetch_add(size, Ordering::Relaxed); + } + "pipeline" => { + self.pipeline_memory.fetch_add(size, Ordering::Relaxed); + } _ => {} } - + // Record for leak detection let allocation_info = AllocationInfo { size, @@ -545,35 +555,41 @@ impl MemoryProfiler { stack_trace: None, // Could be implemented with backtrace crate resource_type: resource_type.to_string(), }; - + self.leak_detection.write().insert(handle, allocation_info); - + // Record sample self.record_memory_sample(); } - + /// Record deallocation pub fn record_deallocation(&self, handle: ResourceHandle) { if !self.enabled.load(Ordering::Relaxed) { return; } - + if let Some(info) = self.leak_detection.write().remove(&handle) { self.total_allocated.fetch_sub(info.size, Ordering::Relaxed); self.deallocation_count.fetch_add(1, Ordering::Relaxed); - + // Update type-specific counters match info.resource_type.as_str() { - "buffer" => { self.buffer_memory.fetch_sub(info.size, Ordering::Relaxed); } - "texture" => { self.texture_memory.fetch_sub(info.size, Ordering::Relaxed); } - "pipeline" => { self.pipeline_memory.fetch_sub(info.size, Ordering::Relaxed); } + "buffer" => { + self.buffer_memory.fetch_sub(info.size, Ordering::Relaxed); + } + "texture" => { + self.texture_memory.fetch_sub(info.size, Ordering::Relaxed); + } + "pipeline" => { + self.pipeline_memory.fetch_sub(info.size, Ordering::Relaxed); + } _ => {} } } - + self.record_memory_sample(); } - + /// Record memory sample fn record_memory_sample(&self) { let sample = MemorySample { @@ -586,36 +602,57 @@ impl MemoryProfiler { texture_memory: self.texture_memory.load(Ordering::Relaxed), pipeline_memory: self.pipeline_memory.load(Ordering::Relaxed), }; - + let mut history = self.memory_history.write(); history.push_back(sample); if history.len() > 1000 { history.pop_front(); } } - + /// Detect memory leaks pub fn detect_leaks(&self, max_age: Duration) -> Vec { let now = Instant::now(); let leak_detection = self.leak_detection.read(); - + leak_detection .iter() .filter(|(_, info)| now.duration_since(info.timestamp) > max_age) .map(|(&handle, _)| handle) .collect() } - + /// Get memory statistics pub fn get_stats(&self) -> HashMap { let mut stats = HashMap::new(); - stats.insert("total_allocated".to_string(), self.total_allocated.load(Ordering::Relaxed)); - stats.insert("peak_allocated".to_string(), self.peak_allocated.load(Ordering::Relaxed)); - stats.insert("allocation_count".to_string(), self.allocation_count.load(Ordering::Relaxed)); - stats.insert("deallocation_count".to_string(), self.deallocation_count.load(Ordering::Relaxed)); - stats.insert("buffer_memory".to_string(), self.buffer_memory.load(Ordering::Relaxed)); - stats.insert("texture_memory".to_string(), self.texture_memory.load(Ordering::Relaxed)); - stats.insert("pipeline_memory".to_string(), self.pipeline_memory.load(Ordering::Relaxed)); + stats.insert( + "total_allocated".to_string(), + self.total_allocated.load(Ordering::Relaxed), + ); + stats.insert( + "peak_allocated".to_string(), + self.peak_allocated.load(Ordering::Relaxed), + ); + stats.insert( + "allocation_count".to_string(), + self.allocation_count.load(Ordering::Relaxed), + ); + stats.insert( + "deallocation_count".to_string(), + self.deallocation_count.load(Ordering::Relaxed), + ); + stats.insert( + "buffer_memory".to_string(), + self.buffer_memory.load(Ordering::Relaxed), + ); + stats.insert( + "texture_memory".to_string(), + self.texture_memory.load(Ordering::Relaxed), + ); + stats.insert( + "pipeline_memory".to_string(), + self.pipeline_memory.load(Ordering::Relaxed), + ); stats } } @@ -631,35 +668,36 @@ impl PerformanceAnalyzer { analysis_enabled: AtomicBool::new(true), } } - + /// Analyze frame performance pub fn analyze_frame(&self, _frame_timing: &FrameTiming) { // Placeholder for frame analysis } - + /// Analyze frame timing pub fn analyze_frame_timing(&self, _frame_time: Duration) { // Placeholder for frame timing analysis } - + /// Get detected bottlenecks pub fn get_bottlenecks(&self) -> Vec { self.bottleneck_detector.detected_bottlenecks.read().clone() } - + /// Get optimization suggestions pub fn get_optimization_suggestions(&self) -> Vec { self.optimization_suggestions.read().clone() } - + /// Generate optimization suggestions (moved from duplicate impl) fn generate_optimization_suggestions(&self, frame_time: Duration) { let frame_time_ms = frame_time.as_secs_f64() * 1000.0; - + let mut suggestions = self.optimization_suggestions.write(); suggestions.clear(); - - if frame_time_ms > 16.67 { // 60 FPS threshold + + if frame_time_ms > 16.67 { + // 60 FPS threshold suggestions.push(OptimizationSuggestion { title: "Frame time exceeds 60 FPS target".to_string(), description: "Consider reducing draw calls or optimizing shaders".to_string(), @@ -668,11 +706,13 @@ impl PerformanceAnalyzer { category: OptimizationCategory::Rendering, }); } - - if frame_time_ms > 33.33 { // 30 FPS threshold + + if frame_time_ms > 33.33 { + // 30 FPS threshold suggestions.push(OptimizationSuggestion { title: "Critical performance issue detected".to_string(), - description: "Frame time is critically high, immediate optimization required".to_string(), + description: "Frame time is critically high, immediate optimization required" + .to_string(), impact: OptimizationImpact::Critical, difficulty: OptimizationDifficulty::Hard, category: OptimizationCategory::Rendering, @@ -684,20 +724,20 @@ impl PerformanceAnalyzer { impl BottleneckDetector { pub fn new() -> Self { Self { - cpu_threshold: 16.0, // 16ms - gpu_threshold: 16.0, // 16ms + cpu_threshold: 16.0, // 16ms + gpu_threshold: 16.0, // 16ms memory_threshold: 0.8, // 80% detected_bottlenecks: RwLock::new(Vec::new()), } } - + /// Analyze frame time for bottlenecks pub fn analyze_frame_time(&self, frame_time: Duration) { let frame_time_ms = frame_time.as_secs_f64() * 1000.0; - + let mut bottlenecks = self.detected_bottlenecks.write(); bottlenecks.clear(); - + if frame_time_ms > self.cpu_threshold { bottlenecks.push(Bottleneck { bottleneck_type: BottleneckType::CpuBound, @@ -721,14 +761,14 @@ impl RegressionDetector { detected_regressions: RwLock::new(Vec::new()), } } - + /// Check for performance regression pub fn check_regression(&self, metric_type: MetricType, current_value: f64) { let mut baselines = self.baseline_metrics.write(); - + if let Some(&baseline) = baselines.get(&metric_type) { let regression = (current_value - baseline) / baseline; - + if regression > self.regression_threshold { let mut regressions = self.detected_regressions.write(); regressions.push(PerformanceRegression { @@ -758,11 +798,11 @@ impl Profiler { warn!("Timestamp queries not enabled on device. GPU profiling disabled."); None }; - + let cpu_profiler = Arc::new(CpuProfiler::new(10000)); let memory_profiler = Arc::new(MemoryProfiler::new()); let performance_analyzer = Arc::new(PerformanceAnalyzer::new()); - + Ok(Self { device, gpu_timer, @@ -780,57 +820,64 @@ impl Profiler { max_frame_time: RwLock::new(0.0), }) } - + /// Begin frame profiling pub fn begin_frame(&self) { if !self.enabled.load(Ordering::Relaxed) { return; } - + let frame_id = self.current_frame.fetch_add(1, Ordering::Relaxed); *self.frame_start_time.write() = Some(Instant::now()); - + self.cpu_profiler.begin_section("frame"); - + debug!("Begin frame {}", frame_id); } - + /// End frame profiling pub fn end_frame(&self) { if !self.enabled.load(Ordering::Relaxed) { return; } - + self.cpu_profiler.end_section("frame"); - + if let Some(start_time) = *self.frame_start_time.read() { let frame_time = start_time.elapsed(); let frame_time_ms = frame_time.as_secs_f64() * 1000.0; - + // Update statistics self.total_frames.fetch_add(1, Ordering::Relaxed); - + let mut avg = self.average_frame_time.write(); let total = self.total_frames.load(Ordering::Relaxed) as f64; *avg = (*avg * (total - 1.0) + frame_time_ms) / total; - + let mut min = self.min_frame_time.write(); if frame_time_ms < *min { *min = frame_time_ms; } - + let mut max = self.max_frame_time.write(); if frame_time_ms > *max { *max = frame_time_ms; } - + // Analyze performance if enabled if self.auto_analysis.load(Ordering::Relaxed) { self.performance_analyzer.analyze_frame_timing(frame_time); } + + inspector::inspector().record_frame_timeline( + self.current_frame.load(Ordering::Relaxed), + frame_time, + Duration::ZERO, + Some("CPU frame end".to_string()), + ); } } - + /// Begin GPU timing pub fn begin_gpu_timing(&self, encoder: &mut CommandEncoder, label: &str) -> Option { if self.enabled.load(Ordering::Relaxed) { @@ -843,7 +890,7 @@ impl Profiler { None } } - + /// End GPU timing pub fn end_gpu_timing(&self, encoder: &mut CommandEncoder, query_id: u32) { if self.enabled.load(Ordering::Relaxed) { @@ -852,14 +899,14 @@ impl Profiler { } } } - + /// Get comprehensive performance report pub fn get_performance_report(&self) -> PerformanceReport { let cpu_samples = self.cpu_profiler.collect_samples(); let memory_stats = self.memory_profiler.get_stats(); let bottlenecks = self.performance_analyzer.get_bottlenecks(); let suggestions = self.performance_analyzer.get_optimization_suggestions(); - + PerformanceReport { frame_stats: FrameStats { total_frames: self.total_frames.load(Ordering::Relaxed), @@ -873,12 +920,12 @@ impl Profiler { optimization_suggestions: suggestions, } } - + /// Enable/disable profiling pub fn set_enabled(&self, enabled: bool) { self.enabled.store(enabled, Ordering::Relaxed); } - + /// Enable/disable detailed profiling pub fn set_detailed_profiling(&self, enabled: bool) { self.detailed_profiling.store(enabled, Ordering::Relaxed); diff --git a/crates/strato-widgets/src/inspector.rs b/crates/strato-widgets/src/inspector.rs new file mode 100644 index 0000000..9f2a16a --- /dev/null +++ b/crates/strato-widgets/src/inspector.rs @@ -0,0 +1,271 @@ +//! In-app inspector overlay for visualizing widget trees, state snapshots, and performance timelines. + +use std::collections::HashMap; + +use glam::Vec2; +use strato_core::event::{Event, EventResult, KeyCode, KeyboardEvent, Modifiers}; +use strato_core::inspector::{self, ComponentNodeSnapshot, InspectorSnapshot, LayoutBoxSnapshot}; +use strato_core::layout::{Constraints, Layout, Size}; +use strato_core::types::{Color, Rect, Transform}; +use strato_renderer::batch::RenderBatch; + +use crate::container::Container; +use crate::layout::Column; +use crate::scroll_view::ScrollView; +use crate::text::Text; +use crate::widget::{generate_id, Widget, WidgetId}; + +const DEFAULT_PANEL_WIDTH: f32 = 340.0; +const DEFAULT_PANEL_HEIGHT: f32 = 320.0; + +/// Overlay widget that renders the inspector panel and captures instrumentation data. +#[derive(Debug)] +pub struct InspectorOverlay { + id: WidgetId, + child: Box, + shortcut: (KeyCode, Modifiers), + pub visible: bool, + cached_child_size: Size, + panel: Option>, + panel_size: Option, +} + +impl InspectorOverlay { + /// Create a new overlay wrapping the provided child widget. + pub fn new(child: impl Widget + 'static) -> Self { + Self { + id: generate_id(), + child: Box::new(child), + shortcut: ( + KeyCode::I, + Modifiers { + control: true, + shift: true, + alt: false, + super_key: false, + }, + ), + visible: false, + cached_child_size: Size::zero(), + panel: None, + panel_size: None, + } + } + + /// Override the keyboard shortcut used to toggle visibility. + pub fn shortcut(mut self, key: KeyCode, modifiers: Modifiers) -> Self { + self.shortcut = (key, modifiers); + self + } + + fn shortcut_pressed(&self, key: &KeyboardEvent) -> bool { + key.key_code == self.shortcut.0 + && key.modifiers.control == self.shortcut.1.control + && key.modifiers.shift == self.shortcut.1.shift + && key.modifiers.alt == self.shortcut.1.alt + && key.modifiers.super_key == self.shortcut.1.super_key + } + + fn collect_components( + &self, + widget: &(dyn Widget + '_), + depth: usize, + nodes: &mut Vec, + ) { + nodes.push(ComponentNodeSnapshot { + id: widget.id(), + name: format!("{:?}", widget), + depth, + props: HashMap::new(), + state: HashMap::new(), + }); + + for child in widget.children() { + self.collect_components(child, depth + 1, nodes); + } + } + + fn build_panel(&self, snapshot: &InspectorSnapshot) -> Box { + let mut lines: Vec> = Vec::new(); + lines.push(Box::new( + Text::new("Inspector (Ctrl+Shift+I)") + .font_size(16.0) + .color(Color::rgb(1.0, 1.0, 1.0)), + )); + + lines.push(Box::new( + Text::new("Component hierarchy") + .font_size(14.0) + .color(Color::rgb(0.8, 0.9, 1.0)), + )); + if snapshot.components.is_empty() { + lines.push(Box::new( + Text::new("(no widgets rendered yet)").font_size(12.0), + )); + } else { + for node in &snapshot.components { + let indent = " ".repeat(node.depth); + let line = format!("{}• {} #{}", indent, node.name, node.id); + lines.push(Box::new( + Text::new(line) + .font_size(12.0) + .color(Color::rgb(0.9, 0.9, 0.9)), + )); + } + } + + lines.push(Box::new( + Text::new("State snapshots") + .font_size(14.0) + .color(Color::rgb(0.8, 0.9, 1.0)), + )); + if snapshot.state_snapshots.is_empty() { + lines.push(Box::new( + Text::new("(no state mutations captured)").font_size(12.0), + )); + } else { + for snapshot in snapshot.state_snapshots.iter().take(8) { + let line = format!("• {} => {}", snapshot.state_id.data(), snapshot.detail); + lines.push(Box::new(Text::new(line).font_size(12.0))); + } + } + + lines.push(Box::new( + Text::new("Layout boxes") + .font_size(14.0) + .color(Color::rgb(0.8, 0.9, 1.0)), + )); + lines.push(Box::new( + Text::new(format!( + "{} boxes captured this frame", + snapshot.layout_boxes.len() + )) + .font_size(12.0), + )); + + lines.push(Box::new( + Text::new("Performance timeline") + .font_size(14.0) + .color(Color::rgb(0.8, 0.9, 1.0)), + )); + if snapshot.frame_timelines.is_empty() { + lines.push(Box::new( + Text::new("(no frames recorded yet)").font_size(12.0), + )); + } else { + for frame in snapshot.frame_timelines.iter().rev().take(5) { + let note = frame.notes.clone().unwrap_or_else(|| "".to_string()); + let line = format!( + "• Frame {}: {:.2}ms cpu / {:.2}ms gpu {}", + frame.frame_id, frame.cpu_time_ms, frame.gpu_time_ms, note + ); + lines.push(Box::new(Text::new(line).font_size(12.0))); + } + } + + let column = Column::new().spacing(4.0).children(lines); + let scrollable = ScrollView::new(column); + + Box::new( + Container::new() + .padding(12.0) + .background(Color::rgba(0.08, 0.1, 0.14, 0.92)) + .border(1.0, Color::rgba(0.4, 0.6, 1.0, 0.4)) + .child(scrollable), + ) + } +} + +impl Widget for InspectorOverlay { + fn id(&self) -> WidgetId { + self.id + } + + fn layout(&mut self, constraints: Constraints) -> Size { + let inspector = inspector::inspector(); + if inspector.is_enabled() && self.visible { + inspector.begin_frame(); + let mut nodes = Vec::new(); + self.collect_components(self.child.as_ref(), 0, &mut nodes); + inspector.record_component_tree(nodes); + } + + self.cached_child_size = self.child.layout(constraints); + + if inspector::inspector().is_enabled() && self.visible { + let snapshot = inspector::inspector().snapshot(); + let mut panel = self.build_panel(&snapshot); + let panel_constraints = Constraints { + min_width: DEFAULT_PANEL_WIDTH, + max_width: DEFAULT_PANEL_WIDTH, + min_height: 0.0, + max_height: constraints.max_height.min(DEFAULT_PANEL_HEIGHT), + }; + self.panel_size = Some(panel.layout(panel_constraints)); + self.panel = Some(panel); + } else { + self.panel = None; + self.panel_size = None; + } + + self.cached_child_size + } + + fn render(&self, batch: &mut RenderBatch, layout: Layout) { + let child_layout = Layout::new(layout.position, self.cached_child_size); + self.child.render(batch, child_layout); + + if inspector::inspector().is_enabled() && self.visible { + inspector::inspector().record_layout_box(LayoutBoxSnapshot { + widget_id: self.child.id(), + bounds: Rect::new( + layout.position.x, + layout.position.y, + self.cached_child_size.width, + self.cached_child_size.height, + ), + }); + + let snapshot = inspector::inspector().snapshot(); + for layout_box in &snapshot.layout_boxes { + batch.add_rect( + layout_box.bounds, + Color::rgba(0.1, 0.7, 1.0, 0.15), + Transform::identity(), + ); + } + + if let (Some(panel), Some(panel_size)) = (&self.panel, self.panel_size) { + let panel_pos = Vec2::new( + layout.position.x + layout.size.width - panel_size.width - 12.0, + layout.position.y + 12.0, + ); + let panel_layout = Layout::new(panel_pos, panel_size); + panel.render(batch, panel_layout); + + inspector::inspector().record_layout_box(LayoutBoxSnapshot { + widget_id: panel.id(), + bounds: Rect::new( + panel_pos.x, + panel_pos.y, + panel_size.width, + panel_size.height, + ), + }); + } + } + } + + fn handle_event(&mut self, event: &Event) -> EventResult { + if let Event::KeyDown(key) = event { + if self.shortcut_pressed(key) { + let now_visible = !self.visible; + self.visible = now_visible; + inspector::inspector().set_enabled(now_visible); + return EventResult::Handled; + } + } + + self.child.handle_event(event) + } +} diff --git a/crates/strato-widgets/src/lib.rs b/crates/strato-widgets/src/lib.rs index 815a201..059f801 100644 --- a/crates/strato-widgets/src/lib.rs +++ b/crates/strato-widgets/src/lib.rs @@ -1,46 +1,49 @@ //! StratoUI Widgets - A comprehensive widget library for StratoUI -//! +//! //! This crate provides a collection of UI widgets built on top of the StratoUI core framework. //! All widgets are designed to be composable, reactive, and performant. -pub mod widget; -pub mod button; -pub mod text; -pub mod container; -pub mod input; -pub mod layout; -pub mod theme; -pub mod wrap; -pub mod grid; pub mod animation; pub mod builder; +pub mod button; pub mod checkbox; -pub mod slider; +pub mod container; pub mod dropdown; +pub mod grid; pub mod image; -pub mod scroll_view; +pub mod input; +pub mod inspector; +pub mod layout; pub mod registry; +pub mod scroll_view; +pub mod slider; +pub mod text; +pub mod theme; +pub mod widget; +pub mod wrap; pub mod prelude; use crate::prelude::*; // Re-export all widget types for easy access -pub use strato_macros::view; -pub use widget::{Widget, WidgetContext, WidgetId}; +pub use builder::WidgetBuilder; pub use button::{Button, ButtonStyle}; -pub use text::{Text, TextStyle}; +pub use checkbox::{Checkbox, CheckboxStyle, RadioButton}; pub use container::{Container, ContainerStyle}; -pub use input::{TextInput, InputStyle, InputType}; -pub use layout::{Row, Column, Stack, Flex}; -pub use theme::{Theme}; -pub use builder::WidgetBuilder; -pub use checkbox::{Checkbox, RadioButton, CheckboxStyle}; -pub use slider::{Slider, ProgressBar, SliderStyle}; pub use dropdown::{Dropdown, DropdownOption, DropdownStyle}; -pub use image::{Image, ImageBuilder, ImageFit, ImageSource, ImageData, ImageFormat, ImageFilter, ImageStyle}; -pub use scroll_view::ScrollView; pub use grid::{Grid, GridUnit}; - +pub use image::{ + Image, ImageBuilder, ImageData, ImageFilter, ImageFit, ImageFormat, ImageSource, ImageStyle, +}; +pub use input::{InputStyle, InputType, TextInput}; +pub use inspector::InspectorOverlay; +pub use layout::{Column, Flex, Row, Stack}; +pub use scroll_view::ScrollView; +pub use slider::{ProgressBar, Slider, SliderStyle}; +pub use strato_macros::view; +pub use text::{Text, TextStyle}; +pub use theme::Theme; +pub use widget::{Widget, WidgetContext, WidgetId}; /// Initialize the widgets module pub fn init() -> strato_core::Result<()> { @@ -52,15 +55,11 @@ pub fn init() -> strato_core::Result<()> { pub fn example_app() -> impl Widget { Container::new() .padding(20.0) - .child( - Column::new() - .spacing(10.0) - .children(vec![ - Box::new(Text::new("Welcome to StratoUI")), - Box::new(Button::new("Click Me")), - Box::new(TextInput::new().placeholder("Enter text...")), - ]) - ) + .child(Column::new().spacing(10.0).children(vec![ + Box::new(Text::new("Welcome to StratoUI")), + Box::new(Button::new("Click Me")), + Box::new(TextInput::new().placeholder("Enter text...")), + ])) } #[cfg(test)] diff --git a/docs/INSPECTOR.md b/docs/INSPECTOR.md new file mode 100644 index 0000000..e22ad0e --- /dev/null +++ b/docs/INSPECTOR.md @@ -0,0 +1,29 @@ +# StratoSDK Inspector Overlay + +The inspector provides an in-app panel that visualizes the widget hierarchy, recent state snapshots, layout boxes, and frame timelines so you can debug rendering behavior without leaving the running app. + +## Toggling the overlay +- Press **Ctrl+Shift+I** (configurable when constructing `InspectorOverlay`) to toggle the panel at runtime. +- You can also enable/disable programmatically with `strato_core::inspector::inspector().set_enabled(bool)`. + +## Development vs. production defaults +- In **debug/development builds** (`cfg!(debug_assertions)`), the inspector is enabled by default. Instrumentation hooks capture layout and state changes automatically. +- In **release/production builds**, disable the inspector to remove overhead: + - Call `inspector().configure(InspectorConfig { enabled: false, ..Default::default() })` during startup, or + - Wrap your root widget in `InspectorOverlay` only for debug builds (`#[cfg(debug_assertions)]`). + +## Adding the overlay to your UI +```rust +use strato_widgets::InspectorOverlay; + +let app = build_app_ui(); +let instrumented = InspectorOverlay::new(app); +``` + +The overlay renders a compact panel with: +- **Component hierarchy**: per-widget IDs and depth. +- **State snapshots**: the latest serialized signal updates. +- **Layout boxes**: highlighted rectangles overlaying the current frame. +- **Performance timeline**: per-frame CPU/GPU timings from the renderer profiler. + +No additional plumbing is required—the overlay pulls data from the shared inspector instrumentation in `strato-core` and `strato-renderer`.