Skip to main content

appscale_core/
devtools.rs

1//! DevTools — Inspection, profiling, and debugging infrastructure.
2//!
3//! Provides:
4//! - Tree Inspector: snapshot the shadow tree into a serializable structure
5//! - Layout Overlay: gather computed layout rectangles for overlay rendering
6//! - Performance Profiler: track frame timing, layout stats, commit counts
7//! - IR Replay: record and replay IR command batches
8//! - WebSocket bridge types (protocol messages)
9
10use crate::ir::{IrBatch, IrCommand};
11use crate::layout::{ComputedLayout, LayoutEngine};
12use crate::tree::{NodeId, ShadowTree};
13use crate::platform::PropValue;
14use serde::{Serialize, Deserialize};
15use std::collections::HashMap;
16use std::time::{Duration, Instant};
17
18// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
19// Tree Inspector
20// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
21
22/// Serializable snapshot of a single node (for DevTools UI).
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct NodeSnapshot {
25    pub id: u64,
26    pub view_type: String,
27    pub component_name: Option<String>,
28    pub props: HashMap<String, serde_json::Value>,
29    pub children: Vec<NodeSnapshot>,
30    pub layout: Option<LayoutRect>,
31}
32
33/// Computed layout rectangle for a node.
34#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
35pub struct LayoutRect {
36    pub x: f32,
37    pub y: f32,
38    pub width: f32,
39    pub height: f32,
40}
41
42impl From<ComputedLayout> for LayoutRect {
43    fn from(cl: ComputedLayout) -> Self {
44        Self { x: cl.x, y: cl.y, width: cl.width, height: cl.height }
45    }
46}
47
48/// Build a full tree snapshot starting from the root.
49pub fn snapshot_tree(tree: &ShadowTree, layout_engine: &LayoutEngine) -> Option<NodeSnapshot> {
50    let root_id = tree.root()?;
51    Some(snapshot_node(root_id, tree, layout_engine))
52}
53
54fn snapshot_node(id: NodeId, tree: &ShadowTree, layout: &LayoutEngine) -> NodeSnapshot {
55    let node = tree.get(id).expect("node must exist");
56
57    let props: HashMap<String, serde_json::Value> = node.props.iter()
58        .map(|(k, v)| (k.clone(), prop_value_to_json(v)))
59        .collect();
60
61    let children: Vec<NodeSnapshot> = node.children.iter()
62        .map(|&child_id| snapshot_node(child_id, tree, layout))
63        .collect();
64
65    let layout_rect = layout.get_computed(id).map(|cl| LayoutRect::from(*cl));
66
67    NodeSnapshot {
68        id: id.0,
69        view_type: format!("{:?}", node.view_type),
70        component_name: node.component_name.clone(),
71        props,
72        children,
73        layout: layout_rect,
74    }
75}
76
77fn prop_value_to_json(v: &PropValue) -> serde_json::Value {
78    match v {
79        PropValue::String(s) => serde_json::Value::String(s.clone()),
80        PropValue::Bool(b) => serde_json::Value::Bool(*b),
81        PropValue::I32(i) => serde_json::json!(*i),
82        PropValue::F32(f) => serde_json::json!(*f),
83        PropValue::F64(f) => serde_json::json!(*f),
84        PropValue::Rect { x, y, width, height } => serde_json::json!({
85            "x": x, "y": y, "width": width, "height": height
86        }),
87        PropValue::Color(c) => serde_json::json!({
88            "r": c.r, "g": c.g, "b": c.b, "a": c.a
89        }),
90        PropValue::Null => serde_json::Value::Null,
91    }
92}
93
94// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
95// Layout Overlay
96// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
97
98/// All computed layout rectangles for overlay rendering.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct LayoutOverlay {
101    pub rects: Vec<OverlayRect>,
102}
103
104/// A single overlay rectangle with metadata.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct OverlayRect {
107    pub node_id: u64,
108    pub view_type: String,
109    pub rect: LayoutRect,
110    pub is_highlighted: bool,
111}
112
113/// Gather all layout rectangles for the overlay.
114/// If `highlight_id` is set, that node is marked as highlighted.
115pub fn gather_overlay(
116    tree: &ShadowTree,
117    layout_engine: &LayoutEngine,
118    highlight_id: Option<NodeId>,
119) -> LayoutOverlay {
120    let mut rects = Vec::new();
121
122    for (&node_id, node) in tree.iter() {
123        if let Some(cl) = layout_engine.get_computed(node_id) {
124            rects.push(OverlayRect {
125                node_id: node_id.0,
126                view_type: format!("{:?}", node.view_type),
127                rect: LayoutRect::from(*cl),
128                is_highlighted: highlight_id == Some(node_id),
129            });
130        }
131    }
132
133    // Sort by node_id for deterministic output
134    rects.sort_by_key(|r| r.node_id);
135    LayoutOverlay { rects }
136}
137
138// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
139// Performance Profiler
140// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
141
142/// A single frame timing record.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct FrameRecord {
145    pub frame_number: u64,
146    pub total_ms: f64,
147    pub layout_ms: f64,
148    pub commit_count: u32,
149    pub node_count: u32,
150}
151
152/// Ongoing frame measurement.
153pub struct FrameTimer {
154    start: Instant,
155    layout_duration: Duration,
156    commit_count: u32,
157}
158
159impl FrameTimer {
160    pub fn start() -> Self {
161        Self {
162            start: Instant::now(),
163            layout_duration: Duration::ZERO,
164            commit_count: 0,
165        }
166    }
167
168    pub fn record_layout(&mut self, duration: Duration) {
169        self.layout_duration += duration;
170    }
171
172    pub fn record_commit(&mut self) {
173        self.commit_count += 1;
174    }
175
176    pub fn finish(self, frame_number: u64, node_count: u32) -> FrameRecord {
177        let total = self.start.elapsed();
178        FrameRecord {
179            frame_number,
180            total_ms: total.as_secs_f64() * 1000.0,
181            layout_ms: self.layout_duration.as_secs_f64() * 1000.0,
182            commit_count: self.commit_count,
183            node_count,
184        }
185    }
186}
187
188/// Rolling profiler that keeps the last N frames of timing data.
189pub struct Profiler {
190    frames: Vec<FrameRecord>,
191    max_frames: usize,
192    total_frames: u64,
193}
194
195impl Profiler {
196    pub fn new(max_frames: usize) -> Self {
197        Self {
198            frames: Vec::with_capacity(max_frames),
199            max_frames,
200            total_frames: 0,
201        }
202    }
203
204    pub fn push_frame(&mut self, record: FrameRecord) {
205        if self.frames.len() >= self.max_frames {
206            self.frames.remove(0);
207        }
208        self.frames.push(record);
209        self.total_frames += 1;
210    }
211
212    pub fn frames(&self) -> &[FrameRecord] {
213        &self.frames
214    }
215
216    pub fn total_frames(&self) -> u64 {
217        self.total_frames
218    }
219
220    /// Summary stats across recorded frames.
221    pub fn summary(&self) -> ProfileSummary {
222        if self.frames.is_empty() {
223            return ProfileSummary::default();
224        }
225
226        let n = self.frames.len() as f64;
227        let avg_total = self.frames.iter().map(|f| f.total_ms).sum::<f64>() / n;
228        let avg_layout = self.frames.iter().map(|f| f.layout_ms).sum::<f64>() / n;
229        let max_total = self.frames.iter().map(|f| f.total_ms).fold(0.0_f64, f64::max);
230        let total_commits: u32 = self.frames.iter().map(|f| f.commit_count).sum();
231
232        ProfileSummary {
233            frame_count: self.frames.len() as u64,
234            avg_frame_ms: avg_total,
235            avg_layout_ms: avg_layout,
236            max_frame_ms: max_total,
237            total_commits,
238        }
239    }
240}
241
242/// Aggregated profiler statistics.
243#[derive(Debug, Clone, Default, Serialize, Deserialize)]
244pub struct ProfileSummary {
245    pub frame_count: u64,
246    pub avg_frame_ms: f64,
247    pub avg_layout_ms: f64,
248    pub max_frame_ms: f64,
249    pub total_commits: u32,
250}
251
252// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
253// IR Replay
254// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
255
256/// Records IR batches for later replay or debugging.
257pub struct IrRecorder {
258    batches: Vec<TimestampedBatch>,
259    recording: bool,
260    start_time: Option<Instant>,
261}
262
263/// An IR batch with its relative timestamp.
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct TimestampedBatch {
266    pub offset_ms: f64,
267    pub batch: IrBatch,
268}
269
270impl IrRecorder {
271    pub fn new() -> Self {
272        Self {
273            batches: Vec::new(),
274            recording: false,
275            start_time: None,
276        }
277    }
278
279    pub fn start_recording(&mut self) {
280        self.batches.clear();
281        self.recording = true;
282        self.start_time = Some(Instant::now());
283    }
284
285    pub fn stop_recording(&mut self) {
286        self.recording = false;
287    }
288
289    pub fn is_recording(&self) -> bool {
290        self.recording
291    }
292
293    /// Record a batch if recording is active.
294    pub fn record(&mut self, batch: &IrBatch) {
295        if !self.recording { return; }
296        let offset_ms = self.start_time
297            .map(|t| t.elapsed().as_secs_f64() * 1000.0)
298            .unwrap_or(0.0);
299        self.batches.push(TimestampedBatch {
300            offset_ms,
301            batch: batch.clone(),
302        });
303    }
304
305    /// Get all recorded batches.
306    pub fn batches(&self) -> &[TimestampedBatch] {
307        &self.batches
308    }
309
310    /// Number of recorded batches.
311    pub fn len(&self) -> usize {
312        self.batches.len()
313    }
314
315    pub fn is_empty(&self) -> bool {
316        self.batches.is_empty()
317    }
318
319    /// Export recorded batches as JSON.
320    pub fn export_json(&self) -> serde_json::Value {
321        serde_json::json!({
322            "version": 1,
323            "batch_count": self.batches.len(),
324            "batches": self.batches,
325        })
326    }
327}
328
329impl Default for IrRecorder {
330    fn default() -> Self { Self::new() }
331}
332
333// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
334// DevTools Protocol (WebSocket messages)
335// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
336
337/// Messages from DevTools UI → Engine.
338#[derive(Debug, Clone, Serialize, Deserialize)]
339#[serde(tag = "type")]
340pub enum DevToolsRequest {
341    /// Request a full tree snapshot.
342    #[serde(rename = "getTree")]
343    GetTree,
344
345    /// Request layout overlay data.
346    #[serde(rename = "getOverlay")]
347    GetOverlay { highlight_node_id: Option<u64> },
348
349    /// Request profiler summary.
350    #[serde(rename = "getProfileSummary")]
351    GetProfileSummary,
352
353    /// Request recent frame records.
354    #[serde(rename = "getFrames")]
355    GetFrames { count: Option<usize> },
356
357    /// Start/stop IR recording.
358    #[serde(rename = "setRecording")]
359    SetRecording { enabled: bool },
360
361    /// Get recorded IR batches.
362    #[serde(rename = "getRecording")]
363    GetRecording,
364
365    /// Highlight a specific node.
366    #[serde(rename = "highlightNode")]
367    HighlightNode { node_id: u64 },
368}
369
370/// Messages from Engine → DevTools UI.
371#[derive(Debug, Clone, Serialize, Deserialize)]
372#[serde(tag = "type")]
373pub enum DevToolsResponse {
374    #[serde(rename = "tree")]
375    Tree { root: Option<NodeSnapshot> },
376
377    #[serde(rename = "overlay")]
378    Overlay { overlay: LayoutOverlay },
379
380    #[serde(rename = "profileSummary")]
381    ProfileSummary { summary: ProfileSummary },
382
383    #[serde(rename = "frames")]
384    Frames { frames: Vec<FrameRecord> },
385
386    #[serde(rename = "recording")]
387    Recording { data: serde_json::Value },
388
389    #[serde(rename = "error")]
390    Error { message: String },
391}
392
393/// Handle an incoming DevTools request and produce a response.
394pub fn handle_devtools_request(
395    request: &DevToolsRequest,
396    tree: &ShadowTree,
397    layout_engine: &LayoutEngine,
398    profiler: &Profiler,
399    recorder: &mut IrRecorder,
400) -> DevToolsResponse {
401    match request {
402        DevToolsRequest::GetTree => {
403            let root = snapshot_tree(tree, layout_engine);
404            DevToolsResponse::Tree { root }
405        }
406        DevToolsRequest::GetOverlay { highlight_node_id } => {
407            let highlight = highlight_node_id.map(NodeId);
408            let overlay = gather_overlay(tree, layout_engine, highlight);
409            DevToolsResponse::Overlay { overlay }
410        }
411        DevToolsRequest::GetProfileSummary => {
412            let summary = profiler.summary();
413            DevToolsResponse::ProfileSummary { summary }
414        }
415        DevToolsRequest::GetFrames { count } => {
416            let all = profiler.frames();
417            let frames = match count {
418                Some(n) => all[all.len().saturating_sub(*n)..].to_vec(),
419                None => all.to_vec(),
420            };
421            DevToolsResponse::Frames { frames }
422        }
423        DevToolsRequest::SetRecording { enabled } => {
424            if *enabled {
425                recorder.start_recording();
426            } else {
427                recorder.stop_recording();
428            }
429            DevToolsResponse::Recording { data: serde_json::json!({"recording": enabled}) }
430        }
431        DevToolsRequest::GetRecording => {
432            let data = recorder.export_json();
433            DevToolsResponse::Recording { data }
434        }
435        DevToolsRequest::HighlightNode { node_id } => {
436            let highlight = Some(NodeId(*node_id));
437            let overlay = gather_overlay(tree, layout_engine, highlight);
438            DevToolsResponse::Overlay { overlay }
439        }
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use crate::platform::ViewType;
447    use std::thread;
448
449    #[test]
450    fn test_snapshot_empty_tree() {
451        let tree = ShadowTree::new();
452        let layout = LayoutEngine::new();
453        assert!(snapshot_tree(&tree, &layout).is_none());
454    }
455
456    #[test]
457    fn test_snapshot_tree_with_nodes() {
458        let mut tree = ShadowTree::new();
459        let mut layout = LayoutEngine::new();
460
461        // Create root -> child hierarchy
462        let root_id = NodeId(1);
463        let child_id = NodeId(2);
464
465        let mut root_props = HashMap::new();
466        root_props.insert("title".into(), PropValue::String("Hello".into()));
467
468        tree.create_node(root_id, ViewType::Container, root_props);
469        tree.create_node(child_id, ViewType::Text, HashMap::new());
470        tree.set_root(root_id);
471        tree.append_child(root_id, child_id);
472
473        layout.create_node(root_id, &Default::default()).unwrap();
474        layout.create_node(child_id, &Default::default()).unwrap();
475
476        let snap = snapshot_tree(&tree, &layout).unwrap();
477        assert_eq!(snap.id, 1);
478        assert_eq!(snap.children.len(), 1);
479        assert_eq!(snap.children[0].id, 2);
480        assert_eq!(snap.props.get("title").unwrap(), "Hello");
481    }
482
483    #[test]
484    fn test_layout_overlay_empty() {
485        let tree = ShadowTree::new();
486        let layout = LayoutEngine::new();
487        let overlay = gather_overlay(&tree, &layout, None);
488        assert!(overlay.rects.is_empty());
489    }
490
491    #[test]
492    fn test_frame_timer() {
493        let mut timer = FrameTimer::start();
494        timer.record_layout(Duration::from_millis(5));
495        timer.record_commit();
496        timer.record_commit();
497        // Let a tiny bit of time pass
498        thread::sleep(Duration::from_millis(1));
499        let record = timer.finish(1, 10);
500        assert_eq!(record.frame_number, 1);
501        assert_eq!(record.commit_count, 2);
502        assert_eq!(record.node_count, 10);
503        assert!(record.total_ms >= 1.0);
504        assert!(record.layout_ms >= 4.5); // ~5ms
505    }
506
507    #[test]
508    fn test_profiler_summary() {
509        let mut profiler = Profiler::new(100);
510        profiler.push_frame(FrameRecord {
511            frame_number: 1, total_ms: 10.0, layout_ms: 4.0, commit_count: 2, node_count: 50,
512        });
513        profiler.push_frame(FrameRecord {
514            frame_number: 2, total_ms: 20.0, layout_ms: 8.0, commit_count: 3, node_count: 55,
515        });
516
517        let summary = profiler.summary();
518        assert_eq!(summary.frame_count, 2);
519        assert!((summary.avg_frame_ms - 15.0).abs() < 0.01);
520        assert!((summary.avg_layout_ms - 6.0).abs() < 0.01);
521        assert!((summary.max_frame_ms - 20.0).abs() < 0.01);
522        assert_eq!(summary.total_commits, 5);
523    }
524
525    #[test]
526    fn test_profiler_rolling_window() {
527        let mut profiler = Profiler::new(3);
528        for i in 1..=5 {
529            profiler.push_frame(FrameRecord {
530                frame_number: i, total_ms: i as f64, layout_ms: 0.0,
531                commit_count: 1, node_count: 10,
532            });
533        }
534        assert_eq!(profiler.frames().len(), 3);
535        assert_eq!(profiler.frames()[0].frame_number, 3);
536        assert_eq!(profiler.frames()[2].frame_number, 5);
537        assert_eq!(profiler.total_frames(), 5);
538    }
539
540    #[test]
541    fn test_ir_recorder() {
542        use crate::ir::IrCommand;
543
544        let mut recorder = IrRecorder::new();
545        assert!(!recorder.is_recording());
546
547        recorder.start_recording();
548        assert!(recorder.is_recording());
549
550        let batch = IrBatch {
551            commit_id: 1,
552            timestamp_ms: 0.0,
553            commands: vec![IrCommand::SetRootNode { id: NodeId(1) }],
554        };
555        recorder.record(&batch);
556        recorder.record(&batch);
557
558        recorder.stop_recording();
559        assert_eq!(recorder.len(), 2);
560
561        let json = recorder.export_json();
562        assert_eq!(json["batch_count"], 2);
563        assert_eq!(json["version"], 1);
564    }
565
566    #[test]
567    fn test_devtools_protocol_roundtrip() {
568        let request_json = r#"{"type":"getTree"}"#;
569        let request: DevToolsRequest = serde_json::from_str(request_json).unwrap();
570        assert!(matches!(request, DevToolsRequest::GetTree));
571
572        let response = DevToolsResponse::Tree { root: None };
573        let json = serde_json::to_string(&response).unwrap();
574        assert!(json.contains("\"type\":\"tree\""));
575    }
576
577    #[test]
578    fn test_handle_devtools_request_get_tree() {
579        let tree = ShadowTree::new();
580        let layout = LayoutEngine::new();
581        let profiler = Profiler::new(100);
582        let mut recorder = IrRecorder::new();
583
584        let resp = handle_devtools_request(
585            &DevToolsRequest::GetTree, &tree, &layout, &profiler, &mut recorder,
586        );
587        match resp {
588            DevToolsResponse::Tree { root } => assert!(root.is_none()),
589            _ => panic!("expected Tree response"),
590        }
591    }
592
593    #[test]
594    fn test_handle_devtools_set_recording() {
595        let tree = ShadowTree::new();
596        let layout = LayoutEngine::new();
597        let profiler = Profiler::new(100);
598        let mut recorder = IrRecorder::new();
599
600        handle_devtools_request(
601            &DevToolsRequest::SetRecording { enabled: true },
602            &tree, &layout, &profiler, &mut recorder,
603        );
604        assert!(recorder.is_recording());
605
606        handle_devtools_request(
607            &DevToolsRequest::SetRecording { enabled: false },
608            &tree, &layout, &profiler, &mut recorder,
609        );
610        assert!(!recorder.is_recording());
611    }
612}