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}