Skip to main content

guise/devtools/
elements.rs

1//! The Elements panel: the component tree, and the sidebar that explains the
2//! selected node.
3//!
4//! This is Safari's Elements tab with `<div>` swapped for `<Button>`. The tree
5//! comes from [`super::probe`] — every `guise` component tags its root, so the
6//! outline is the component hierarchy rather than a wall of anonymous
7//! containers, which is the more useful reading of the same structure.
8//!
9//! The sidebar is read-only by design. A probe node is a snapshot taken during
10//! prepaint; writing to it would edit a copy and change nothing on screen. Live
11//! style editing is a different mechanism entirely — gpui's own element picker,
12//! wired up in [`super::install`] — and pretending otherwise here would be a
13//! worse lie than the missing feature.
14
15use std::collections::HashSet;
16
17use gpui::prelude::*;
18use gpui::{div, px, AnyElement, Context, Hsla, ScrollHandle, SharedString, Window};
19
20use super::probe::{ProbeNode, ProbeTree};
21use super::shell::{
22  cell, disclosure, elide, empty_state, filter_pill, glyph, hairline, hairline_v, kv_row,
23  section_header, Ink, LABEL_SIZE, MONO_SIZE, ROW_HEIGHT, SIDEBAR_WIDTH,
24};
25use super::styles::{box_model, declarations, BoxModel, Declaration};
26use super::DevTools;
27use crate::icon::IconName;
28use crate::style::MONO_FAMILY;
29
30/// The sidebar tabs, in Safari's order.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum ElementsSidebar {
33  #[default]
34  Styles,
35  Computed,
36  Node,
37}
38
39impl ElementsSidebar {
40  fn label(self) -> &'static str {
41    match self {
42      ElementsSidebar::Styles => "Styles",
43      ElementsSidebar::Computed => "Computed",
44      ElementsSidebar::Node => "Node",
45    }
46  }
47
48  const ALL: [ElementsSidebar; 3] = [
49    ElementsSidebar::Styles,
50    ElementsSidebar::Computed,
51    ElementsSidebar::Node,
52  ];
53}
54
55/// Panel state: what is selected, what is folded, which sidebar is showing.
56#[derive(Default)]
57pub struct ElementsPanel {
58  /// The selected node's stable key, which is what survives a re-render.
59  pub selected: Option<SharedString>,
60  /// Folded subtrees. Stored as the exception so a newly appearing node is
61  /// expanded, matching how Safari reveals new DOM.
62  collapsed: HashSet<SharedString>,
63  pub(crate) sidebar: ElementsSidebar,
64  /// The tree's scroll, so a selection made from outside the panel can be
65  /// scrolled to. Expanding a node's ancestors reveals it in the list; it
66  /// still has to be brought on screen.
67  scroll: ScrollHandle,
68}
69
70impl ElementsPanel {
71  pub fn select(&mut self, key: impl Into<SharedString>) {
72    self.selected = Some(key.into());
73  }
74
75  pub fn toggle(&mut self, key: &SharedString) {
76    if !self.collapsed.remove(key) {
77      self.collapsed.insert(key.clone());
78    }
79  }
80
81  fn is_collapsed(&self, key: &SharedString) -> bool {
82    self.collapsed.contains(key)
83  }
84
85  /// Expand every ancestor of `key` so a selection made elsewhere — the
86  /// element picker, a Console source link — is actually on screen.
87  pub fn reveal(&mut self, tree: &ProbeTree, key: &SharedString) {
88    if let Some(index) = tree.find(key) {
89      for ancestor in tree.ancestry(index) {
90        self.collapsed.remove(&tree.nodes[ancestor].key);
91      }
92    }
93    self.selected = Some(key.clone());
94    // The rows are counted after the ancestors are expanded, because that
95    // is what decides which row the node ends up on.
96    if let Some(row) = self
97      .rows(tree)
98      .iter()
99      .position(|index| tree.get(*index).map(|node| &node.key) == Some(key))
100    {
101      self.scroll.scroll_to_top_of_item(row);
102    }
103  }
104
105  /// The selected node, if it is still in the current tree. A node can vanish
106  /// between frames — a menu closes, a list scrolls — and the panel simply
107  /// shows nothing rather than holding a stale copy.
108  pub fn selected_node<'a>(&self, tree: &'a ProbeTree) -> Option<&'a ProbeNode> {
109    let key = self.selected.as_ref()?;
110    tree.find(key).and_then(|index| tree.get(index))
111  }
112
113  /// Flatten the tree into rows, honouring folds. One row per node, and no
114  /// closing row: a browser prints `</div>` because markup nests in text,
115  /// but this tree nests by indentation, where a closing line says nothing
116  /// the next row's indent does not already say — and costs half the panel.
117  fn rows(&self, tree: &ProbeTree) -> Vec<usize> {
118    let mut rows = Vec::with_capacity(tree.len());
119    for root in &tree.roots {
120      self.push_rows(tree, *root, &mut rows);
121    }
122    rows
123  }
124
125  fn push_rows(&self, tree: &ProbeTree, index: usize, rows: &mut Vec<usize>) {
126    let Some(node) = tree.get(index) else {
127      return;
128    };
129    rows.push(index);
130    if node.is_leaf() || self.is_collapsed(&node.key) {
131      return;
132    }
133    for child in &node.children {
134      self.push_rows(tree, *child, rows);
135    }
136  }
137
138  pub fn render(
139    &self,
140    tree: &ProbeTree,
141    window: &mut Window,
142    cx: &mut Context<DevTools>,
143  ) -> AnyElement {
144    let ink = Ink::read(cx);
145
146    if tree.is_empty() {
147      return div()
148        .flex()
149        .flex_1()
150        .min_h(px(0.0))
151        .child(empty_state(
152          "No elements recorded. Components report themselves while the inspector is open.",
153          &ink,
154        ))
155        .into_any_element();
156    }
157
158    div()
159      .flex()
160      .flex_1()
161      .min_h(px(0.0))
162      .w_full()
163      .child(self.tree_column(tree, &ink, cx))
164      .child(hairline_v(&ink))
165      .child(self.sidebar_column(tree, &ink, window, cx))
166      .into_any_element()
167  }
168
169  /// The tree, plus the breadcrumb bar Safari pins under it.
170  fn tree_column(&self, tree: &ProbeTree, ink: &Ink, cx: &mut Context<DevTools>) -> AnyElement {
171    let rows = self.rows(tree);
172    let mut list = div()
173      .id("devtools-elements-tree")
174      .track_scroll(&self.scroll)
175      .flex()
176      .flex_col()
177      .flex_1()
178      .min_h(px(0.0))
179      .w_full()
180      .overflow_scroll()
181      .bg(ink.content)
182      .font_family(MONO_FAMILY)
183      .text_size(px(MONO_SIZE));
184
185    for index in rows {
186      let Some(node) = tree.get(index) else {
187        continue;
188      };
189      list = list.child(self.row(node, index, ink, cx));
190    }
191
192    div()
193      .flex()
194      .flex_col()
195      .flex_1()
196      .min_w(px(0.0))
197      .h_full()
198      .child(list)
199      .child(hairline(ink))
200      .child(self.breadcrumbs(tree, ink, cx))
201      .into_any_element()
202  }
203
204  /// One markup line: `▶ <Button variant="filled">`.
205  fn row(
206    &self,
207    node: &ProbeNode,
208    index: usize,
209    ink: &Ink,
210    cx: &mut Context<DevTools>,
211  ) -> AnyElement {
212    let selected = self.selected.as_ref() == Some(&node.key);
213    let expandable = !node.is_leaf();
214    let expanded = !self.is_collapsed(&node.key);
215    let indent = node.depth as f32 * 12.0 + 6.0;
216    let text_color = if selected {
217      ink.selected_text
218    } else {
219      ink.text
220    };
221    let punct = if selected {
222      ink.selected_text
223    } else {
224      ink.punct
225    };
226    let tag_color = if selected { ink.selected_text } else { ink.tag };
227    let hover_bg = ink.hover;
228
229    let key_for_click = node.key.clone();
230    let key_for_toggle = node.key.clone();
231
232    // The component, then its props as a YAML flow mapping. These are
233    // builder arguments, not markup attributes, so they are printed the way
234    // the Styles pane prints a declaration rather than the way HTML would.
235    let mut markup = div()
236      .flex()
237      .items_center()
238      .gap(px(0.0))
239      .flex_1()
240      .min_w(px(0.0))
241      .overflow_hidden()
242      .whitespace_nowrap()
243      .child(
244        div()
245          .flex_none()
246          .text_color(tag_color)
247          .child(node.name.clone()),
248      );
249
250    for (position, (name, value)) in node.attrs.iter().enumerate() {
251      markup = markup.child(
252        div()
253          .flex()
254          .flex_none()
255          .child(
256            div()
257              .text_color(punct)
258              .child(SharedString::new_static(if position == 0 {
259                "   "
260              } else {
261                ", "
262              })),
263          )
264          .child(
265            div()
266              .text_color(if selected {
267                ink.selected_text
268              } else {
269                ink.attr
270              })
271              .child(name.clone()),
272          )
273          .when(!value.is_empty(), |el| {
274            el.child(
275              div()
276                .text_color(punct)
277                .child(SharedString::new_static(": ")),
278            )
279            .child(
280              div()
281                .text_color(if selected {
282                  ink.selected_text
283                } else {
284                  ink.value
285                })
286                .child(value.clone()),
287            )
288          }),
289      );
290    }
291
292    div()
293      .id(("devtools-element-row", index))
294      .flex()
295      .items_center()
296      .flex_none()
297      .h(px(ROW_HEIGHT))
298      .w_full()
299      .pl(px(indent))
300      .pr(px(6.0))
301      .text_color(text_color)
302      .when(selected, |el| el.bg(ink.selected))
303      .when(!selected, |el| el.hover(move |st| st.bg(hover_bg)))
304      .child(
305        div()
306          .id(("devtools-element-twisty", index))
307          .flex()
308          .flex_none()
309          .items_center()
310          .justify_center()
311          .w(px(14.0))
312          .h(px(ROW_HEIGHT))
313          .child(if expandable {
314            disclosure(Some(expanded), ink, cx)
315          } else {
316            disclosure(None, ink, cx)
317          })
318          .on_click(
319            cx.listener(move |this: &mut DevTools, _event, _window, cx| {
320              this.elements.toggle(&key_for_toggle);
321              cx.notify();
322            }),
323          ),
324      )
325      .child(markup)
326      .on_click(
327        cx.listener(move |this: &mut DevTools, _event, _window, cx| {
328          this.elements.select(key_for_click.clone());
329          cx.notify();
330        }),
331      )
332      .into_any_element()
333  }
334
335  /// The path from the root to the selection, along the bottom edge.
336  fn breadcrumbs(&self, tree: &ProbeTree, ink: &Ink, cx: &mut Context<DevTools>) -> AnyElement {
337    let mut bar = div()
338      .flex()
339      .flex_none()
340      .items_center()
341      .h(px(22.0))
342      .w_full()
343      .px(px(6.0))
344      .gap(px(2.0))
345      .bg(ink.chrome)
346      .text_size(px(LABEL_SIZE))
347      .overflow_hidden();
348
349    let Some(index) = self.selected.as_ref().and_then(|key| tree.find(key)) else {
350      return bar
351        .child(
352          div()
353            .text_color(ink.dim)
354            .child(SharedString::new_static("Select an element")),
355        )
356        .into_any_element();
357    };
358
359    let chain = tree.ancestry(index);
360    let last = chain.len().saturating_sub(1);
361    for (position, node_index) in chain.into_iter().enumerate() {
362      let Some(node) = tree.get(node_index) else {
363        continue;
364      };
365      if position > 0 {
366        bar = bar.child(
367          div()
368            .flex_none()
369            .text_color(ink.dim)
370            .child(SharedString::new_static("›")),
371        );
372      }
373      let key = node.key.clone();
374      let is_last = position == last;
375      let hover_bg = ink.hover;
376      bar = bar.child(
377        div()
378          .id(("devtools-crumb", node_index))
379          .flex()
380          .flex_none()
381          .items_center()
382          .h(px(17.0))
383          .px(px(5.0))
384          .rounded(px(4.0))
385          .text_color(if is_last { ink.text } else { ink.dim })
386          .hover(move |st| st.bg(hover_bg))
387          .child(node.name.clone())
388          .on_click(
389            cx.listener(move |this: &mut DevTools, _event, _window, cx| {
390              this.elements.select(key.clone());
391              cx.notify();
392            }),
393          ),
394      );
395    }
396
397    bar.into_any_element()
398  }
399
400  fn sidebar_column(
401    &self,
402    tree: &ProbeTree,
403    ink: &Ink,
404    window: &mut Window,
405    cx: &mut Context<DevTools>,
406  ) -> AnyElement {
407    let mut tabs = div()
408      .flex()
409      .flex_none()
410      .items_center()
411      .gap(px(4.0))
412      .h(px(26.0))
413      .px(px(8.0))
414      .w_full()
415      .bg(ink.chrome)
416      .border_b_1()
417      .border_color(ink.border);
418
419    for tab in ElementsSidebar::ALL {
420      tabs = tabs.child(
421        filter_pill(
422          ("devtools-elements-sidebar", tab as usize),
423          tab.label(),
424          self.sidebar == tab,
425          ink,
426        )
427        .on_click(
428          cx.listener(move |this: &mut DevTools, _event, _window, cx| {
429            this.elements.sidebar = tab;
430            cx.notify();
431          }),
432        ),
433      );
434    }
435
436    let body = match self.selected_node(tree) {
437      None => empty_state("No element selected", ink).into_any_element(),
438      Some(node) => match self.sidebar {
439        ElementsSidebar::Styles => styles_view(node, ink, cx),
440        ElementsSidebar::Computed => computed_view(node, ink, window, cx),
441        ElementsSidebar::Node => node_view(node, ink, cx),
442      },
443    };
444
445    div()
446      .flex()
447      .flex_col()
448      .flex_none()
449      .w(px(SIDEBAR_WIDTH))
450      .h_full()
451      .bg(ink.content)
452      .child(tabs)
453      .child(
454        div()
455          .id("devtools-elements-sidebar-body")
456          .flex()
457          .flex_col()
458          .flex_1()
459          .min_h(px(0.0))
460          .w_full()
461          .overflow_scroll()
462          .child(body),
463      )
464      .into_any_element()
465  }
466}
467
468/// A `property: value;` line, with a swatch when the value is a color.
469fn declaration_row(declaration: &Declaration, ink: &Ink) -> AnyElement {
470  div()
471    .flex()
472    .items_start()
473    .w_full()
474    .pl(px(20.0))
475    .pr(px(8.0))
476    .py(px(1.0))
477    .font_family(MONO_FAMILY)
478    .text_size(px(MONO_SIZE))
479    .child(
480      div()
481        .flex_none()
482        .text_color(ink.property)
483        .child(declaration.property.clone()),
484    )
485    .child(
486      div()
487        .flex_none()
488        .text_color(ink.punct)
489        .child(SharedString::new_static(": ")),
490    )
491    .when_some(declaration.color, |el, color| {
492      el.child(
493        div()
494          .flex_none()
495          .w(px(9.0))
496          .h(px(9.0))
497          .mt(px(3.0))
498          .mr(px(4.0))
499          .rounded(px(2.0))
500          .border_1()
501          .border_color(ink.border)
502          .bg(color),
503      )
504    })
505    .child(
506      div()
507        .flex_1()
508        .text_color(ink.value)
509        .child(declaration.value.clone()),
510    )
511    .child(
512      div()
513        .flex_none()
514        .text_color(ink.punct)
515        .child(SharedString::new_static(";")),
516    )
517    .into_any_element()
518}
519
520/// The Styles sidebar: one rule block, headed by the component as its selector.
521fn styles_view(node: &ProbeNode, ink: &Ink, _cx: &mut Context<DevTools>) -> AnyElement {
522  let Some(style) = node.style.as_ref() else {
523    return empty_state("This element reported no style", ink).into_any_element();
524  };
525  let declarations = declarations(style);
526
527  let mut block = div()
528    .flex()
529    .flex_col()
530    .w_full()
531    .py(px(4.0))
532    .font_family(MONO_FAMILY)
533    .text_size(px(MONO_SIZE))
534    .child(
535      div()
536        .flex()
537        .items_center()
538        .justify_between()
539        .w_full()
540        .px(px(8.0))
541        .child(
542          div()
543            .flex()
544            .child(
545              div()
546                .text_color(ink.tag)
547                .child(SharedString::from(node.name.to_string())),
548            )
549            .child(
550              div()
551                .text_color(ink.punct)
552                .child(SharedString::new_static(" {")),
553            ),
554        )
555        .when_some(node.source.as_ref(), |el, source| {
556          el.child(
557            div()
558              .text_size(px(LABEL_SIZE))
559              .text_color(ink.dim)
560              .child(SharedString::from(source.short())),
561          )
562        }),
563    );
564
565  if declarations.is_empty() {
566    block = block.child(
567      div()
568        .pl(px(20.0))
569        .text_color(ink.dim)
570        .child(SharedString::new_static("/* no declarations */")),
571    );
572  }
573  for declaration in &declarations {
574    block = block.child(declaration_row(declaration, ink));
575  }
576
577  block = block.child(
578    div()
579      .px(px(8.0))
580      .text_color(ink.punct)
581      .child(SharedString::new_static("}")),
582  );
583
584  div()
585    .flex()
586    .flex_col()
587    .w_full()
588    .child(section_header(
589      SharedString::from(format!("{} — {} rules", node.name, 1)),
590      ink,
591    ))
592    .child(block)
593    .into_any_element()
594}
595
596/// The Computed sidebar: the box model diagram, then every declaration sorted
597/// by name — the shape Safari's Computed pane has.
598fn computed_view(
599  node: &ProbeNode,
600  ink: &Ink,
601  window: &mut Window,
602  _cx: &mut Context<DevTools>,
603) -> AnyElement {
604  let rem_size = window.rem_size();
605  let model = node
606    .style
607    .as_ref()
608    .map(|style| box_model(style, node.bounds.size, rem_size))
609    .unwrap_or(BoxModel {
610      width: f32::from(node.bounds.size.width),
611      height: f32::from(node.bounds.size.height),
612      ..BoxModel::default()
613    });
614
615  let mut sorted = node
616    .style
617    .as_ref()
618    .map(|style| declarations(style))
619    .unwrap_or_default();
620  sorted.sort_by(|a, b| a.property.cmp(&b.property));
621
622  let mut properties = div().flex().flex_col().w_full().pb(px(6.0));
623  for declaration in &sorted {
624    properties = properties.child(declaration_row(declaration, ink));
625  }
626
627  div()
628    .flex()
629    .flex_col()
630    .w_full()
631    .child(section_header("Box Model", ink))
632    .child(box_model_view(&model, ink))
633    .child(section_header("Properties", ink))
634    .child(properties)
635    .into_any_element()
636}
637
638/// Nested boxes labelled with their edge values, outermost first — margin,
639/// border, padding, content.
640fn box_model_view(model: &BoxModel, ink: &Ink) -> AnyElement {
641  let (content_width, content_height) = model.content();
642
643  let band = |label: &'static str,
644              color: Hsla,
645              top: f32,
646              right: f32,
647              bottom: f32,
648              left: f32,
649              inner: AnyElement| {
650    div()
651      .flex()
652      .flex_col()
653      .items_center()
654      .w_full()
655      .bg(color)
656      .border_1()
657      .border_color(ink.border)
658      .child(
659        div()
660          .flex()
661          .items_center()
662          .justify_between()
663          .w_full()
664          .px(px(4.0))
665          .child(
666            // The band is a saturated fill, so the dim text color
667            // this would otherwise use disappears into it.
668            div()
669              .text_size(px(9.0))
670              .text_color(ink.text)
671              .child(SharedString::new_static(label)),
672          )
673          .child(
674            div()
675              .text_size(px(9.0))
676              .text_color(ink.text)
677              .child(SharedString::from(number(top))),
678          ),
679      )
680      .child(
681        div()
682          .flex()
683          .items_center()
684          .justify_between()
685          .w_full()
686          .gap(px(4.0))
687          .px(px(4.0))
688          .child(
689            div()
690              .flex_none()
691              .text_size(px(9.0))
692              .text_color(ink.text)
693              .child(SharedString::from(number(left))),
694          )
695          .child(inner)
696          .child(
697            div()
698              .flex_none()
699              .text_size(px(9.0))
700              .text_color(ink.text)
701              .child(SharedString::from(number(right))),
702          ),
703      )
704      .child(
705        div().flex().justify_center().w_full().child(
706          div()
707            .text_size(px(9.0))
708            .text_color(ink.text)
709            .child(SharedString::from(number(bottom))),
710        ),
711      )
712  };
713
714  let content = div()
715    .flex()
716    .flex_1()
717    .items_center()
718    .justify_center()
719    .h(px(30.0))
720    .bg(ink.box_content)
721    .border_1()
722    .border_color(ink.border)
723    .text_size(px(10.0))
724    .text_color(ink.text)
725    .child(SharedString::from(format!(
726      "{} × {}",
727      number(content_width),
728      number(content_height)
729    )))
730    .into_any_element();
731
732  let padding = band(
733    "padding",
734    ink.box_padding,
735    model.padding.top,
736    model.padding.right,
737    model.padding.bottom,
738    model.padding.left,
739    content,
740  )
741  .into_any_element();
742
743  let border = band(
744    "border",
745    ink.box_border,
746    model.border.top,
747    model.border.right,
748    model.border.bottom,
749    model.border.left,
750    padding,
751  )
752  .into_any_element();
753
754  let margin = band(
755    "margin",
756    ink.box_margin,
757    model.margin.top,
758    model.margin.right,
759    model.margin.bottom,
760    model.margin.left,
761    border,
762  );
763
764  div()
765    .flex()
766    .flex_col()
767    .w_full()
768    .p(px(10.0))
769    .font_family(MONO_FAMILY)
770    .child(margin)
771    .into_any_element()
772}
773
774/// `0` reads better than `0.00`, and a fractional pixel is worth seeing.
775fn number(value: f32) -> String {
776  if (value - value.round()).abs() < 0.01 {
777    format!("{}", value.round() as i64)
778  } else {
779    format!("{value:.2}")
780  }
781}
782
783/// The Node sidebar: identity, geometry, and the reported attributes.
784fn node_view(node: &ProbeNode, ink: &Ink, cx: &mut Context<DevTools>) -> AnyElement {
785  let mut identity = div().flex().flex_col().w_full().py(px(4.0));
786  identity = identity.child(kv_row("Component", node.name.clone(), ink));
787  identity = identity.child(kv_row("Path", node.key.clone(), ink));
788  if let Some(id) = &node.element_id {
789    identity = identity.child(kv_row("Element ID", id.clone(), ink));
790  }
791  if let Some(source) = &node.source {
792    let target = source.clone();
793    identity = identity.child(
794      div()
795        .id("devtools-node-source")
796        .child(kv_row(
797          "Source",
798          SharedString::from(elide(source.file.as_ref(), 34) + &format!(":{}", source.line)),
799          ink,
800        ))
801        .on_click(
802          cx.listener(move |this: &mut DevTools, _event, _window, cx| {
803            this.reveal_source(target.clone(), cx);
804          }),
805        ),
806    );
807  }
808
809  let bounds = node.bounds;
810  let mut geometry = div().flex().flex_col().w_full().py(px(4.0));
811  geometry = geometry.child(kv_row(
812    "Position",
813    SharedString::from(format!(
814      "{}, {}",
815      number(f32::from(bounds.origin.x)),
816      number(f32::from(bounds.origin.y))
817    )),
818    ink,
819  ));
820  geometry = geometry.child(kv_row(
821    "Size",
822    SharedString::from(format!(
823      "{} × {}",
824      number(f32::from(bounds.size.width)),
825      number(f32::from(bounds.size.height))
826    )),
827    ink,
828  ));
829  geometry = geometry.child(kv_row(
830    "Depth",
831    SharedString::from(node.depth.to_string()),
832    ink,
833  ));
834
835  let mut attributes = div().flex().flex_col().w_full().py(px(4.0));
836  if node.attrs.is_empty() {
837    attributes = attributes.child(
838      div()
839        .px(px(8.0))
840        .py(px(2.0))
841        .text_size(px(LABEL_SIZE))
842        .text_color(ink.dim)
843        .child(SharedString::new_static("None reported")),
844    );
845  }
846  for (name, value) in &node.attrs {
847    attributes = attributes.child(kv_row(
848      name.clone(),
849      if value.is_empty() {
850        SharedString::new_static("true")
851      } else {
852        value.clone()
853      },
854      ink,
855    ));
856  }
857
858  div()
859    .flex()
860    .flex_col()
861    .w_full()
862    .child(section_header("Identity", ink))
863    .child(identity)
864    .child(section_header("Geometry", ink))
865    .child(geometry)
866    .child(section_header("Attributes", ink))
867    .child(attributes)
868    .into_any_element()
869}
870
871/// The Layers panel reuses the tree: gpui has no compositing layers to show, so
872/// what is genuinely useful is paint order and geometry, which is what this
873/// lists — deepest-painting last, as the compositor would.
874pub fn layers_view(
875  tree: &ProbeTree,
876  selected: Option<&SharedString>,
877  ink: &Ink,
878  cx: &mut Context<DevTools>,
879) -> AnyElement {
880  if tree.is_empty() {
881    return empty_state("No elements recorded", ink).into_any_element();
882  }
883
884  let mut rows: Vec<&ProbeNode> = tree.nodes.iter().collect();
885  rows.sort_by(|a, b| {
886    let area_a = f32::from(a.bounds.size.width) * f32::from(a.bounds.size.height);
887    let area_b = f32::from(b.bounds.size.width) * f32::from(b.bounds.size.height);
888    area_b
889      .partial_cmp(&area_a)
890      .unwrap_or(std::cmp::Ordering::Equal)
891  });
892
893  let mut list = div()
894    .id("devtools-layers")
895    .flex()
896    .flex_col()
897    .flex_1()
898    .min_h(px(0.0))
899    .w_full()
900    .overflow_scroll()
901    .bg(ink.content)
902    .font_family(MONO_FAMILY)
903    .text_size(px(MONO_SIZE));
904
905  list = list.child(
906    div()
907      .flex()
908      .flex_none()
909      .items_center()
910      .h(px(20.0))
911      .w_full()
912      .bg(ink.chrome)
913      .border_b_1()
914      .border_color(ink.border)
915      .child(cell("Layer", None, ink.dim))
916      .child(cell("Depth", Some(52.0), ink.dim))
917      .child(cell("Position", Some(90.0), ink.dim))
918      .child(cell("Size", Some(90.0), ink.dim))
919      .child(cell("Area", Some(78.0), ink.dim)),
920  );
921
922  for (position, node) in rows.iter().enumerate() {
923    let is_selected = selected == Some(&node.key);
924    let key = node.key.clone();
925    let hover_bg = ink.hover;
926    let area = f32::from(node.bounds.size.width) * f32::from(node.bounds.size.height);
927    let text = if is_selected {
928      ink.selected_text
929    } else {
930      ink.text
931    };
932    let dim = if is_selected {
933      ink.selected_text
934    } else {
935      ink.dim
936    };
937
938    list = list.child(
939      div()
940        .id(("devtools-layer-row", position))
941        .flex()
942        .items_center()
943        .flex_none()
944        .h(px(ROW_HEIGHT))
945        .w_full()
946        .when(is_selected, |el| el.bg(ink.selected))
947        .when(!is_selected && position % 2 == 1, |el| el.bg(ink.stripe))
948        .when(!is_selected, |el| el.hover(move |st| st.bg(hover_bg)))
949        .child(
950          div()
951            .flex()
952            .items_center()
953            .flex_1()
954            .min_w(px(0.0))
955            .h_full()
956            .px(px(6.0))
957            .gap(px(5.0))
958            .child(glyph(IconName::Layers, 11.0, dim, cx))
959            .child(
960              div()
961                .overflow_hidden()
962                .whitespace_nowrap()
963                .text_color(text)
964                .child(node.name.clone()),
965            ),
966        )
967        .child(cell(node.depth.to_string(), Some(52.0), dim))
968        .child(cell(
969          format!(
970            "{}, {}",
971            number(f32::from(node.bounds.origin.x)),
972            number(f32::from(node.bounds.origin.y))
973          ),
974          Some(90.0),
975          dim,
976        ))
977        .child(cell(
978          format!(
979            "{} × {}",
980            number(f32::from(node.bounds.size.width)),
981            number(f32::from(node.bounds.size.height))
982          ),
983          Some(90.0),
984          dim,
985        ))
986        // Areas run to six digits; a fractional pixel of area is noise.
987        .child(cell(
988          format!("{} px²", area.round() as i64),
989          Some(84.0),
990          dim,
991        ))
992        .on_click(
993          cx.listener(move |this: &mut DevTools, _event, _window, cx| {
994            this.elements.select(key.clone());
995            cx.notify();
996          }),
997        ),
998    );
999  }
1000
1001  list.into_any_element()
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006  use super::*;
1007  use crate::devtools::probe;
1008
1009  fn tree_of(build: impl FnOnce()) -> ProbeTree {
1010    probe::set_enabled(false);
1011    probe::set_enabled(true);
1012    build();
1013    probe::begin_frame_unclaimed();
1014    probe::tree()
1015  }
1016
1017  /// Drives the recorder the way `ProbeElement::prepaint` does.
1018  fn node(name: &'static str, children: impl FnOnce()) {
1019    probe::test_record(name, children);
1020  }
1021
1022  /// One row per node, however deep — the tree nests by indentation, so a
1023  /// container costs one line and not two.
1024  #[test]
1025  fn every_node_produces_exactly_one_row() {
1026    let panel = ElementsPanel::default();
1027    let tree = tree_of(|| {
1028      node("Stack", || {
1029        node("Button", || {});
1030      });
1031    });
1032
1033    assert_eq!(panel.rows(&tree), vec![0, 1]);
1034  }
1035
1036  #[test]
1037  fn collapsing_hides_children() {
1038    let mut panel = ElementsPanel::default();
1039    let tree = tree_of(|| {
1040      node("Stack", || {
1041        node("Button", || {});
1042        node("Badge", || {});
1043      });
1044    });
1045
1046    panel.toggle(&tree.nodes[0].key.clone());
1047    assert_eq!(panel.rows(&tree), vec![0]);
1048  }
1049
1050  #[test]
1051  fn toggling_twice_restores_the_children() {
1052    let mut panel = ElementsPanel::default();
1053    let tree = tree_of(|| node("Stack", || node("Button", || {})));
1054    let key = tree.nodes[0].key.clone();
1055
1056    panel.toggle(&key);
1057    panel.toggle(&key);
1058    assert_eq!(panel.rows(&tree), vec![0, 1]);
1059  }
1060
1061  #[test]
1062  fn revealing_expands_every_ancestor() {
1063    let mut panel = ElementsPanel::default();
1064    let tree = tree_of(|| node("AppShell", || node("Stack", || node("Button", || {}))));
1065    let leaf = tree.nodes[2].key.clone();
1066
1067    panel.toggle(&tree.nodes[0].key.clone());
1068    panel.toggle(&tree.nodes[1].key.clone());
1069    panel.reveal(&tree, &leaf);
1070
1071    assert_eq!(panel.selected.as_ref(), Some(&leaf));
1072    assert!(panel.rows(&tree).contains(&2));
1073  }
1074
1075  #[test]
1076  fn a_selection_that_left_the_tree_resolves_to_nothing() {
1077    let mut panel = ElementsPanel::default();
1078    let tree = tree_of(|| node("Stack", || node("Menu", || {})));
1079    panel.select(tree.nodes[1].key.clone());
1080    assert!(panel.selected_node(&tree).is_some());
1081
1082    let without_menu = tree_of(|| node("Stack", || {}));
1083    assert!(panel.selected_node(&without_menu).is_none());
1084  }
1085}