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, ©_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, ©_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(©_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}