Skip to main content

gpui_devtools/
lib.rs

1use std::{cell::RefCell, rc::Rc, sync::Arc, time::Duration};
2
3use gpui::{
4  App, ClipboardItem, Context, Div, DivInspectorState, Inspector, InspectorElementId, IntoElement,
5  KeyBinding, StyleRefinement, Window, actions, div, img, prelude::*, rgb,
6};
7
8const DEFAULT_MACOS_KEY_BINDING: &str = "cmd-alt-i";
9const DEFAULT_OTHER_KEY_BINDING: &str = "ctrl-alt-i";
10const COPY_FEEDBACK_DURATION: Duration = Duration::from_millis(1500);
11const PICK_ICON_SVG: &[u8] = include_bytes!("../assets/icons/square-dashed-mouse-pointer.svg");
12const CLOSE_ICON_SVG: &[u8] = include_bytes!("../assets/icons/x.svg");
13
14actions!(gpui_devtools, [ToggleInspector]);
15
16#[derive(Clone, Debug)]
17pub struct Config {
18  pub key_binding: Option<&'static str>,
19  pub background: u32,
20  pub panel_background: u32,
21  pub border: u32,
22  pub text: u32,
23  pub muted_text: u32,
24  pub accent: u32,
25}
26
27impl Default for Config {
28  fn default() -> Self {
29    Self {
30      key_binding: Some(default_key_binding()),
31      background: 0x111318,
32      panel_background: 0x191c22,
33      border: 0x30343d,
34      text: 0xe6e9ef,
35      muted_text: 0x9299a8,
36      accent: 0x61afef,
37    }
38  }
39}
40
41impl Config {
42  pub fn key_binding(mut self, key_binding: Option<&'static str>) -> Self {
43    self.key_binding = key_binding;
44    self
45  }
46}
47
48pub fn init(cx: &mut App) {
49  init_with(Config::default(), cx);
50}
51
52pub fn init_with(config: Config, cx: &mut App) {
53  if let Some(key_binding) = config.key_binding {
54    cx.bind_keys([KeyBinding::new(key_binding, ToggleInspector, None)]);
55  }
56
57  cx.on_action(|_: &ToggleInspector, cx| toggle_active_window(cx));
58
59  let div_config = config.clone();
60  cx.register_inspector_element(move |_id, state: &DivInspectorState, _window, _cx| {
61    render_div_state(state, &div_config)
62  });
63
64  let copy_feedback = Rc::new(RefCell::new(CopyFeedback::default()));
65  cx.set_inspector_renderer(Box::new(move |inspector, window, cx| {
66    render_inspector(inspector, window, cx, &copy_feedback, &config).into_any_element()
67  }));
68}
69
70pub fn toggle_active_window(cx: &mut App) {
71  let Some(active_window) = cx.active_window() else {
72    return;
73  };
74
75  cx.defer(move |cx| {
76    let _ = active_window.update(cx, |_, window, cx| window.toggle_inspector(cx));
77  });
78}
79
80fn render_inspector(
81  inspector: &mut Inspector,
82  window: &mut Window,
83  cx: &mut Context<Inspector>,
84  copy_feedback: &Rc<RefCell<CopyFeedback>>,
85  config: &Config,
86) -> Div {
87  let active_element = inspector.active_element_id().cloned();
88  let is_picking = inspector.is_picking();
89  let inspector_states = inspector.render_inspector_states(window, cx);
90  let content = div()
91    .id("gpui-devtools-content")
92    .flex_1()
93    .overflow_y_scroll()
94    .p_3()
95    .flex()
96    .flex_col()
97    .gap_3();
98  let content = if let Some(id) = active_element {
99    content
100      .child(render_element_id(&id, cx, copy_feedback, config))
101      .children(inspector_states)
102  } else {
103    content.child(render_empty_state(is_picking, config))
104  };
105
106  let pick_button = div()
107    .id("gpui-devtools-pick")
108    .size(gpui::px(28.0))
109    .flex()
110    .items_center()
111    .justify_center()
112    .rounded_md()
113    .cursor_pointer()
114    .border_1()
115    .border_color(if is_picking {
116      rgb(config.accent)
117    } else {
118      rgb(config.border)
119    })
120    .bg(if is_picking {
121      rgb(config.accent)
122    } else {
123      rgb(config.panel_background)
124    })
125    .hover(|button| button.border_color(rgb(config.accent)))
126    .child(render_icon(PICK_ICON_SVG, config.text).size_4())
127    .on_click(cx.listener(|inspector, _, window, _cx| {
128      inspector.start_picking();
129      window.refresh();
130    }));
131  let close_button = div()
132    .id("gpui-devtools-close")
133    .size(gpui::px(28.0))
134    .flex()
135    .items_center()
136    .justify_center()
137    .rounded_md()
138    .cursor_pointer()
139    .hover(|button| button.bg(rgb(config.panel_background)))
140    .child(render_icon(CLOSE_ICON_SVG, config.muted_text).size_4())
141    .on_click(cx.listener(|_inspector, _, window, cx| {
142      window.toggle_inspector(cx);
143    }));
144
145  div()
146    .size_full()
147    .flex()
148    .flex_col()
149    .bg(rgb(config.background))
150    .text_color(rgb(config.text))
151    .border_l_1()
152    .border_color(rgb(config.border))
153    .child(
154      div()
155        .h_12()
156        .px_3()
157        .flex()
158        .items_center()
159        .justify_between()
160        .border_b_1()
161        .border_color(rgb(config.border))
162        .child(
163          div()
164            .flex()
165            .items_center()
166            .gap_2()
167            .child(pick_button)
168            .child(
169              div()
170                .font_weight(gpui::FontWeight::SEMIBOLD)
171                .child("GPUI DevTools"),
172            ),
173        )
174        .child(close_button),
175    )
176    .child(content)
177}
178
179fn render_icon(svg: &'static [u8], color: u32) -> gpui::Img {
180  img(Arc::new(gpui::Image::from_bytes(
181    gpui::ImageFormat::Svg,
182    recolor_svg(svg, color),
183  )))
184}
185
186fn recolor_svg(svg: &[u8], color: u32) -> Vec<u8> {
187  let color = format!("#{:06x}", color & 0xffffff);
188  String::from_utf8_lossy(svg)
189    .replace("currentColor", &color)
190    .into_bytes()
191}
192
193fn render_empty_state(is_picking: bool, config: &Config) -> Div {
194  let (title, description) = empty_state_copy(is_picking);
195
196  div()
197    .flex_1()
198    .py_12()
199    .px_4()
200    .flex()
201    .flex_col()
202    .items_center()
203    .justify_center()
204    .gap_2()
205    .text_center()
206    .child(
207      div()
208        .text_lg()
209        .font_weight(gpui::FontWeight::SEMIBOLD)
210        .child(title),
211    )
212    .child(
213      div()
214        .text_sm()
215        .text_color(rgb(config.muted_text))
216        .child(description),
217    )
218}
219
220fn empty_state_copy(is_picking: bool) -> (&'static str, &'static str) {
221  if is_picking {
222    (
223      "Pick an element",
224      "Move over the application and click to inspect.",
225    )
226  } else {
227    (
228      "No element selected",
229      "Use Pick to select an element in the application.",
230    )
231  }
232}
233
234fn render_element_id(
235  id: &InspectorElementId,
236  cx: &mut Context<Inspector>,
237  copy_feedback: &Rc<RefCell<CopyFeedback>>,
238  config: &Config,
239) -> Div {
240  let source = source_location(id);
241  let global_id = id.path.global_id.to_string();
242
243  section("Selected element", config)
244    .child(copyable_property(
245      CopyableProperty {
246        id: "gpui-devtools-copy-source",
247        label: "Source",
248        display_value: source.clone(),
249        copy_value: source,
250        target: CopyTarget::Source,
251      },
252      cx,
253      copy_feedback,
254      config,
255    ))
256    .child(
257      div()
258        .flex()
259        .gap_3()
260        .child(property("Instance", id.instance_id.to_string(), config))
261        .child(
262          copyable_property(
263            CopyableProperty {
264              id: "gpui-devtools-copy-global-id",
265              label: "Global ID",
266              display_value: truncate_middle(&global_id, 48),
267              copy_value: global_id,
268              target: CopyTarget::GlobalId,
269            },
270            cx,
271            copy_feedback,
272            config,
273          )
274          .w_0()
275          .flex_1(),
276        ),
277    )
278}
279
280fn render_div_state(state: &DivInspectorState, config: &Config) -> Div {
281  div()
282    .flex()
283    .flex_col()
284    .gap_3()
285    .child(
286      section("Layout", config)
287        .child(render_geometry(state, config))
288        .child(property("Origin", state.bounds.origin.to_string(), config)),
289    )
290    .child(render_styles(&state.base_style, config))
291}
292
293fn render_styles(style: &StyleRefinement, config: &Config) -> Div {
294  let groups = style_groups(style);
295  let panel = section("Styles", config);
296
297  if groups.is_empty() {
298    panel.child(
299      div()
300        .text_sm()
301        .text_color(rgb(config.muted_text))
302        .child("No explicit style refinements."),
303    )
304  } else {
305    panel.children(
306      groups
307        .into_iter()
308        .map(|group| render_style_group(group, config)),
309    )
310  }
311}
312
313fn render_style_group(group: StyleGroup, config: &Config) -> Div {
314  div()
315    .flex()
316    .flex_col()
317    .gap_1()
318    .child(
319      div()
320        .pt_1()
321        .text_xs()
322        .font_weight(gpui::FontWeight::SEMIBOLD)
323        .text_color(rgb(config.accent))
324        .child(group.label),
325    )
326    .children(
327      group
328        .properties
329        .into_iter()
330        .map(|property| render_style_property(property, config)),
331    )
332}
333
334fn render_style_property(property: StyleProperty, config: &Config) -> Div {
335  div()
336    .py_1()
337    .flex()
338    .items_start()
339    .justify_between()
340    .gap_3()
341    .border_b_1()
342    .border_color(rgb(config.border))
343    .text_xs()
344    .child(
345      div()
346        .flex_shrink_0()
347        .text_color(rgb(config.muted_text))
348        .child(property.label),
349    )
350    .child(
351      div()
352        .w_0()
353        .flex_1()
354        .flex()
355        .items_center()
356        .justify_end()
357        .gap_2()
358        .when_some(property.swatch, |row, swatch| {
359          row.child(
360            div()
361              .size_3()
362              .flex_shrink_0()
363              .rounded_sm()
364              .border_1()
365              .border_color(rgb(config.border))
366              .bg(swatch),
367          )
368        })
369        .child(
370          div()
371            .w_0()
372            .flex_1()
373            .truncate()
374            .font_family("monospace")
375            .text_right()
376            .child(property.value),
377        ),
378    )
379}
380
381#[derive(Debug, PartialEq)]
382struct StyleGroup {
383  label: &'static str,
384  properties: Vec<StyleProperty>,
385}
386
387#[derive(Debug, PartialEq)]
388struct StyleProperty {
389  label: &'static str,
390  value: String,
391  swatch: Option<gpui::Fill>,
392}
393
394fn style_groups(style: &StyleRefinement) -> Vec<StyleGroup> {
395  let mut groups = Vec::new();
396
397  let mut layout = Vec::new();
398  push_debug(&mut layout, "Display", style.display.as_ref());
399  push_debug(&mut layout, "Visibility", style.visibility.as_ref());
400  push_debug(&mut layout, "Overflow X", style.overflow.x.as_ref());
401  push_debug(&mut layout, "Overflow Y", style.overflow.y.as_ref());
402  push_debug(
403    &mut layout,
404    "Scrollbar width",
405    style.scrollbar_width.as_ref(),
406  );
407  push_debug(
408    &mut layout,
409    "Concurrent scroll",
410    style.allow_concurrent_scroll.as_ref(),
411  );
412  push_debug(
413    &mut layout,
414    "Restrict scroll axis",
415    style.restrict_scroll_to_axis.as_ref(),
416  );
417  push_debug(&mut layout, "Position", style.position.as_ref());
418  push_debug(&mut layout, "Inset top", style.inset.top.as_ref());
419  push_debug(&mut layout, "Inset right", style.inset.right.as_ref());
420  push_debug(&mut layout, "Inset bottom", style.inset.bottom.as_ref());
421  push_debug(&mut layout, "Inset left", style.inset.left.as_ref());
422  push_debug(&mut layout, "Width", style.size.width.as_ref());
423  push_debug(&mut layout, "Height", style.size.height.as_ref());
424  push_debug(&mut layout, "Min width", style.min_size.width.as_ref());
425  push_debug(&mut layout, "Min height", style.min_size.height.as_ref());
426  push_debug(&mut layout, "Max width", style.max_size.width.as_ref());
427  push_debug(&mut layout, "Max height", style.max_size.height.as_ref());
428  push_debug(&mut layout, "Aspect ratio", style.aspect_ratio.as_ref());
429  push_debug(&mut layout, "Align items", style.align_items.as_ref());
430  push_debug(&mut layout, "Align self", style.align_self.as_ref());
431  push_debug(&mut layout, "Align content", style.align_content.as_ref());
432  push_debug(
433    &mut layout,
434    "Justify content",
435    style.justify_content.as_ref(),
436  );
437  push_debug(&mut layout, "Column gap", style.gap.width.as_ref());
438  push_debug(&mut layout, "Row gap", style.gap.height.as_ref());
439  push_debug(&mut layout, "Flex direction", style.flex_direction.as_ref());
440  push_debug(&mut layout, "Flex wrap", style.flex_wrap.as_ref());
441  push_debug(&mut layout, "Flex basis", style.flex_basis.as_ref());
442  push_debug(&mut layout, "Flex grow", style.flex_grow.as_ref());
443  push_debug(&mut layout, "Flex shrink", style.flex_shrink.as_ref());
444  push_debug(&mut layout, "Grid columns", style.grid_cols.as_ref());
445  push_debug(&mut layout, "Grid rows", style.grid_rows.as_ref());
446  push_debug(&mut layout, "Grid location", style.grid_location.as_ref());
447  push_group(&mut groups, "Layout", layout);
448
449  let mut spacing = Vec::new();
450  push_compact_sides(
451    &mut spacing,
452    "Margin",
453    [
454      ("Margin top", style.margin.top.as_ref()),
455      ("Margin right", style.margin.right.as_ref()),
456      ("Margin bottom", style.margin.bottom.as_ref()),
457      ("Margin left", style.margin.left.as_ref()),
458    ],
459  );
460  push_compact_sides(
461    &mut spacing,
462    "Padding",
463    [
464      ("Padding top", style.padding.top.as_ref()),
465      ("Padding right", style.padding.right.as_ref()),
466      ("Padding bottom", style.padding.bottom.as_ref()),
467      ("Padding left", style.padding.left.as_ref()),
468    ],
469  );
470  push_compact_sides(
471    &mut spacing,
472    "Border",
473    [
474      ("Border top", style.border_widths.top.as_ref()),
475      ("Border right", style.border_widths.right.as_ref()),
476      ("Border bottom", style.border_widths.bottom.as_ref()),
477      ("Border left", style.border_widths.left.as_ref()),
478    ],
479  );
480  push_group(&mut groups, "Spacing", spacing);
481
482  let mut appearance = Vec::new();
483  push_fill(&mut appearance, "Background", style.background.as_ref());
484  push_color(&mut appearance, "Border color", style.border_color.as_ref());
485  push_debug(&mut appearance, "Border style", style.border_style.as_ref());
486  push_compact_sides(
487    &mut appearance,
488    "Radius",
489    [
490      ("Radius top left", style.corner_radii.top_left.as_ref()),
491      ("Radius top right", style.corner_radii.top_right.as_ref()),
492      (
493        "Radius bottom right",
494        style.corner_radii.bottom_right.as_ref(),
495      ),
496      (
497        "Radius bottom left",
498        style.corner_radii.bottom_left.as_ref(),
499      ),
500    ],
501  );
502  push_debug(&mut appearance, "Box shadow", style.box_shadow.as_ref());
503  push_debug(&mut appearance, "Cursor", style.mouse_cursor.as_ref());
504  push_debug(&mut appearance, "Opacity", style.opacity.as_ref());
505  push_group(&mut groups, "Appearance", appearance);
506
507  if let Some(text) = style.text.explicit_refinement() {
508    let mut typography = Vec::new();
509    push_color(&mut typography, "Color", text.color.as_ref());
510    push_debug(&mut typography, "Font family", text.font_family.as_ref());
511    push_debug(
512      &mut typography,
513      "Font features",
514      text.font_features.as_ref(),
515    );
516    push_debug(
517      &mut typography,
518      "Font fallbacks",
519      text.font_fallbacks.as_ref(),
520    );
521    push_debug(&mut typography, "Font size", text.font_size.as_ref());
522    push_debug(&mut typography, "Line height", text.line_height.as_ref());
523    push_debug(&mut typography, "Font weight", text.font_weight.as_ref());
524    push_debug(&mut typography, "Font style", text.font_style.as_ref());
525    push_color(
526      &mut typography,
527      "Background",
528      text.background_color.as_ref(),
529    );
530    push_debug(&mut typography, "Underline", text.underline.as_ref());
531    push_debug(
532      &mut typography,
533      "Strikethrough",
534      text.strikethrough.as_ref(),
535    );
536    push_debug(&mut typography, "White space", text.white_space.as_ref());
537    push_debug(
538      &mut typography,
539      "Text overflow",
540      text.text_overflow.as_ref(),
541    );
542    push_debug(&mut typography, "Text align", text.text_align.as_ref());
543    push_debug(&mut typography, "Line clamp", text.line_clamp.as_ref());
544    push_group(&mut groups, "Typography", typography);
545  }
546
547  groups
548}
549
550trait ExplicitTextRefinement {
551  fn explicit_refinement(&self) -> Option<&gpui::TextStyleRefinement>;
552}
553
554impl ExplicitTextRefinement for gpui::TextStyleRefinement {
555  fn explicit_refinement(&self) -> Option<&gpui::TextStyleRefinement> {
556    self.is_some().then_some(self)
557  }
558}
559
560impl ExplicitTextRefinement for Option<gpui::TextStyleRefinement> {
561  fn explicit_refinement(&self) -> Option<&gpui::TextStyleRefinement> {
562    self.as_ref().filter(|text| text.is_some())
563  }
564}
565
566fn push_color(
567  properties: &mut Vec<StyleProperty>,
568  label: &'static str,
569  color: Option<&gpui::Hsla>,
570) {
571  if let Some(color) = color {
572    properties.push(StyleProperty {
573      label,
574      value: format_color(*color),
575      swatch: Some((*color).into()),
576    });
577  }
578}
579
580fn push_fill(properties: &mut Vec<StyleProperty>, label: &'static str, fill: Option<&gpui::Fill>) {
581  if let Some(fill) = fill {
582    properties.push(StyleProperty {
583      label,
584      value: "Color".into(),
585      swatch: Some(fill.clone()),
586    });
587  }
588}
589
590fn format_color(color: gpui::Hsla) -> String {
591  let rgba = color.to_rgb();
592  let [red, green, blue, alpha] = [rgba.r, rgba.g, rgba.b, rgba.a]
593    .map(|component| (component.clamp(0.0, 1.0) * 255.0).round() as u8);
594
595  if alpha == u8::MAX {
596    format!("#{red:02x}{green:02x}{blue:02x}")
597  } else {
598    format!("#{red:02x}{green:02x}{blue:02x}{alpha:02x}")
599  }
600}
601
602fn push_compact_sides<T: std::fmt::Debug + PartialEq>(
603  properties: &mut Vec<StyleProperty>,
604  label: &'static str,
605  sides: [(&'static str, Option<&T>); 4],
606) {
607  let [
608    (top_label, top),
609    (right_label, right),
610    (bottom_label, bottom),
611    (left_label, left),
612  ] = sides;
613
614  if let (Some(top), Some(right), Some(bottom), Some(left)) = (top, right, bottom, left) {
615    if top == right && top == bottom && top == left {
616      push_value(properties, label, format!("{top:?}"));
617      return;
618    }
619    if top == bottom && right == left {
620      push_value(properties, label, format!("{top:?} {right:?}"));
621      return;
622    }
623  }
624
625  push_debug(properties, top_label, top);
626  push_debug(properties, right_label, right);
627  push_debug(properties, bottom_label, bottom);
628  push_debug(properties, left_label, left);
629}
630
631fn push_value(properties: &mut Vec<StyleProperty>, label: &'static str, value: String) {
632  properties.push(StyleProperty {
633    label,
634    value,
635    swatch: None,
636  });
637}
638
639fn push_debug<T: std::fmt::Debug>(
640  properties: &mut Vec<StyleProperty>,
641  label: &'static str,
642  value: Option<&T>,
643) {
644  if let Some(value) = value {
645    properties.push(StyleProperty {
646      label,
647      value: format!("{value:?}"),
648      swatch: None,
649    });
650  }
651}
652
653fn push_group(groups: &mut Vec<StyleGroup>, label: &'static str, properties: Vec<StyleProperty>) {
654  if !properties.is_empty() {
655    groups.push(StyleGroup { label, properties });
656  }
657}
658
659fn render_geometry(state: &DivInspectorState, config: &Config) -> Div {
660  div()
661    .p_2()
662    .rounded_md()
663    .border_1()
664    .border_color(rgb(config.accent))
665    .bg(rgb(config.background))
666    .child(geometry_label(
667      "Element",
668      state.bounds.size.to_string(),
669      config,
670    ))
671    .child(
672      div()
673        .mt_2()
674        .p_3()
675        .rounded_md()
676        .border_1()
677        .border_color(rgb(config.border))
678        .bg(rgb(config.panel_background))
679        .child(geometry_label(
680          "Content",
681          state.content_size.to_string(),
682          config,
683        )),
684    )
685}
686
687fn geometry_label(label: &'static str, value: String, config: &Config) -> Div {
688  div()
689    .flex()
690    .items_center()
691    .justify_between()
692    .text_xs()
693    .child(div().text_color(rgb(config.muted_text)).child(label))
694    .child(div().font_family("monospace").child(value))
695}
696
697fn section(title: &'static str, config: &Config) -> Div {
698  div()
699    .p_3()
700    .flex()
701    .flex_col()
702    .gap_2()
703    .rounded_md()
704    .bg(rgb(config.panel_background))
705    .border_1()
706    .border_color(rgb(config.border))
707    .child(div().font_weight(gpui::FontWeight::SEMIBOLD).child(title))
708}
709
710fn property(label: &'static str, value: String, config: &Config) -> Div {
711  property_with_action(label, value, None, config)
712}
713
714#[derive(Clone, Copy, Debug, Eq, PartialEq)]
715enum CopyTarget {
716  Source,
717  GlobalId,
718}
719
720#[derive(Debug, Default)]
721struct CopyFeedback {
722  copied: Option<(CopyTarget, String)>,
723  generation: u64,
724}
725
726impl CopyFeedback {
727  fn is_copied(&self, target: CopyTarget, value: &str) -> bool {
728    self
729      .copied
730      .as_ref()
731      .is_some_and(|copied| copied.0 == target && copied.1 == value)
732  }
733
734  fn mark_copied(&mut self, target: CopyTarget, value: String) -> u64 {
735    self.generation = self.generation.wrapping_add(1);
736    self.copied = Some((target, value));
737    self.generation
738  }
739
740  fn clear(&mut self, generation: u64) -> bool {
741    if self.generation != generation {
742      return false;
743    }
744
745    self.copied = None;
746    true
747  }
748}
749
750struct CopyableProperty {
751  id: &'static str,
752  label: &'static str,
753  display_value: String,
754  copy_value: String,
755  target: CopyTarget,
756}
757
758fn copyable_property(
759  property: CopyableProperty,
760  cx: &mut Context<Inspector>,
761  copy_feedback: &Rc<RefCell<CopyFeedback>>,
762  config: &Config,
763) -> Div {
764  let CopyableProperty {
765    id,
766    label,
767    display_value,
768    copy_value,
769    target,
770  } = property;
771  let is_copied = copy_feedback.borrow().is_copied(target, &copy_value);
772  let copy_feedback = Rc::clone(copy_feedback);
773  let action = div()
774    .id(id)
775    .w(gpui::px(56.0))
776    .px_1()
777    .rounded_sm()
778    .cursor_pointer()
779    .text_center()
780    .text_xs()
781    .whitespace_nowrap()
782    .text_color(rgb(config.accent))
783    .hover(|button| button.bg(rgb(config.background)))
784    .child(if is_copied { "Copied!" } else { "Copy" })
785    .on_click(cx.listener(move |_inspector, _, window, cx| {
786      cx.write_to_clipboard(text_clipboard_item(copy_value.clone()));
787      let generation = copy_feedback
788        .borrow_mut()
789        .mark_copied(target, copy_value.clone());
790      window.refresh();
791
792      let copy_feedback = Rc::clone(&copy_feedback);
793      cx.spawn(async move |inspector, cx| {
794        cx.background_executor().timer(COPY_FEEDBACK_DURATION).await;
795        let cleared = copy_feedback.borrow_mut().clear(generation);
796        if cleared {
797          let _ = inspector.update(cx, |_, cx| cx.notify());
798        }
799      })
800      .detach();
801    }));
802
803  property_with_action(
804    label,
805    display_value,
806    Some(action.into_any_element()),
807    config,
808  )
809}
810
811fn text_clipboard_item(value: String) -> ClipboardItem {
812  ClipboardItem::new_string(value)
813}
814
815fn property_with_action(
816  label: &'static str,
817  value: String,
818  action: Option<gpui::AnyElement>,
819  config: &Config,
820) -> Div {
821  div()
822    .overflow_hidden()
823    .flex()
824    .flex_col()
825    .gap_1()
826    .child(
827      div()
828        .flex()
829        .items_center()
830        .justify_between()
831        .text_xs()
832        .text_color(rgb(config.muted_text))
833        .child(label)
834        .when_some(action, |label, action| label.child(action)),
835    )
836    .child(
837      div()
838        .w_full()
839        .truncate()
840        .text_sm()
841        .font_family("monospace")
842        .child(value),
843    )
844}
845
846fn truncate_middle(value: &str, max_chars: usize) -> String {
847  let chars = value.chars().collect::<Vec<_>>();
848  if chars.len() <= max_chars {
849    return value.to_owned();
850  }
851  if max_chars <= 1 {
852    return "…".chars().take(max_chars).collect();
853  }
854
855  let available = max_chars - 1;
856  let start = available.div_ceil(2);
857  let end = available - start;
858  format!(
859    "{}…{}",
860    chars[..start].iter().collect::<String>(),
861    chars[chars.len() - end..].iter().collect::<String>()
862  )
863}
864
865fn source_location(id: &InspectorElementId) -> String {
866  let location = id.path.source_location;
867  format!(
868    "{}:{}:{}",
869    compact_source_path(location.file()),
870    location.line(),
871    location.column()
872  )
873}
874
875fn compact_source_path(file: &str) -> String {
876  let components = std::path::Path::new(file)
877    .components()
878    .filter_map(|component| match component {
879      std::path::Component::Normal(component) => component.to_str(),
880      _ => None,
881    })
882    .collect::<Vec<_>>();
883  let start = components
884    .iter()
885    .rposition(|component| *component == "src")
886    .unwrap_or_else(|| components.len().saturating_sub(1));
887  components[start..].join("/")
888}
889
890const fn default_key_binding() -> &'static str {
891  if cfg!(target_os = "macos") {
892    DEFAULT_MACOS_KEY_BINDING
893  } else {
894    DEFAULT_OTHER_KEY_BINDING
895  }
896}
897
898#[cfg(test)]
899mod tests {
900  use super::*;
901
902  #[test]
903  fn config_can_disable_the_default_key_binding() {
904    assert_eq!(Config::default().key_binding(None).key_binding, None);
905  }
906
907  #[test]
908  fn default_key_binding_matches_the_platform() {
909    let expected = if cfg!(target_os = "macos") {
910      "cmd-alt-i"
911    } else {
912      "ctrl-alt-i"
913    };
914    assert_eq!(default_key_binding(), expected);
915  }
916
917  #[test]
918  fn empty_state_copy_matches_picker_state() {
919    assert_eq!(empty_state_copy(true).0, "Pick an element");
920    assert_eq!(empty_state_copy(false).0, "No element selected");
921  }
922
923  #[test]
924  fn equal_and_opposite_sides_are_compacted() {
925    let mut properties = Vec::new();
926    push_compact_sides(
927      &mut properties,
928      "Padding",
929      [
930        ("Padding top", Some(&1)),
931        ("Padding right", Some(&2)),
932        ("Padding bottom", Some(&1)),
933        ("Padding left", Some(&2)),
934      ],
935    );
936    assert_eq!(
937      properties,
938      vec![StyleProperty {
939        label: "Padding",
940        value: "1 2".into(),
941        swatch: None,
942      }]
943    );
944
945    properties.clear();
946    push_compact_sides(
947      &mut properties,
948      "Border",
949      [
950        ("Border top", Some(&1)),
951        ("Border right", Some(&1)),
952        ("Border bottom", Some(&1)),
953        ("Border left", Some(&1)),
954      ],
955    );
956    assert_eq!(properties[0].value, "1");
957  }
958
959  #[test]
960  fn style_groups_only_include_explicit_refinements() {
961    let mut style = StyleRefinement::default();
962    assert!(style_groups(&style).is_empty());
963
964    style.opacity = Some(0.5);
965    assert_eq!(
966      style_groups(&style),
967      vec![StyleGroup {
968        label: "Appearance",
969        properties: vec![StyleProperty {
970          label: "Opacity",
971          value: "0.5".into(),
972          swatch: None,
973        }],
974      }]
975    );
976  }
977
978  #[test]
979  fn copy_feedback_ignores_stale_timeouts() {
980    let mut feedback = CopyFeedback::default();
981    let first = feedback.mark_copied(CopyTarget::Source, "src/lib.rs:1:1".into());
982    let second = feedback.mark_copied(CopyTarget::GlobalId, "global-id".into());
983
984    assert!(!feedback.clear(first));
985    assert!(feedback.is_copied(CopyTarget::GlobalId, "global-id"));
986    assert!(feedback.clear(second));
987    assert!(!feedback.is_copied(CopyTarget::GlobalId, "global-id"));
988  }
989
990  #[test]
991  fn clipboard_items_preserve_the_full_value() {
992    let value = "view-123.long-global-id";
993    assert_eq!(
994      text_clipboard_item(value.into()).text(),
995      Some(value.to_owned())
996    );
997  }
998
999  #[test]
1000  fn colors_are_formatted_as_hex() {
1001    assert_eq!(format_color(gpui::white()), "#ffffff");
1002    assert_eq!(format_color(gpui::hsla(0.0, 1.0, 0.5, 0.5)), "#ff000080");
1003  }
1004
1005  #[test]
1006  fn long_values_are_truncated_in_the_middle() {
1007    assert_eq!(truncate_middle("short", 10), "short");
1008    assert_eq!(truncate_middle("abcdefghijkl", 7), "abc…jkl");
1009    assert_eq!(truncate_middle("abc", 1), "…");
1010  }
1011
1012  #[test]
1013  fn svg_icons_use_the_configured_color() {
1014    assert_eq!(
1015      recolor_svg(b"<svg stroke=\"currentColor\" />", 0x12abef),
1016      b"<svg stroke=\"#12abef\" />"
1017    );
1018  }
1019
1020  #[test]
1021  fn source_paths_are_compact() {
1022    assert_eq!(
1023      compact_source_path("/workspace/app/src/views/card.rs"),
1024      "src/views/card.rs"
1025    );
1026    assert_eq!(compact_source_path("main.rs"), "main.rs");
1027  }
1028}