StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.
| 1 | //! In-app inspector overlay for visualizing widget trees, state snapshots, and performance timelines. |
| 2 | |
| 3 | use std::collections::HashMap; |
| 4 | |
| 5 | use glam::Vec2; |
| 6 | use strato_core::event::{Event, EventResult, KeyCode, KeyboardEvent, Modifiers}; |
| 7 | use strato_core::inspector::{self, ComponentNodeSnapshot, InspectorSnapshot, LayoutBoxSnapshot}; |
| 8 | use strato_core::layout::{Constraints, Layout, Size}; |
| 9 | use strato_core::types::{Color, Rect, Transform}; |
| 10 | use strato_renderer::batch::RenderBatch; |
| 11 | |
| 12 | use crate::container::Container; |
| 13 | use crate::layout::Column; |
| 14 | use crate::scroll_view::ScrollView; |
| 15 | use crate::text::Text; |
| 16 | use crate::widget::{generate_id, Widget, WidgetId}; |
| 17 | use slotmap::Key; |
| 18 | |
| 19 | const DEFAULT_PANEL_WIDTH: f32 = 340.0; |
| 20 | const DEFAULT_PANEL_HEIGHT: f32 = 320.0; |
| 21 | |
| 22 | /// Overlay widget that renders the inspector panel and captures instrumentation data. |
| 23 | #[derive(Debug)] |
| 24 | pub struct InspectorOverlay { |
| 25 | id: WidgetId, |
| 26 | child: Box<dyn Widget>, |
| 27 | shortcut: (KeyCode, Modifiers), |
| 28 | pub visible: bool, |
| 29 | cached_child_size: Size, |
| 30 | panel: Option<Box<dyn Widget>>, |
| 31 | panel_size: Option<Size>, |
| 32 | } |
| 33 | |
| 34 | impl InspectorOverlay { |
| 35 | /// Create a new overlay wrapping the provided child widget. |
| 36 | pub fn new(child: impl Widget + 'static) -> Self { |
| 37 | Self { |
| 38 | id: generate_id(), |
| 39 | child: Box::new(child), |
| 40 | shortcut: ( |
| 41 | KeyCode::I, |
| 42 | Modifiers { |
| 43 | control: true, |
| 44 | shift: true, |
| 45 | alt: false, |
| 46 | super_key: false, |
| 47 | }, |
| 48 | ), |
| 49 | visible: false, |
| 50 | cached_child_size: Size::zero(), |
| 51 | panel: None, |
| 52 | panel_size: None, |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | /// Override the keyboard shortcut used to toggle visibility. |
| 57 | pub fn shortcut(mut self, key: KeyCode, modifiers: Modifiers) -> Self { |
| 58 | self.shortcut = (key, modifiers); |
| 59 | self |
| 60 | } |
| 61 | |
| 62 | fn shortcut_pressed(&self, key: &KeyboardEvent) -> bool { |
| 63 | key.key_code == self.shortcut.0 |
| 64 | && key.modifiers.control == self.shortcut.1.control |
| 65 | && key.modifiers.shift == self.shortcut.1.shift |
| 66 | && key.modifiers.alt == self.shortcut.1.alt |
| 67 | && key.modifiers.super_key == self.shortcut.1.super_key |
| 68 | } |
| 69 | |
| 70 | fn collect_components( |
| 71 | &self, |
| 72 | widget: &(dyn Widget + '_), |
| 73 | depth: usize, |
| 74 | nodes: &mut Vec<ComponentNodeSnapshot>, |
| 75 | ) { |
| 76 | nodes.push(ComponentNodeSnapshot { |
| 77 | id: strato_core::widget::WidgetId(widget.id()), |
| 78 | name: format!("{:?}", widget), |
| 79 | depth, |
| 80 | props: HashMap::new(), |
| 81 | state: HashMap::new(), |
| 82 | }); |
| 83 | |
| 84 | for child in widget.children() { |
| 85 | self.collect_components(child, depth + 1, nodes); |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | fn build_panel(&self, snapshot: &InspectorSnapshot) -> Box<dyn Widget> { |
| 90 | let mut lines: Vec<Box<dyn Widget>> = Vec::new(); |
| 91 | lines.push(Box::new( |
| 92 | Text::new("Inspector (Ctrl+Shift+I)") |
| 93 | .font_size(16.0) |
| 94 | .color(Color::rgb(1.0, 1.0, 1.0)), |
| 95 | )); |
| 96 | |
| 97 | lines.push(Box::new( |
| 98 | Text::new("Component hierarchy") |
| 99 | .font_size(14.0) |
| 100 | .color(Color::rgb(0.8, 0.9, 1.0)), |
| 101 | )); |
| 102 | if snapshot.components.is_empty() { |
| 103 | lines.push(Box::new( |
| 104 | Text::new("(no widgets rendered yet)").font_size(12.0), |
| 105 | )); |
| 106 | } else { |
| 107 | for node in &snapshot.components { |
| 108 | let indent = " ".repeat(node.depth); |
| 109 | let line = format!("{}• {} #{:?}", indent, node.name, node.id); |
| 110 | lines.push(Box::new( |
| 111 | Text::new(line) |
| 112 | .font_size(12.0) |
| 113 | .color(Color::rgb(0.9, 0.9, 0.9)), |
| 114 | )); |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | lines.push(Box::new( |
| 119 | Text::new("State snapshots") |
| 120 | .font_size(14.0) |
| 121 | .color(Color::rgb(0.8, 0.9, 1.0)), |
| 122 | )); |
| 123 | if snapshot.state_snapshots.is_empty() { |
| 124 | lines.push(Box::new( |
| 125 | Text::new("(no state mutations captured)").font_size(12.0), |
| 126 | )); |
| 127 | } else { |
| 128 | for snapshot in snapshot.state_snapshots.iter().take(8) { |
| 129 | let line = format!("• {:?} => {}", snapshot.state_id.data(), snapshot.detail); |
| 130 | lines.push(Box::new(Text::new(line).font_size(12.0))); |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | lines.push(Box::new( |
| 135 | Text::new("Layout boxes") |
| 136 | .font_size(14.0) |
| 137 | .color(Color::rgb(0.8, 0.9, 1.0)), |
| 138 | )); |
| 139 | lines.push(Box::new( |
| 140 | Text::new(format!( |
| 141 | "{} boxes captured this frame", |
| 142 | snapshot.layout_boxes.len() |
| 143 | )) |
| 144 | .font_size(12.0), |
| 145 | )); |
| 146 | |
| 147 | lines.push(Box::new( |
| 148 | Text::new("Performance timeline") |
| 149 | .font_size(14.0) |
| 150 | .color(Color::rgb(0.8, 0.9, 1.0)), |
| 151 | )); |
| 152 | if snapshot.frame_timelines.is_empty() { |
| 153 | lines.push(Box::new( |
| 154 | Text::new("(no frames recorded yet)").font_size(12.0), |
| 155 | )); |
| 156 | } else { |
| 157 | for frame in snapshot.frame_timelines.iter().rev().take(5) { |
| 158 | let note = frame.notes.clone().unwrap_or_else(|| "".to_string()); |
| 159 | let line = format!( |
| 160 | "• Frame {}: {:.2}ms cpu / {:.2}ms gpu {}", |
| 161 | frame.frame_id, frame.cpu_time_ms, frame.gpu_time_ms, note |
| 162 | ); |
| 163 | lines.push(Box::new(Text::new(line).font_size(12.0))); |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | let column = Column::new().spacing(4.0).children(lines); |
| 168 | let scrollable = ScrollView::new(column); |
| 169 | |
| 170 | Box::new( |
| 171 | Container::new() |
| 172 | .padding(12.0) |
| 173 | .background(Color::rgba(0.08, 0.1, 0.14, 0.92)) |
| 174 | .border(1.0, Color::rgba(0.4, 0.6, 1.0, 0.4)) |
| 175 | .child(scrollable), |
| 176 | ) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | impl Widget for InspectorOverlay { |
| 181 | fn id(&self) -> WidgetId { |
| 182 | self.id |
| 183 | } |
| 184 | |
| 185 | fn as_any(&self) -> &dyn std::any::Any { |
| 186 | self |
| 187 | } |
| 188 | |
| 189 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { |
| 190 | self |
| 191 | } |
| 192 | |
| 193 | fn clone_widget(&self) -> Box<dyn Widget> { |
| 194 | // Since we can't easily clone the boxed child trait object without more bounds, |
| 195 | // and InspectorOverlay is likely a singleton/special widget, |
| 196 | // we might need a specific strategy. |
| 197 | // For now, assuming `InspectorOverlay` needs to be Clone but `child` is `Box<dyn Widget>`. |
| 198 | // `Box<dyn Widget>` isn't automatically cloneable unless `Widget` has `clone_widget`. |
| 199 | // We can use the child's `clone_widget` method. |
| 200 | Box::new(Self { |
| 201 | id: generate_id(), // Generate new ID on clone? Or copy? Usually clone implies new ID for widgets or copy? |
| 202 | // BaseWidget generates new ID. |
| 203 | child: self.child.clone_widget(), |
| 204 | shortcut: self.shortcut, |
| 205 | visible: self.visible, |
| 206 | cached_child_size: self.cached_child_size, |
| 207 | panel: self.panel.as_ref().map(|p| p.clone_widget()), |
| 208 | panel_size: self.panel_size, |
| 209 | }) |
| 210 | } |
| 211 | |
| 212 | fn layout(&mut self, constraints: Constraints) -> Size { |
| 213 | let inspector = inspector::inspector(); |
| 214 | if inspector.is_enabled() && self.visible { |
| 215 | inspector.begin_frame(); |
| 216 | let mut nodes = Vec::new(); |
| 217 | self.collect_components(self.child.as_ref(), 0, &mut nodes); |
| 218 | inspector.record_component_tree(nodes); |
| 219 | } |
| 220 | |
| 221 | self.cached_child_size = self.child.layout(constraints); |
| 222 | |
| 223 | if inspector::inspector().is_enabled() && self.visible { |
| 224 | let snapshot = inspector::inspector().snapshot(); |
| 225 | let mut panel = self.build_panel(&snapshot); |
| 226 | let panel_constraints = Constraints { |
| 227 | min_width: DEFAULT_PANEL_WIDTH, |
| 228 | max_width: DEFAULT_PANEL_WIDTH, |
| 229 | min_height: 0.0, |
| 230 | max_height: constraints.max_height.min(DEFAULT_PANEL_HEIGHT), |
| 231 | }; |
| 232 | self.panel_size = Some(panel.layout(panel_constraints)); |
| 233 | self.panel = Some(panel); |
| 234 | } else { |
| 235 | self.panel = None; |
| 236 | self.panel_size = None; |
| 237 | } |
| 238 | |
| 239 | self.cached_child_size |
| 240 | } |
| 241 | |
| 242 | fn render(&self, batch: &mut RenderBatch, layout: Layout) { |
| 243 | let child_layout = Layout::new(layout.position, self.cached_child_size); |
| 244 | self.child.render(batch, child_layout); |
| 245 | |
| 246 | if inspector::inspector().is_enabled() && self.visible { |
| 247 | inspector::inspector().record_layout_box(LayoutBoxSnapshot { |
| 248 | widget_id: strato_core::widget::WidgetId(self.child.id()), |
| 249 | bounds: Rect::new( |
| 250 | layout.position.x, |
| 251 | layout.position.y, |
| 252 | self.cached_child_size.width, |
| 253 | self.cached_child_size.height, |
| 254 | ), |
| 255 | }); |
| 256 | |
| 257 | let snapshot = inspector::inspector().snapshot(); |
| 258 | for layout_box in &snapshot.layout_boxes { |
| 259 | batch.add_rect( |
| 260 | layout_box.bounds, |
| 261 | Color::rgba(0.1, 0.7, 1.0, 0.15), |
| 262 | Transform::identity(), |
| 263 | ); |
| 264 | } |
| 265 | |
| 266 | if let (Some(panel), Some(panel_size)) = (&self.panel, self.panel_size) { |
| 267 | let panel_pos = Vec2::new( |
| 268 | layout.position.x + layout.size.width - panel_size.width - 12.0, |
| 269 | layout.position.y + 12.0, |
| 270 | ); |
| 271 | let panel_layout = Layout::new(panel_pos, panel_size); |
| 272 | panel.render(batch, panel_layout); |
| 273 | |
| 274 | inspector::inspector().record_layout_box(LayoutBoxSnapshot { |
| 275 | widget_id: strato_core::widget::WidgetId(panel.id()), |
| 276 | bounds: Rect::new( |
| 277 | panel_pos.x, |
| 278 | panel_pos.y, |
| 279 | panel_size.width, |
| 280 | panel_size.height, |
| 281 | ), |
| 282 | }); |
| 283 | } |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | fn handle_event(&mut self, event: &Event) -> EventResult { |
| 288 | if let Event::KeyDown(key) = event { |
| 289 | if self.shortcut_pressed(key) { |
| 290 | let now_visible = !self.visible; |
| 291 | self.visible = now_visible; |
| 292 | inspector::inspector().set_enabled(now_visible); |
| 293 | return EventResult::Handled; |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | self.child.handle_event(event) |
| 298 | } |
| 299 | } |
| 300 |