Skip to main content

fret_ui/tree/layout/
taffy_debug.rs

1use super::*;
2use crate::layout_engine::{DebugDumpNodeInfo, TaffyLayoutEngine};
3
4#[cfg(not(target_arch = "wasm32"))]
5#[derive(Debug, Clone, Copy)]
6struct LayoutSidecarRootRecord {
7    capture_index: usize,
8    kind: &'static str,
9    root: NodeId,
10    root_bounds: Rect,
11    blocks_underlay_input: bool,
12    blocks_underlay_focus: bool,
13    hit_testable: bool,
14}
15
16fn layout_debug_node_info<H: UiHost>(
17    app: &mut H,
18    window: AppWindowId,
19    node: NodeId,
20) -> DebugDumpNodeInfo {
21    let Some(record) = crate::declarative::frame::element_record_for_node(app, window, node) else {
22        return DebugDumpNodeInfo::default();
23    };
24
25    let mut label = format!("{:?}", record.instance);
26    let mut debug = serde_json::Map::new();
27    debug.insert(
28        "element_id".to_string(),
29        serde_json::json!(record.element.0),
30    );
31    debug.insert(
32        "instance_kind".to_string(),
33        serde_json::json!(record.instance.kind_name()),
34    );
35
36    let mut effective_test_id: Option<String> = None;
37    let mut effective_role: Option<String> = None;
38    let mut effective_label: Option<String> = None;
39
40    match &record.instance {
41        crate::declarative::frame::ElementInstance::Semantics(props) => {
42            effective_role = Some(format!("{:?}", props.role));
43            effective_test_id = props.test_id.as_ref().map(ToString::to_string);
44            effective_label = props.label.as_ref().map(ToString::to_string);
45        }
46        crate::declarative::frame::ElementInstance::SemanticFlex(props) => {
47            effective_role = Some(format!("{:?}", props.role));
48        }
49        _ => {}
50    }
51
52    if let Some(decoration) = record.semantics_decoration.as_ref() {
53        let mut decoration_debug = serde_json::Map::new();
54        if let Some(test_id) = decoration.test_id.as_ref() {
55            decoration_debug.insert("test_id".to_string(), serde_json::json!(test_id.as_ref()));
56            effective_test_id = Some(test_id.to_string());
57        }
58        if let Some(role) = decoration.role {
59            let role = format!("{role:?}");
60            decoration_debug.insert("role".to_string(), serde_json::json!(role));
61            effective_role = Some(role);
62        }
63        if let Some(label_text) = decoration.label.as_ref() {
64            decoration_debug.insert("label".to_string(), serde_json::json!(label_text.as_ref()));
65            effective_label = Some(label_text.to_string());
66        }
67        if !decoration_debug.is_empty() {
68            debug.insert(
69                "semantics_decoration".to_string(),
70                serde_json::Value::Object(decoration_debug),
71            );
72        }
73    }
74
75    if let Some(test_id) = effective_test_id.as_ref() {
76        debug.insert("test_id".to_string(), serde_json::json!(test_id));
77        label.push_str(&format!(" [test_id={test_id}]"));
78    }
79    if let Some(role) = effective_role.as_ref() {
80        debug.insert("semantics_role".to_string(), serde_json::json!(role));
81        label.push_str(&format!(" [semantics_role={role}]"));
82    }
83    if let Some(label_text) = effective_label.as_ref() {
84        debug.insert("semantics_label".to_string(), serde_json::json!(label_text));
85        label.push_str(&format!(" [semantics_label={label_text}]"));
86    }
87    if let Some(key_context) = record.key_context.as_ref() {
88        debug.insert(
89            "key_context".to_string(),
90            serde_json::json!(key_context.as_ref()),
91        );
92    }
93
94    DebugDumpNodeInfo {
95        label: Some(label),
96        debug: Some(serde_json::Value::Object(debug)),
97    }
98}
99
100fn layout_debug_search_label<H: UiHost>(app: &mut H, window: AppWindowId, node: NodeId) -> String {
101    layout_debug_node_info(app, window, node)
102        .label
103        .unwrap_or_default()
104}
105
106#[cfg(not(target_arch = "wasm32"))]
107fn find_layout_debug_match_in_subtree<H: UiHost>(
108    tree: &UiTree<H>,
109    app: &mut H,
110    window: AppWindowId,
111    root: NodeId,
112    filter: &str,
113) -> Option<NodeId> {
114    let root_label = layout_debug_search_label(app, window, root);
115    if root_label.contains(filter) {
116        return Some(root);
117    }
118
119    let mut stack: Vec<NodeId> = vec![root];
120    let mut visited: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
121    while let Some(node) = stack.pop() {
122        if !visited.insert(node) {
123            continue;
124        }
125
126        let label = layout_debug_search_label(app, window, node);
127        if label.contains(filter) {
128            return Some(node);
129        }
130
131        if let Some(node) = tree.nodes.get(node) {
132            stack.extend(node.children.iter().copied());
133        }
134    }
135
136    None
137}
138
139impl<H: UiHost> UiTree<H> {
140    #[cfg(not(target_arch = "wasm32"))]
141    fn layout_sidecar_roots(
142        &self,
143        fallback_root: NodeId,
144        fallback_bounds: Rect,
145    ) -> Vec<LayoutSidecarRootRecord> {
146        let layer_roots: Vec<NodeId> = self
147            .visible_layers_in_paint_order()
148            .filter_map(|layer_id| self.layers.get(layer_id).map(|layer| layer.root))
149            .collect();
150
151        let mut seen: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
152        let mut roots: Vec<LayoutSidecarRootRecord> = self
153            .visible_layers_in_paint_order()
154            .enumerate()
155            .filter_map(|(capture_index, layer_id)| {
156                let layer = self.layers.get(layer_id)?;
157                if !seen.insert(layer.root) {
158                    return None;
159                }
160                let root_bounds = self
161                    .nodes
162                    .get(layer.root)
163                    .map(|node| node.bounds)
164                    .unwrap_or(fallback_bounds);
165                Some(LayoutSidecarRootRecord {
166                    capture_index,
167                    kind: "layer",
168                    root: layer.root,
169                    root_bounds,
170                    blocks_underlay_input: layer.blocks_underlay_input,
171                    blocks_underlay_focus: layer.blocks_underlay_focus,
172                    hit_testable: layer.hit_testable,
173                })
174            })
175            .collect();
176
177        let viewport_bounds: std::collections::HashMap<NodeId, Rect> =
178            self.viewport_roots().iter().copied().collect();
179        for root in self.layout_engine.debug_independent_root_nodes() {
180            if !seen.insert(root) {
181                continue;
182            }
183            if !layer_roots.is_empty()
184                && !self.is_reachable_from_any_root_via_children(root, &layer_roots)
185            {
186                continue;
187            }
188
189            let root_bounds = viewport_bounds
190                .get(&root)
191                .copied()
192                .or_else(|| self.nodes.get(root).map(|node| node.bounds))
193                .unwrap_or(fallback_bounds);
194            let kind = if viewport_bounds.contains_key(&root) {
195                "viewport"
196            } else {
197                "independent"
198            };
199            roots.push(LayoutSidecarRootRecord {
200                capture_index: roots.len(),
201                kind,
202                root,
203                root_bounds,
204                blocks_underlay_input: false,
205                blocks_underlay_focus: false,
206                hit_testable: true,
207            });
208        }
209
210        if roots.is_empty() {
211            roots.push(LayoutSidecarRootRecord {
212                capture_index: 0,
213                kind: "fallback",
214                root: fallback_root,
215                root_bounds: fallback_bounds,
216                blocks_underlay_input: false,
217                blocks_underlay_focus: false,
218                hit_testable: true,
219            });
220        }
221
222        roots
223    }
224
225    pub(super) fn maybe_dump_taffy_subtree(
226        &self,
227        app: &mut H,
228        window: AppWindowId,
229        engine: &TaffyLayoutEngine,
230        root: NodeId,
231        root_bounds: Rect,
232        scale_factor: f32,
233    ) {
234        use std::sync::atomic::{AtomicU32, Ordering};
235
236        let config = crate::runtime_config::ui_runtime_config();
237        let Some(taffy_dump) = config.taffy_dump.as_ref() else {
238            return;
239        };
240
241        static DUMP_COUNT: AtomicU32 = AtomicU32::new(0);
242        let dump_max: Option<u32> = if config.taffy_dump_once {
243            Some(1)
244        } else {
245            taffy_dump.max
246        };
247        if let Some(max) = dump_max {
248            let prev = DUMP_COUNT.fetch_add(1, Ordering::SeqCst);
249            if prev >= max {
250                return;
251            }
252        }
253
254        if let Some(filter) = taffy_dump.root_filter.as_ref()
255            && !format!("{root:?}").contains(filter)
256        {
257            return;
258        }
259
260        // When debugging complex demos or golden-gated layouts, it is often easier to filter by a
261        // stable element label (e.g. a `SemanticsProps.label`) than by ephemeral `NodeId`s.
262        let dump_root = if let Some(filter) = taffy_dump.root_label_filter.as_ref() {
263            let root_label = layout_debug_search_label(app, window, root);
264            if root_label.contains(filter) {
265                root
266            } else {
267                let mut stack: Vec<NodeId> = vec![root];
268                let mut visited: std::collections::HashSet<NodeId> =
269                    std::collections::HashSet::new();
270                let mut found: Option<NodeId> = None;
271                while let Some(node) = stack.pop() {
272                    if !visited.insert(node) {
273                        continue;
274                    }
275
276                    let label = layout_debug_search_label(app, window, node);
277                    if label.contains(filter) {
278                        found = Some(node);
279                        break;
280                    }
281
282                    if let Some(node) = self.nodes.get(node) {
283                        stack.extend(node.children.iter().copied());
284                    }
285                }
286
287                let Some(found) = found else {
288                    return;
289                };
290
291                found
292            }
293        } else {
294            root
295        };
296
297        let out_dir = taffy_dump.out_dir.clone();
298
299        let frame = app.frame_id().0;
300        let root_slug: String = format!("{dump_root:?}")
301            .chars()
302            .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
303            .collect();
304        let filename = format!("taffy_{frame}_{root_slug}.json");
305
306        let dump = engine.debug_dump_subtree_json_with_info(dump_root, |node| {
307            layout_debug_node_info(app, window, node)
308        });
309
310        let wrapped = serde_json::json!({
311            "meta": {
312                "window": format!("{window:?}"),
313                "root_bounds": {
314                    "x": root_bounds.origin.x.0,
315                    "y": root_bounds.origin.y.0,
316                    "w": root_bounds.size.width.0,
317                    "h": root_bounds.size.height.0,
318                },
319                "scale_factor": scale_factor,
320            },
321            "taffy": dump,
322        });
323
324        let result = std::fs::create_dir_all(&out_dir)
325            .and_then(|_| {
326                serde_json::to_vec_pretty(&wrapped)
327                    .map_err(|e| std::io::Error::other(format!("serialize: {e}")))
328            })
329            .and_then(|bytes| {
330                std::fs::write(std::path::Path::new(&out_dir).join(&filename), bytes)
331            });
332
333        match result {
334            Ok(()) => tracing::info!(
335                out_dir = %out_dir,
336                filename = %filename,
337                "wrote taffy debug dump"
338            ),
339            Err(err) => tracing::warn!(
340                error = %err,
341                out_dir = %out_dir,
342                filename = %filename,
343                "failed to write taffy debug dump"
344            ),
345        }
346    }
347
348    /// Write a Taffy layout dump for a subtree rooted at `root`.
349    ///
350    /// The dump includes both local and absolute rects plus a debug label per node. When
351    /// `root_label_filter` is provided, the dump will search for the first node whose debug label
352    /// contains the filter string and use that node as the dump root (falling back to `root` when
353    /// the filter does not match anything).
354    ///
355    /// This is a debug-only escape hatch intended for diagnosing layout regressions and scroll /
356    /// clipping issues. The output is JSON and is written to `out_dir`.
357    #[cfg(not(target_arch = "wasm32"))]
358    #[allow(clippy::too_many_arguments)]
359    pub fn debug_write_taffy_subtree_json(
360        &self,
361        app: &mut H,
362        window: AppWindowId,
363        root: NodeId,
364        root_bounds: Rect,
365        scale_factor: f32,
366        root_label_filter: Option<&str>,
367        out_dir: impl AsRef<std::path::Path>,
368        filename_tag: &str,
369    ) -> std::io::Result<std::path::PathBuf> {
370        fn sanitize_for_filename(s: &str) -> String {
371            s.chars()
372                .map(|ch| match ch {
373                    'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => ch,
374                    _ => '_',
375                })
376                .collect()
377        }
378
379        let dump_root = if let Some(filter) = root_label_filter {
380            let root_label = layout_debug_search_label(app, window, root);
381            if root_label.contains(filter) {
382                root
383            } else {
384                let mut stack: Vec<NodeId> = vec![root];
385                let mut visited: std::collections::HashSet<NodeId> =
386                    std::collections::HashSet::new();
387                let mut found: Option<NodeId> = None;
388                while let Some(node) = stack.pop() {
389                    if !visited.insert(node) {
390                        continue;
391                    }
392
393                    let label = layout_debug_search_label(app, window, node);
394                    if label.contains(filter) {
395                        found = Some(node);
396                        break;
397                    }
398
399                    if let Some(node) = self.nodes.get(node) {
400                        stack.extend(node.children.iter().copied());
401                    }
402                }
403
404                found.unwrap_or(root)
405            }
406        } else {
407            root
408        };
409
410        let tag = sanitize_for_filename(filename_tag);
411        let frame = app.frame_id().0;
412        let root_slug = sanitize_for_filename(&format!("{dump_root:?}"));
413        let filename = if tag.is_empty() {
414            format!("taffy_{frame}_{root_slug}.json")
415        } else {
416            format!("taffy_{frame}_{tag}_{root_slug}.json")
417        };
418
419        let dump = self
420            .layout_engine
421            .debug_dump_subtree_json_with_info(dump_root, |node| {
422                layout_debug_node_info(app, window, node)
423            });
424
425        let wrapped = serde_json::json!({
426            "meta": {
427                "window": format!("{window:?}"),
428                "root_bounds": {
429                    "x": root_bounds.origin.x.0,
430                    "y": root_bounds.origin.y.0,
431                    "w": root_bounds.size.width.0,
432                    "h": root_bounds.size.height.0,
433                },
434                "scale_factor": scale_factor,
435            },
436            "taffy": dump,
437        });
438
439        let out_dir = out_dir.as_ref();
440        std::fs::create_dir_all(out_dir)?;
441        let path = out_dir.join(filename);
442        let bytes = serde_json::to_vec_pretty(&wrapped)
443            .map_err(|e| std::io::Error::other(format!("serialize: {e}")))?;
444        std::fs::write(&path, bytes)?;
445        Ok(path)
446    }
447
448    /// Write a bundle-scoped layout sidecar (Taffy dump) intended for scripted diagnostics runs.
449    ///
450    /// This is a diagnostics-only escape hatch and should remain best-effort. Tooling should treat
451    /// missing sidecars as warnings rather than failures.
452    ///
453    /// The file name is stable: `layout.taffy.v1.json`.
454    #[cfg(not(target_arch = "wasm32"))]
455    #[allow(clippy::too_many_arguments)]
456    pub fn debug_write_layout_sidecar_taffy_v1_json(
457        &self,
458        app: &mut H,
459        window: AppWindowId,
460        root: NodeId,
461        root_bounds: Rect,
462        scale_factor: f32,
463        root_label_filter: Option<&str>,
464        out_dir: impl AsRef<std::path::Path>,
465        captured_at_unix_ms: u64,
466    ) -> std::io::Result<std::path::PathBuf> {
467        let sidecar_roots = self.layout_sidecar_roots(root, root_bounds);
468        let dump_root = if let Some(filter) = root_label_filter {
469            sidecar_roots
470                .iter()
471                .rev()
472                .find_map(|root_record| {
473                    find_layout_debug_match_in_subtree(self, app, window, root_record.root, filter)
474                })
475                .unwrap_or(root)
476        } else {
477            root
478        };
479
480        let mut dump = self
481            .layout_engine
482            .debug_dump_subtree_json_with_info(dump_root, |node| {
483                layout_debug_node_info(app, window, node)
484            });
485        let root_dumps = sidecar_roots
486            .iter()
487            .map(|root_record| {
488                serde_json::json!({
489                    "capture_index": root_record.capture_index,
490                    "kind": root_record.kind,
491                    "root": format!("{:?}", root_record.root),
492                    "root_bounds": {
493                        "x": root_record.root_bounds.origin.x.0,
494                        "y": root_record.root_bounds.origin.y.0,
495                        "w": root_record.root_bounds.size.width.0,
496                        "h": root_record.root_bounds.size.height.0,
497                    },
498                    "blocks_underlay_input": root_record.blocks_underlay_input,
499                    "blocks_underlay_focus": root_record.blocks_underlay_focus,
500                    "hit_testable": root_record.hit_testable,
501                    "dump": self.layout_engine.debug_dump_subtree_json_with_info(
502                        root_record.root,
503                        |node| layout_debug_node_info(app, window, node),
504                    ),
505                })
506            })
507            .collect::<Vec<_>>();
508        if let Some(dump_obj) = dump.as_object_mut() {
509            dump_obj.insert("roots".to_string(), serde_json::Value::Array(root_dumps));
510        }
511
512        let wrapped = serde_json::json!({
513            "schema_version": "v1",
514            "engine": "taffy",
515            "captured_at_unix_ms": captured_at_unix_ms,
516            "clip": {
517                "max_nodes": 0u64,
518                "max_bytes": 0u64,
519                "clipped_nodes": 0u64,
520                "clipped_bytes": 0u64,
521            },
522            "meta": {
523                "window": format!("{window:?}"),
524                "root_bounds": {
525                    "x": root_bounds.origin.x.0,
526                    "y": root_bounds.origin.y.0,
527                    "w": root_bounds.size.width.0,
528                    "h": root_bounds.size.height.0,
529                },
530                "scale_factor": scale_factor,
531                "root_label_filter": root_label_filter,
532                "captured_root_count": sidecar_roots.len(),
533                "visible_layer_root_count": self.visible_layers_in_paint_order().count(),
534            },
535            "taffy": dump,
536        });
537
538        let out_dir = out_dir.as_ref();
539        std::fs::create_dir_all(out_dir)?;
540        let path = out_dir.join("layout.taffy.v1.json");
541        let bytes = serde_json::to_vec(&wrapped)
542            .map_err(|e| std::io::Error::other(format!("serialize: {e}")))?;
543        std::fs::write(&path, bytes)?;
544        Ok(path)
545    }
546}