ailloli_ui_devtools_core/
lib.rs

1//! Backend-agnostic DevTools snapshots, warnings, picking, and wire messages.
2//!
3//! Snapshot geometry uses logical pixels and stable numeric element IDs. The
4//! collector walks retained children in tree order; picking walks the flattened
5//! snapshot in reverse so later, deeper paint candidates win. Warning geometry
6//! comparisons tolerate half a logical pixel.
7
8use ailloli_ui_core::geometry::{ClipShape, Offset, Point, Rect};
9use ailloli_ui_core::ids::ElementId;
10use ailloli_ui_core::style::{AlignItems, FlexItemStyle, LayoutSizeHint, Length};
11use ailloli_ui_core::{Color, Constraints, Size};
12use ailloli_ui_runtime::element::{ElementKind, ElementTree, Key};
13#[cfg(feature = "terminal")]
14use ailloli_ui_terminal_core::{TerminalSnapshot, TerminalSnapshotConfig, TerminalState};
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17
18/// Logical-pixel tolerance used by containment and assigned-size diagnostics.
19const EPSILON: f32 = 0.5;
20
21/// Serializable logical-pixel rectangle.
22///
23/// Edges are inclusive for point picking. Width and height are stored verbatim;
24/// snapshot collection normally supplies non-negative layout values.
25///
26/// # Examples
27///
28/// ```
29/// use ailloli_ui_devtools_core::DebugRect;
30/// let rect = DebugRect { x: 10.0, y: 20.0, w: 30.0, h: 40.0 };
31/// assert_eq!((rect.right(), rect.bottom()), (40.0, 60.0));
32/// ```
33#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
34pub struct DebugRect {
35    /// Left edge in logical pixels.
36    pub x: f32,
37    /// Top edge in logical pixels.
38    pub y: f32,
39    /// Width in logical pixels.
40    pub w: f32,
41    /// Height in logical pixels.
42    pub h: f32,
43}
44
45/// Edge and containment helpers for debug rectangles.
46impl DebugRect {
47    /// Returns `x + w` without clamping or overflow handling.
48    ///
49    /// # Examples
50    ///
51    /// ```
52    /// use ailloli_ui_devtools_core::DebugRect;
53    /// assert_eq!(DebugRect { x: 2.0, y: 0.0, w: 3.0, h: 1.0 }.right(), 5.0);
54    /// ```
55    pub fn right(self) -> f32 {
56        self.x + self.w
57    }
58
59    /// Returns `y + h` without clamping or overflow handling.
60    ///
61    /// # Examples
62    ///
63    /// ```
64    /// use ailloli_ui_devtools_core::DebugRect;
65    /// assert_eq!(DebugRect { x: 0.0, y: 4.0, w: 1.0, h: 6.0 }.bottom(), 10.0);
66    /// ```
67    pub fn bottom(self) -> f32 {
68        self.y + self.h
69    }
70
71    /// Returns whether `point` lies on or within all four edges.
72    ///
73    /// # Examples
74    ///
75    /// ```
76    /// use ailloli_ui_devtools_core::{DebugPoint, DebugRect};
77    /// let rect = DebugRect { x: 0.0, y: 0.0, w: 10.0, h: 5.0 };
78    /// assert!(rect.contains(DebugPoint { x: 10.0, y: 5.0 }));
79    /// assert!(!rect.contains(DebugPoint { x: 10.1, y: 5.0 }));
80    /// ```
81    pub fn contains(self, point: DebugPoint) -> bool {
82        point.x >= self.x
83            && point.y >= self.y
84            && point.x <= self.right()
85            && point.y <= self.bottom()
86    }
87
88    /// Returns whether `other` stays within each edge with [`EPSILON`] tolerance.
89    fn contains_rect(self, other: DebugRect) -> bool {
90        other.x + EPSILON >= self.x
91            && other.y + EPSILON >= self.y
92            && other.right() <= self.right() + EPSILON
93            && other.bottom() <= self.bottom() + EPSILON
94    }
95}
96
97/// Converts core logical geometry without normalization.
98impl From<Rect> for DebugRect {
99    fn from(value: Rect) -> Self {
100        Self {
101            x: value.x,
102            y: value.y,
103            w: value.w,
104            h: value.h,
105        }
106    }
107}
108
109/// Converts back to core logical geometry without normalization.
110impl From<DebugRect> for Rect {
111    fn from(value: DebugRect) -> Self {
112        Rect::new(value.x, value.y, value.w, value.h)
113    }
114}
115
116/// Serializable logical width and height.
117///
118/// # Examples
119///
120/// ```
121/// use ailloli_ui_devtools_core::DebugSize;
122/// let size = DebugSize { w: 640.0, h: 480.0 };
123/// assert_eq!(size.h, 480.0);
124/// ```
125#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
126pub struct DebugSize {
127    /// Width in logical pixels.
128    pub w: f32,
129    /// Height in logical pixels.
130    pub h: f32,
131}
132
133/// Copies core size components verbatim.
134impl From<Size> for DebugSize {
135    fn from(value: Size) -> Self {
136        Self {
137            w: value.w,
138            h: value.h,
139        }
140    }
141}
142
143/// Serializable logical translation.
144///
145/// # Examples
146///
147/// ```
148/// use ailloli_ui_devtools_core::DebugOffset;
149/// let offset = DebugOffset { x: -4.0, y: 8.0 };
150/// assert_eq!((offset.x, offset.y), (-4.0, 8.0));
151/// ```
152#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
153pub struct DebugOffset {
154    /// Horizontal delta in logical pixels.
155    pub x: f32,
156    /// Vertical delta in logical pixels.
157    pub y: f32,
158}
159
160/// Copies core offset components verbatim.
161impl From<Offset> for DebugOffset {
162    fn from(value: Offset) -> Self {
163        Self {
164            x: value.x,
165            y: value.y,
166        }
167    }
168}
169
170/// Serializable point in logical viewport coordinates.
171///
172/// # Examples
173///
174/// ```
175/// use ailloli_ui_devtools_core::DebugPoint;
176/// let point = DebugPoint { x: 12.0, y: 24.0 };
177/// assert_eq!(point.x, 12.0);
178/// ```
179#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
180pub struct DebugPoint {
181    /// Horizontal coordinate in logical pixels.
182    pub x: f32,
183    /// Vertical coordinate in logical pixels.
184    pub y: f32,
185}
186
187/// Copies core point components verbatim.
188impl From<Point> for DebugPoint {
189    fn from(value: Point) -> Self {
190        Self {
191            x: value.x,
192            y: value.y,
193        }
194    }
195}
196
197/// Serializable floating-point RGBA color.
198///
199/// Channels are copied verbatim from [`Color`], normally in `[0, 1]`, and are
200/// not clamped by this diagnostic representation.
201///
202/// # Examples
203///
204/// ```
205/// use ailloli_ui_devtools_core::DebugColor;
206/// let color = DebugColor { r: 1.0, g: 0.5, b: 0.0, a: 1.0 };
207/// assert_eq!(color.a, 1.0);
208/// ```
209#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
210pub struct DebugColor {
211    /// Red channel.
212    pub r: f32,
213    /// Green channel.
214    pub g: f32,
215    /// Blue channel.
216    pub b: f32,
217    /// Alpha channel where zero is transparent and one is opaque.
218    pub a: f32,
219}
220
221/// Copies core color channels verbatim.
222impl From<Color> for DebugColor {
223    fn from(value: Color) -> Self {
224        Self {
225            r: value.r,
226            g: value.g,
227            b: value.b,
228            a: value.a,
229        }
230    }
231}
232
233/// Serializable min/max layout constraints in logical pixels.
234///
235/// Maximum values may be positive infinity to mean unbounded; no normalization
236/// is performed during conversion.
237///
238/// # Examples
239///
240/// ```
241/// use ailloli_ui_devtools_core::DebugConstraints;
242/// let constraints = DebugConstraints { min_w: 0.0, max_w: 800.0, min_h: 20.0, max_h: 600.0 };
243/// assert!(constraints.min_w <= constraints.max_w);
244/// ```
245#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
246pub struct DebugConstraints {
247    /// Minimum width in logical pixels.
248    pub min_w: f32,
249    /// Maximum width in logical pixels.
250    pub max_w: f32,
251    /// Minimum height in logical pixels.
252    pub min_h: f32,
253    /// Maximum height in logical pixels.
254    pub max_h: f32,
255}
256
257/// Copies core constraint components verbatim.
258impl From<Constraints> for DebugConstraints {
259    fn from(value: Constraints) -> Self {
260        Self {
261            min_w: value.min_w,
262            max_w: value.max_w,
263            min_h: value.min_h,
264            max_h: value.max_h,
265        }
266    }
267}
268
269/// Serializable declarative length used by size hints and flex basis.
270///
271/// # Examples
272///
273/// ```
274/// use ailloli_ui_devtools_core::DebugLength;
275/// let length = DebugLength::Percent(0.5);
276/// assert_eq!(serde_json::to_string(&length).unwrap(), r#"{"kind":"Percent","value":0.5}"#);
277/// ```
278#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
279#[serde(tag = "kind", content = "value")]
280pub enum DebugLength {
281    /// Intrinsic sizing.
282    Auto,
283    /// Fixed logical pixels, stored verbatim.
284    Px(f32),
285    /// Fill/remaining-space sizing.
286    Fill,
287    /// Fraction of parent available space, where `0.5` means 50%.
288    Percent(f32),
289}
290
291/// Preserves the exact core length variant and payload.
292impl From<Length> for DebugLength {
293    fn from(value: Length) -> Self {
294        match value {
295            Length::Auto => Self::Auto,
296            Length::Px(v) => Self::Px(v),
297            Length::Fill => Self::Fill,
298            Length::Percent(v) => Self::Percent(v),
299        }
300    }
301}
302
303/// Serializable width/height hints exposed by a retained element.
304///
305/// # Examples
306///
307/// ```
308/// use ailloli_ui_devtools_core::{DebugLayoutSizeHint, DebugLength};
309/// let hint = DebugLayoutSizeHint { width: DebugLength::Fill, height: DebugLength::Px(24.0) };
310/// assert_eq!(hint.height, DebugLength::Px(24.0));
311/// ```
312#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
313pub struct DebugLayoutSizeHint {
314    /// Declarative horizontal length.
315    pub width: DebugLength,
316    /// Declarative vertical length.
317    pub height: DebugLength,
318}
319
320/// Converts both core size-hint axes.
321impl From<LayoutSizeHint> for DebugLayoutSizeHint {
322    fn from(value: LayoutSizeHint) -> Self {
323        Self {
324            width: value.width.into(),
325            height: value.height.into(),
326        }
327    }
328}
329
330/// Serializable flex-item inputs attached to a retained child.
331///
332/// # Examples
333///
334/// ```
335/// use ailloli_ui_devtools_core::{DebugFlexItem, DebugLength};
336/// let item = DebugFlexItem { flex_grow: 1.0, flex_shrink: 0.0,
337///     flex_basis: DebugLength::Auto, align_self: None };
338/// assert_eq!(item.align_self, None);
339/// ```
340#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
341pub struct DebugFlexItem {
342    /// Non-negative grow factor copied from computed style.
343    pub flex_grow: f32,
344    /// Non-negative shrink factor copied from computed style.
345    pub flex_shrink: f32,
346    /// Preferred main-axis basis.
347    pub flex_basis: DebugLength,
348    /// Lowercase alignment name, or `None` to inherit the container.
349    pub align_self: Option<String>,
350}
351
352/// Converts numeric flex values, basis, and optional alignment name.
353impl From<FlexItemStyle> for DebugFlexItem {
354    fn from(value: FlexItemStyle) -> Self {
355        Self {
356            flex_grow: value.flex_grow,
357            flex_shrink: value.flex_shrink,
358            flex_basis: value.flex_basis.into(),
359            align_self: value.align_self.map(align_items_name).map(str::to_string),
360        }
361    }
362}
363
364/// Optional layout-pass inputs and measured output for one element.
365///
366/// # Examples
367///
368/// ```
369/// use ailloli_ui_devtools_core::{DebugLayoutInfo, DebugSize};
370/// let info = DebugLayoutInfo { constraints_in: None, constraints_final: None,
371///     layout_size: DebugSize { w: 10.0, h: 20.0 } };
372/// assert_eq!(info.layout_size.w, 10.0);
373/// ```
374#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
375pub struct DebugLayoutInfo {
376    /// Constraints entering the layout pass, when runtime diagnostics retained them.
377    pub constraints_in: Option<DebugConstraints>,
378    /// Constraints after widget adjustment, when recorded.
379    pub constraints_final: Option<DebugConstraints>,
380    /// Size returned by the recorded layout pass, in logical pixels.
381    pub layout_size: DebugSize,
382}
383
384/// Parent-assigned child slot in local and absolute logical coordinates.
385///
386/// # Examples
387///
388/// ```
389/// use ailloli_ui_devtools_core::{DebugOffset, DebugRect, DebugSize, DebugSlot};
390/// let slot = DebugSlot { child: 7, offset: DebugOffset { x: 2.0, y: 3.0 },
391///     size: DebugSize { w: 10.0, h: 20.0 },
392///     absolute: DebugRect { x: 12.0, y: 13.0, w: 10.0, h: 20.0 } };
393/// assert_eq!(slot.child, 7);
394/// ```
395#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
396pub struct DebugSlot {
397    /// Numeric child element ID.
398    pub child: u64,
399    /// Offset from the parent's absolute origin, in logical pixels.
400    pub offset: DebugOffset,
401    /// Assigned width and height in logical pixels.
402    pub size: DebugSize,
403    /// Slot translated into viewport coordinates.
404    pub absolute: DebugRect,
405}
406
407/// Flattened retained-tree node with layout, clip, style, and warning diagnostics.
408///
409/// Nodes are emitted in pre-order. Child IDs preserve retained child order, and
410/// `depth` is zero for the requested root. Bounds are logical viewport pixels.
411///
412/// # Examples
413///
414/// ```no_run
415/// use ailloli_ui_devtools_core::DebugNode;
416/// fn describe(node: &DebugNode) -> (&str, usize, bool) {
417///     (&node.widget_name, node.depth, node.has_layout)
418/// }
419/// ```
420#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
421pub struct DebugNode {
422    /// Numeric [`ElementId`] value.
423    pub id: u64,
424    /// Parent ID, or `None` for the collected root.
425    pub parent: Option<u64>,
426    /// Edge distance from the collected root.
427    pub depth: usize,
428    /// Child IDs in retained order, including children without layout.
429    pub children: Vec<u64>,
430    /// Widget debug name, or `Empty`/`Component` for structural nodes.
431    pub widget_name: String,
432    /// String representation of the retained key, if any.
433    pub key: Option<String>,
434    /// Layout result size in logical pixels, or zero when layout is absent.
435    pub layout_size: DebugSize,
436    /// Parent-assigned absolute slot; `None` for roots or unlaid-out parents.
437    pub assigned_slot: Option<DebugRect>,
438    /// Best available absolute bounds, falling back through layout and ancestor bounds.
439    pub absolute_bounds: DebugRect,
440    /// Paint bounds translated by the node origin, when layout exists.
441    pub paint_bounds: Option<DebugRect>,
442    /// Bounding rectangle of the translated clip shape, when present.
443    pub clip_bounds: Option<DebugRect>,
444    /// Declarative width and height hints.
445    pub size_hint: DebugLayoutSizeHint,
446    /// Flex-item inputs attached by the parent-facing element.
447    pub flex_item: DebugFlexItem,
448    /// Recorded layout-pass diagnostics, when runtime instrumentation retained them.
449    pub layout_debug: Option<DebugLayoutInfo>,
450    /// Parent-local and absolute slots paired with existing child IDs.
451    pub children_slots: Vec<DebugSlot>,
452    /// Warnings attributed to this node, refreshed by [`compute_warnings`].
453    pub warnings: Vec<DebugWarning>,
454    /// Whether this retained node currently has a layout result.
455    pub has_layout: bool,
456}
457
458/// Stable warning categories emitted by [`compute_warnings`].
459///
460/// # Examples
461///
462/// ```
463/// use ailloli_ui_devtools_core::DebugWarningKind;
464/// assert_eq!(serde_json::to_string(&DebugWarningKind::DuplicateKey).unwrap(), "\"duplicate_key\"");
465/// ```
466#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
467#[serde(rename_all = "snake_case")]
468pub enum DebugWarningKind {
469    /// Node bounds exceed the snapshot viewport by more than 0.5 logical pixels.
470    OutsideViewport,
471    /// Parent-assigned slot exceeds parent bounds by more than the tolerance.
472    SlotOutsideParent,
473    /// Measured size differs from assigned slot by more than the tolerance.
474    LayoutExceedsAssignedSlot,
475    /// Child slot exceeds its parent's clip bounding rectangle.
476    ChildOutsideParentClip,
477    /// A fill/growing item measured larger than its final slot.
478    FlexMeasuredLargerThanSlot,
479    /// A nonempty retained key occurs more than once in the snapshot.
480    DuplicateKey,
481    /// Retained node has no current layout result.
482    MissingLayout,
483}
484
485/// Human-readable diagnostic optionally attributed to one node.
486///
487/// # Examples
488///
489/// ```
490/// use ailloli_ui_devtools_core::{DebugWarning, DebugWarningKind};
491/// let warning = DebugWarning { node: Some(4), kind: DebugWarningKind::MissingLayout,
492///     message: "node is in the tree but has no layout".into() };
493/// assert_eq!(warning.node, Some(4));
494/// ```
495#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
496pub struct DebugWarning {
497    /// Affected element ID, or `None` for future snapshot-wide warnings.
498    pub node: Option<u64>,
499    /// Stable machine-readable category.
500    pub kind: DebugWarningKind,
501    /// Human-readable English explanation; not a stable protocol key.
502    pub message: String,
503}
504
505/// Requested DevTools presentation mode.
506///
507/// `Overlay` is the default. This core model does not implement docking; hosts
508/// interpret the serialized selection.
509///
510/// # Examples
511///
512/// ```
513/// use ailloli_ui_devtools_core::DevToolsMode;
514/// assert_eq!(DevToolsMode::default(), DevToolsMode::Overlay);
515/// assert_eq!(serde_json::to_string(&DevToolsMode::DockRight).unwrap(), "\"dock_right\"");
516/// ```
517#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
518#[serde(rename_all = "snake_case")]
519pub enum DevToolsMode {
520    /// Do not render DevTools UI.
521    Hidden,
522    /// Draw on top of application content.
523    #[default]
524    Overlay,
525    /// Reserve a dock on the logical right side.
526    DockRight,
527    /// Reserve a dock on the logical bottom side.
528    DockBottom,
529}
530
531/// Serializable snapshot of one retained subtree and its derived diagnostics.
532///
533/// # Examples
534///
535/// ```no_run
536/// use ailloli_ui_devtools_core::DebugSnapshot;
537/// fn selected(snapshot: &DebugSnapshot) -> Option<u64> { snapshot.selected }
538/// ```
539#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
540pub struct DebugSnapshot {
541    /// Requested root element ID, even if the ID was absent from the tree.
542    pub root: u64,
543    /// Logical viewport used by outside-viewport warnings.
544    pub viewport: DebugRect,
545    /// Pre-order flattened nodes reachable from `root`.
546    pub nodes: Vec<DebugNode>,
547    /// All derived warnings in node traversal/category order.
548    pub warnings: Vec<DebugWarning>,
549    #[cfg(feature = "terminal")]
550    #[serde(default, skip_serializing_if = "Vec::is_empty")]
551    /// Optional terminal inspections; omitted from JSON when the feature is enabled but empty.
552    pub terminal_inspections: Vec<TerminalDebugSnapshot>,
553    /// Selected element ID copied from caller state, whether or not it is present.
554    pub selected: Option<u64>,
555    /// Hovered element ID copied from caller state, whether or not it is present.
556    pub hovered: Option<u64>,
557    /// Opaque monotonically increasing frame identity supplied by the host.
558    pub frame_index: u64,
559}
560
561/// Linear ID lookup helpers over snapshot nodes.
562impl DebugSnapshot {
563    /// Returns the first node with `id`, or `None` when absent.
564    ///
565    /// Complexity is O(number of nodes).
566    ///
567    /// # Examples
568    ///
569    /// ```no_run
570    /// use ailloli_ui_devtools_core::DebugSnapshot;
571    /// fn root_name(snapshot: &DebugSnapshot) -> Option<&str> {
572    ///     snapshot.node(snapshot.root).map(|node| node.widget_name.as_str())
573    /// }
574    /// ```
575    pub fn node(&self, id: u64) -> Option<&DebugNode> {
576        self.nodes.iter().find(|n| n.id == id)
577    }
578
579    /// Returns the first mutable node with `id`, or `None` when absent.
580    ///
581    /// Complexity is O(number of nodes). Mutating geometry does not recompute
582    /// warnings automatically; call [`compute_warnings`] afterward when needed.
583    ///
584    /// # Examples
585    ///
586    /// ```no_run
587    /// use ailloli_ui_devtools_core::DebugSnapshot;
588    /// fn clear_node_warnings(snapshot: &mut DebugSnapshot, id: u64) {
589    ///     if let Some(node) = snapshot.node_mut(id) { node.warnings.clear(); }
590    /// }
591    /// ```
592    pub fn node_mut(&mut self, id: u64) -> Option<&mut DebugNode> {
593        self.nodes.iter_mut().find(|n| n.id == id)
594    }
595}
596
597#[cfg(feature = "terminal")]
598/// Terminal snapshot plus deterministic cell identities for DevTools inspection.
599///
600/// # Examples
601///
602/// ```no_run
603/// use ailloli_ui_devtools_core::TerminalDebugSnapshot;
604/// fn dimensions(terminal: &TerminalDebugSnapshot) -> (usize, usize) {
605///     (terminal.snapshot.lines.len(), terminal.stable_ids.len())
606/// }
607/// ```
608#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
609pub struct TerminalDebugSnapshot {
610    /// Host-supplied terminal identity embedded in every stable cell ID.
611    pub id: String,
612    /// Human-readable terminal title.
613    pub title: String,
614    /// Bounded terminal viewport/history snapshot.
615    pub snapshot: TerminalSnapshot,
616    /// Cell IDs in line order, then column order, parallel to all snapshot cells.
617    pub stable_ids: Vec<String>,
618}
619
620#[cfg(feature = "terminal")]
621/// Construction from an already captured terminal snapshot.
622impl TerminalDebugSnapshot {
623    /// Builds stable IDs as `terminal:ID:line:LINE:cell:COLUMN`.
624    ///
625    /// Logical history lines use their global index; visual-only lines use the
626    /// `visual-N` sentinel. No terminal text is embedded in the IDs.
627    ///
628    /// # Examples
629    ///
630    /// ```no_run
631    /// use ailloli_ui_devtools_core::TerminalDebugSnapshot;
632    /// use ailloli_ui_terminal_core::TerminalSnapshot;
633    /// fn wrap(snapshot: TerminalSnapshot) -> TerminalDebugSnapshot {
634    ///     TerminalDebugSnapshot::from_terminal_snapshot("shell-1", "Shell", snapshot)
635    /// }
636    /// ```
637    pub fn from_terminal_snapshot(
638        id: impl Into<String>,
639        title: impl Into<String>,
640        snapshot: TerminalSnapshot,
641    ) -> Self {
642        let id = id.into();
643        let stable_ids = snapshot
644            .lines
645            .iter()
646            .flat_map(|line| {
647                let line_id = line
648                    .global_index
649                    .map(|idx| idx.to_string())
650                    .unwrap_or_else(|| format!("visual-{}", line.visual_index));
651                let terminal_id = id.clone();
652                line.cells.iter().map(move |cell| {
653                    format!("terminal:{terminal_id}:line:{line_id}:cell:{}", cell.col)
654                })
655            })
656            .collect();
657        Self {
658            id,
659            title: title.into(),
660            snapshot,
661            stable_ids,
662        }
663    }
664}
665
666#[cfg(feature = "terminal")]
667/// Captures terminal state with `config` and adds deterministic cell identities.
668///
669/// # Examples
670///
671/// ```no_run
672/// use ailloli_ui_devtools_core::terminal_debug_snapshot;
673/// use ailloli_ui_terminal_core::{TerminalSnapshotConfig, TerminalState};
674/// fn capture(state: &TerminalState, config: TerminalSnapshotConfig) {
675///     let snapshot = terminal_debug_snapshot("shell-1", "Shell", state, config);
676///     assert_eq!(snapshot.id, "shell-1");
677/// }
678/// ```
679pub fn terminal_debug_snapshot(
680    id: impl Into<String>,
681    title: impl Into<String>,
682    state: &TerminalState,
683    config: TerminalSnapshotConfig,
684) -> TerminalDebugSnapshot {
685    TerminalDebugSnapshot::from_terminal_snapshot(
686        id,
687        title,
688        TerminalSnapshot::from_state(state, config),
689    )
690}
691
692/// Backend-neutral overlay primitive derived from hover, selection, or warnings.
693///
694/// All positions and thicknesses are logical pixels.
695///
696/// # Examples
697///
698/// ```
699/// use ailloli_ui_devtools_core::{DebugColor, DebugDrawCmd, DebugRect};
700/// let cmd = DebugDrawCmd::RectOutline { rect: DebugRect { x: 0.0, y: 0.0, w: 10.0, h: 10.0 },
701///     color: DebugColor { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }, thickness: 2.0 };
702/// assert!(matches!(cmd, DebugDrawCmd::RectOutline { thickness: 2.0, .. }));
703/// ```
704#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
705#[serde(tag = "kind", rename_all = "snake_case")]
706pub enum DebugDrawCmd {
707    /// Stroked rectangle overlay.
708    RectOutline {
709        /// Bounds in logical viewport coordinates.
710        rect: DebugRect,
711        /// Stroke color.
712        color: DebugColor,
713        /// Stroke thickness in logical pixels.
714        thickness: f32,
715    },
716    /// Filled rectangle overlay.
717    RectFill {
718        /// Bounds in logical viewport coordinates.
719        rect: DebugRect,
720        /// Fill color.
721        color: DebugColor,
722    },
723    /// Text label anchored at a logical viewport point.
724    TextLabel {
725        /// Label origin in logical pixels.
726        pos: DebugPoint,
727        /// UTF-8 label contents.
728        text: String,
729        /// Text color.
730        color: DebugColor,
731    },
732}
733
734/// Tagged client-to-host DevTools protocol message.
735///
736/// Unknown `type` values are rejected by Serde. `Select`/`Hover` with `None`
737/// clear the corresponding host state.
738///
739/// # Examples
740///
741/// ```
742/// use ailloli_ui_devtools_core::DevToolsClientMessage;
743/// let message: DevToolsClientMessage = serde_json::from_str(r#"{"type":"select","id":42}"#).unwrap();
744/// assert_eq!(message, DevToolsClientMessage::Select { id: Some(42) });
745/// ```
746#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
747#[serde(tag = "type", rename_all = "snake_case")]
748pub enum DevToolsClientMessage {
749    /// Selects or clears an element.
750    Select {
751        /// Numeric element ID, or `None` to clear selection.
752        id: Option<u64>,
753    },
754    /// Sets or clears the hovered element.
755    Hover {
756        /// Numeric element ID, or `None` to clear hover.
757        id: Option<u64>,
758    },
759    /// Requests a host presentation mode.
760    SetMode {
761        /// Requested mode.
762        mode: DevToolsMode,
763    },
764    /// Liveness request answered by [`DevToolsServerMessage::Pong`].
765    Ping,
766}
767
768/// Tagged host-to-client DevTools protocol message.
769///
770/// # Examples
771///
772/// ```
773/// use ailloli_ui_devtools_core::DevToolsServerMessage;
774/// let hello = DevToolsServerMessage::Hello { protocol: 1 };
775/// assert_eq!(serde_json::to_string(&hello).unwrap(), r#"{"type":"hello","protocol":1}"#);
776/// ```
777#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
778#[serde(tag = "type", rename_all = "snake_case")]
779pub enum DevToolsServerMessage {
780    /// Announces the host-selected protocol version.
781    Hello {
782        /// Protocol version interpreted by the host/client pair.
783        protocol: u32,
784    },
785    /// Delivers a complete replacement snapshot.
786    Snapshot {
787        /// Current retained-tree diagnostic state.
788        snapshot: DebugSnapshot,
789    },
790    /// Liveness response with no payload.
791    Pong,
792    /// Human-readable protocol or host failure.
793    Error {
794        /// Error text; not a stable machine-readable code.
795        message: String,
796    },
797}
798
799/// Collects a subtree with no selected/hovered state at frame index zero.
800///
801/// If `root` is absent, the returned snapshot has no nodes but still preserves
802/// the requested root ID and viewport.
803///
804/// # Examples
805///
806/// ```
807/// use ailloli_ui_core::Rect;
808/// use ailloli_ui_devtools_core::collect_debug_snapshot;
809/// use ailloli_ui_runtime::element::{ElementKind, ElementTree};
810/// let mut tree = ElementTree::<()>::new();
811/// let root = tree.create_element(ElementKind::Empty, None, None);
812/// let snapshot = collect_debug_snapshot(&tree, root, Rect::new(0.0, 0.0, 100.0, 50.0));
813/// assert_eq!(snapshot.root, root.0);
814/// ```
815pub fn collect_debug_snapshot<A: 'static>(
816    tree: &ElementTree<A>,
817    root: ElementId,
818    viewport: Rect,
819) -> DebugSnapshot {
820    collect_debug_snapshot_with_state(tree, root, viewport, None, None, 0)
821}
822
823/// Collects a pre-order subtree and derives warnings with explicit host state.
824///
825/// `selected` and `hovered` are copied even when they are outside the collected
826/// subtree. `frame_index` is opaque and is not incremented internally. Traversal
827/// is O(nodes + child slots); warning computation is expected O(nodes).
828///
829/// # Examples
830///
831/// ```no_run
832/// use ailloli_ui_core::{ids::ElementId, Rect};
833/// use ailloli_ui_devtools_core::collect_debug_snapshot_with_state;
834/// use ailloli_ui_runtime::element::ElementTree;
835/// fn capture(tree: &ElementTree<()>, root: ElementId) {
836///     let snapshot = collect_debug_snapshot_with_state(tree, root,
837///         Rect::new(0.0, 0.0, 800.0, 600.0), Some(root), None, 42);
838///     assert_eq!(snapshot.frame_index, 42);
839/// }
840/// ```
841pub fn collect_debug_snapshot_with_state<A: 'static>(
842    tree: &ElementTree<A>,
843    root: ElementId,
844    viewport: Rect,
845    selected: Option<ElementId>,
846    hovered: Option<ElementId>,
847    frame_index: u64,
848) -> DebugSnapshot {
849    let mut snapshot = DebugSnapshot {
850        root: root.0,
851        viewport: viewport.into(),
852        nodes: Vec::new(),
853        warnings: Vec::new(),
854        #[cfg(feature = "terminal")]
855        terminal_inspections: Vec::new(),
856        selected: selected.map(|id| id.0),
857        hovered: hovered.map(|id| id.0),
858        frame_index,
859    };
860    collect_node(
861        tree,
862        root,
863        None,
864        0,
865        None,
866        DebugRect::from(viewport),
867        &mut snapshot,
868    );
869    compute_warnings(&mut snapshot);
870    snapshot
871}
872
873/// Replaces snapshot-wide and per-node derived warning lists.
874///
875/// Bounds/size comparisons tolerate 0.5 logical pixels. Missing-layout nodes
876/// receive only `MissingLayout` because the remaining geometric checks require
877/// layout. Duplicate keys are counted globally by their exact string. Repeated
878/// calls are idempotent for unchanged snapshot inputs.
879///
880/// # Examples
881///
882/// ```no_run
883/// use ailloli_ui_devtools_core::{compute_warnings, DebugSnapshot};
884/// fn refresh(snapshot: &mut DebugSnapshot) {
885///     compute_warnings(snapshot);
886///     assert_eq!(snapshot.warnings.iter().filter(|w| w.node.is_none()).count(), 0);
887/// }
888/// ```
889pub fn compute_warnings(snapshot: &mut DebugSnapshot) {
890    let mut warnings = Vec::new();
891    let by_id = snapshot
892        .nodes
893        .iter()
894        .enumerate()
895        .map(|(idx, node)| (node.id, idx))
896        .collect::<HashMap<_, _>>();
897
898    let mut key_counts = HashMap::<String, usize>::new();
899    for node in &snapshot.nodes {
900        if let Some(key) = &node.key {
901            *key_counts.entry(key.clone()).or_default() += 1;
902        }
903    }
904
905    let mut per_node = HashMap::<u64, Vec<DebugWarning>>::new();
906    for node in &snapshot.nodes {
907        if !node.has_layout {
908            push_warning(
909                &mut warnings,
910                &mut per_node,
911                node.id,
912                DebugWarningKind::MissingLayout,
913                "node is in the tree but has no layout",
914            );
915            continue;
916        }
917
918        if !snapshot.viewport.contains_rect(node.absolute_bounds) {
919            push_warning(
920                &mut warnings,
921                &mut per_node,
922                node.id,
923                DebugWarningKind::OutsideViewport,
924                "node bounds exceed the viewport",
925            );
926        }
927
928        if let Some(parent_id) = node.parent {
929            if let Some(parent_idx) = by_id.get(&parent_id) {
930                let parent = &snapshot.nodes[*parent_idx];
931                if let Some(slot) = node.assigned_slot {
932                    if !parent.absolute_bounds.contains_rect(slot) {
933                        push_warning(
934                            &mut warnings,
935                            &mut per_node,
936                            node.id,
937                            DebugWarningKind::SlotOutsideParent,
938                            "assigned slot exceeds parent bounds",
939                        );
940                    }
941                    if let Some(clip) = parent.clip_bounds {
942                        if !clip.contains_rect(slot) {
943                            push_warning(
944                                &mut warnings,
945                                &mut per_node,
946                                node.id,
947                                DebugWarningKind::ChildOutsideParentClip,
948                                "assigned slot exceeds parent clip",
949                            );
950                        }
951                    }
952                    if size_exceeds_slot(node.layout_size, slot) {
953                        push_warning(
954                            &mut warnings,
955                            &mut per_node,
956                            node.id,
957                            DebugWarningKind::LayoutExceedsAssignedSlot,
958                            "layout size differs from assigned slot size",
959                        );
960                        if node.flex_item.flex_grow > 0.0
961                            || matches!(node.size_hint.width, DebugLength::Fill)
962                            || matches!(node.size_hint.height, DebugLength::Fill)
963                        {
964                            push_warning(
965                                &mut warnings,
966                                &mut per_node,
967                                node.id,
968                                DebugWarningKind::FlexMeasuredLargerThanSlot,
969                                "flex/fill item layout is larger than its final slot",
970                            );
971                        }
972                    }
973                }
974            }
975        }
976
977        if let Some(key) = &node.key {
978            if key_counts.get(key).copied().unwrap_or_default() > 1 {
979                push_warning(
980                    &mut warnings,
981                    &mut per_node,
982                    node.id,
983                    DebugWarningKind::DuplicateKey,
984                    "duplicate view key in this tree",
985                );
986            }
987        }
988    }
989
990    for node in &mut snapshot.nodes {
991        node.warnings = per_node.remove(&node.id).unwrap_or_default();
992    }
993    snapshot.warnings = warnings;
994}
995
996/// Returns the topmost laid-out node containing a logical viewport point.
997///
998/// Nodes are inspected in reverse snapshot order, which prefers later siblings
999/// and descendants from the pre-order collection. A candidate must pass its own
1000/// bounds/clip and every available ancestor clip. Missing ancestor records stop
1001/// clip traversal permissively. Complexity is O(nodes + ancestor depth).
1002///
1003/// # Examples
1004///
1005/// ```no_run
1006/// use ailloli_ui_devtools_core::{pick_element_at, DebugPoint, DebugSnapshot};
1007/// fn pick(snapshot: &DebugSnapshot) -> Option<u64> {
1008///     pick_element_at(snapshot, DebugPoint { x: 40.0, y: 20.0 })
1009/// }
1010/// ```
1011pub fn pick_element_at(snapshot: &DebugSnapshot, point: impl Into<DebugPoint>) -> Option<u64> {
1012    let point = point.into();
1013    let by_id = snapshot
1014        .nodes
1015        .iter()
1016        .map(|node| (node.id, node))
1017        .collect::<HashMap<_, _>>();
1018    for node in snapshot.nodes.iter().rev() {
1019        if !node.has_layout || !node.absolute_bounds.contains(point) {
1020            continue;
1021        }
1022        if let Some(clip) = node.clip_bounds {
1023            if !clip.contains(point) {
1024                continue;
1025            }
1026        }
1027        if !ancestor_clips_contain(&by_id, node, point) {
1028            continue;
1029        }
1030        return Some(node.id);
1031    }
1032    None
1033}
1034
1035/// Builds hover, selection, then warning outlines in deterministic order.
1036///
1037/// Hover is blue at 2 logical pixels, selection red at 2, and each node warning
1038/// yellow at 1. Unknown IDs and snapshot-wide warnings are skipped. Duplicate
1039/// warnings for one node intentionally produce duplicate outlines.
1040///
1041/// # Examples
1042///
1043/// ```no_run
1044/// use ailloli_ui_devtools_core::{debug_draw_cmds, DebugDrawCmd, DebugSnapshot};
1045/// fn overlays(snapshot: &DebugSnapshot) -> Vec<DebugDrawCmd> {
1046///     debug_draw_cmds(snapshot)
1047/// }
1048/// ```
1049pub fn debug_draw_cmds(snapshot: &DebugSnapshot) -> Vec<DebugDrawCmd> {
1050    let mut out = Vec::new();
1051    if let Some(id) = snapshot.hovered {
1052        if let Some(node) = snapshot.node(id) {
1053            out.push(DebugDrawCmd::RectOutline {
1054                rect: node.absolute_bounds,
1055                color: Color::rgba(70, 150, 255, 0.95).into(),
1056                thickness: 2.0,
1057            });
1058        }
1059    }
1060    if let Some(id) = snapshot.selected {
1061        if let Some(node) = snapshot.node(id) {
1062            out.push(DebugDrawCmd::RectOutline {
1063                rect: node.absolute_bounds,
1064                color: Color::rgba(255, 64, 64, 0.95).into(),
1065                thickness: 2.0,
1066            });
1067        }
1068    }
1069    for warning in &snapshot.warnings {
1070        let Some(id) = warning.node else {
1071            continue;
1072        };
1073        let Some(node) = snapshot.node(id) else {
1074            continue;
1075        };
1076        out.push(DebugDrawCmd::RectOutline {
1077            rect: node.absolute_bounds,
1078            color: Color::rgba(255, 210, 0, 0.65).into(),
1079            thickness: 1.0,
1080        });
1081    }
1082    out
1083}
1084
1085/// Recursively appends one retained node and descendants in pre-order.
1086///
1087/// Missing IDs end that branch. Without layout, a node inherits the fallback
1088/// bounds and passes no assigned slots to children. Child layout slots are paired
1089/// by index; extra slots or children simply have no pair.
1090fn collect_node<A: 'static>(
1091    tree: &ElementTree<A>,
1092    id: ElementId,
1093    parent: Option<ElementId>,
1094    depth: usize,
1095    assigned_slot: Option<DebugRect>,
1096    fallback_bounds: DebugRect,
1097    snapshot: &mut DebugSnapshot,
1098) {
1099    let Some(el) = tree.get(id) else {
1100        return;
1101    };
1102
1103    let has_layout = el.layout.is_some();
1104    let absolute_bounds = assigned_slot
1105        .or_else(|| el.layout.as_ref().map(|layout| layout.paint_bounds.into()))
1106        .unwrap_or(fallback_bounds);
1107    let paint_bounds = el.layout.as_ref().map(|layout| {
1108        layout
1109            .paint_bounds
1110            .translate(Offset::new(absolute_bounds.x, absolute_bounds.y))
1111            .into()
1112    });
1113    let clip_bounds = el.layout.as_ref().and_then(|layout| {
1114        layout.clip.map(|clip| {
1115            translate_clip(clip, Offset::new(absolute_bounds.x, absolute_bounds.y))
1116                .bounding_rect()
1117                .into()
1118        })
1119    });
1120    let layout_size = el
1121        .layout
1122        .as_ref()
1123        .map(|layout| layout.size)
1124        .unwrap_or_default();
1125    let children = el.children.iter().map(|child| child.0).collect::<Vec<_>>();
1126    let children_slots = el
1127        .layout
1128        .as_ref()
1129        .map(|layout| {
1130            layout
1131                .children
1132                .iter()
1133                .enumerate()
1134                .filter_map(|(idx, slot)| {
1135                    let child = el.children.get(idx)?;
1136                    let absolute = DebugRect {
1137                        x: absolute_bounds.x + slot.offset.x,
1138                        y: absolute_bounds.y + slot.offset.y,
1139                        w: slot.size.w,
1140                        h: slot.size.h,
1141                    };
1142                    Some(DebugSlot {
1143                        child: child.0,
1144                        offset: slot.offset.into(),
1145                        size: slot.size.into(),
1146                        absolute,
1147                    })
1148                })
1149                .collect::<Vec<_>>()
1150        })
1151        .unwrap_or_default();
1152
1153    snapshot.nodes.push(DebugNode {
1154        id: id.0,
1155        parent: parent.map(|id| id.0),
1156        depth,
1157        children,
1158        widget_name: widget_name(&el.kind).to_string(),
1159        key: key_string(&el.key),
1160        layout_size: layout_size.into(),
1161        assigned_slot,
1162        absolute_bounds,
1163        paint_bounds,
1164        clip_bounds,
1165        size_hint: el.size_hint.into(),
1166        flex_item: el.flex_item.into(),
1167        layout_debug: layout_debug(tree, id, layout_size),
1168        children_slots,
1169        warnings: Vec::new(),
1170        has_layout,
1171    });
1172
1173    if let Some(layout) = el.layout.as_ref() {
1174        for (idx, child_id) in el.children.iter().copied().enumerate() {
1175            let child_slot = layout.children.get(idx).map(|slot| DebugRect {
1176                x: absolute_bounds.x + slot.offset.x,
1177                y: absolute_bounds.y + slot.offset.y,
1178                w: slot.size.w,
1179                h: slot.size.h,
1180            });
1181            collect_node(
1182                tree,
1183                child_id,
1184                Some(id),
1185                depth + 1,
1186                child_slot,
1187                absolute_bounds,
1188                snapshot,
1189            );
1190        }
1191    } else {
1192        for child_id in el.children.iter().copied() {
1193            collect_node(
1194                tree,
1195                child_id,
1196                Some(id),
1197                depth + 1,
1198                None,
1199                absolute_bounds,
1200                snapshot,
1201            );
1202        }
1203    }
1204}
1205
1206/// Converts retained layout instrumentation, or returns `None` when unavailable.
1207fn layout_debug<A>(
1208    tree: &ElementTree<A>,
1209    id: ElementId,
1210    _layout_size: Size,
1211) -> Option<DebugLayoutInfo> {
1212    let debug = tree.get(id)?.layout_debug.as_ref()?;
1213    Some(DebugLayoutInfo {
1214        constraints_in: Some(debug.constraints_in.into()),
1215        constraints_final: debug.constraints_final.map(Into::into),
1216        layout_size: debug.layout_size.into(),
1217    })
1218}
1219
1220/// Clones one node-attributed warning into global and per-node collections.
1221fn push_warning(
1222    warnings: &mut Vec<DebugWarning>,
1223    per_node: &mut HashMap<u64, Vec<DebugWarning>>,
1224    node: u64,
1225    kind: DebugWarningKind,
1226    message: &'static str,
1227) {
1228    let warning = DebugWarning {
1229        node: Some(node),
1230        kind,
1231        message: message.to_string(),
1232    };
1233    warnings.push(warning.clone());
1234    per_node.entry(node).or_default().push(warning);
1235}
1236
1237/// Compares width/height independently using the 0.5-logical-pixel tolerance.
1238fn size_exceeds_slot(layout_size: DebugSize, slot: DebugRect) -> bool {
1239    (layout_size.w - slot.w).abs() > EPSILON || (layout_size.h - slot.h).abs() > EPSILON
1240}
1241
1242/// Requires `point` inside every recorded ancestor clip.
1243///
1244/// An absent parent record ends traversal permissively to keep partial snapshots pickable.
1245fn ancestor_clips_contain(
1246    by_id: &HashMap<u64, &DebugNode>,
1247    node: &DebugNode,
1248    point: DebugPoint,
1249) -> bool {
1250    let mut current = node.parent;
1251    while let Some(id) = current {
1252        let Some(parent) = by_id.get(&id) else {
1253            return true;
1254        };
1255        if let Some(clip) = parent.clip_bounds {
1256            if !clip.contains(point) {
1257                return false;
1258            }
1259        }
1260        current = parent.parent;
1261    }
1262    true
1263}
1264
1265/// Translates clip geometry while preserving rounded radius.
1266fn translate_clip(clip: ClipShape, origin: Offset) -> ClipShape {
1267    match clip {
1268        ClipShape::Rect(r) => ClipShape::Rect(r.translate(origin)),
1269        ClipShape::RoundRect { rect, radius } => ClipShape::RoundRect {
1270            rect: rect.translate(origin),
1271            radius,
1272        },
1273    }
1274}
1275
1276/// Returns structural names or the widget's static debug name.
1277fn widget_name<A: 'static>(kind: &ElementKind<A>) -> &'static str {
1278    match kind {
1279        ElementKind::Empty => "Empty",
1280        ElementKind::Widget(widget) => widget.debug_name(),
1281        ElementKind::Component(_) => "Component",
1282    }
1283}
1284
1285/// Converts each retained key representation to its exact visible string.
1286fn key_string(key: &Option<Key>) -> Option<String> {
1287    match key {
1288        None => None,
1289        Some(Key::Static(value)) => Some((*value).to_string()),
1290        Some(Key::String(value)) => Some(value.clone()),
1291        Some(Key::U64(value)) => Some(value.to_string()),
1292    }
1293}
1294
1295/// Maps cross-axis alignment to stable lowercase protocol text.
1296fn align_items_name(value: AlignItems) -> &'static str {
1297    match value {
1298        AlignItems::Start => "start",
1299        AlignItems::Center => "center",
1300        AlignItems::End => "end",
1301        AlignItems::Stretch => "stretch",
1302    }
1303}