1use gpui::{
2 App, Context, Div, DivInspectorState, Inspector, InspectorElementId, IntoElement, KeyBinding,
3 Window, actions, div, prelude::*, rgb,
4};
5
6const DEFAULT_MACOS_KEY_BINDING: &str = "cmd-alt-i";
7const DEFAULT_OTHER_KEY_BINDING: &str = "ctrl-alt-i";
8
9actions!(gpui_devtools, [ToggleInspector]);
10
11#[derive(Clone, Debug)]
12pub struct Config {
13 pub key_binding: Option<&'static str>,
14 pub background: u32,
15 pub panel_background: u32,
16 pub border: u32,
17 pub text: u32,
18 pub muted_text: u32,
19 pub accent: u32,
20}
21
22impl Default for Config {
23 fn default() -> Self {
24 Self {
25 key_binding: Some(default_key_binding()),
26 background: 0x111318,
27 panel_background: 0x191c22,
28 border: 0x30343d,
29 text: 0xe6e9ef,
30 muted_text: 0x9299a8,
31 accent: 0x61afef,
32 }
33 }
34}
35
36impl Config {
37 pub fn key_binding(mut self, key_binding: Option<&'static str>) -> Self {
38 self.key_binding = key_binding;
39 self
40 }
41}
42
43pub fn init(cx: &mut App) {
44 init_with(Config::default(), cx);
45}
46
47pub fn init_with(config: Config, cx: &mut App) {
48 if let Some(key_binding) = config.key_binding {
49 cx.bind_keys([KeyBinding::new(key_binding, ToggleInspector, None)]);
50 }
51
52 cx.on_action(|_: &ToggleInspector, cx| toggle_active_window(cx));
53
54 let div_config = config.clone();
55 cx.register_inspector_element(move |_id, state: &DivInspectorState, _window, _cx| {
56 render_div_state(state, &div_config)
57 });
58
59 cx.set_inspector_renderer(Box::new(move |inspector, window, cx| {
60 render_inspector(inspector, window, cx, &config).into_any_element()
61 }));
62}
63
64pub fn toggle_active_window(cx: &mut App) {
65 let Some(active_window) = cx.active_window() else {
66 return;
67 };
68
69 cx.defer(move |cx| {
70 let _ = active_window.update(cx, |_, window, cx| window.toggle_inspector(cx));
71 });
72}
73
74fn render_inspector(
75 inspector: &mut Inspector,
76 window: &mut Window,
77 cx: &mut Context<Inspector>,
78 config: &Config,
79) -> Div {
80 let active_element = inspector.active_element_id().cloned();
81
82 div()
83 .size_full()
84 .flex()
85 .flex_col()
86 .bg(rgb(config.background))
87 .text_color(rgb(config.text))
88 .border_l_1()
89 .border_color(rgb(config.border))
90 .child(
91 div()
92 .h_12()
93 .px_3()
94 .flex()
95 .items_center()
96 .justify_between()
97 .border_b_1()
98 .border_color(rgb(config.border))
99 .child(
100 div()
101 .font_weight(gpui::FontWeight::SEMIBOLD)
102 .child("GPUI DevTools"),
103 )
104 .child(
105 div()
106 .id("gpui-devtools-pick")
107 .px_2()
108 .py_1()
109 .rounded_md()
110 .cursor_pointer()
111 .bg(if inspector.is_picking() {
112 rgb(config.accent)
113 } else {
114 rgb(config.panel_background)
115 })
116 .child(if inspector.is_picking() {
117 "Picking"
118 } else {
119 "Pick"
120 })
121 .on_click(cx.listener(|inspector, _, window, _cx| {
122 inspector.start_picking();
123 window.refresh();
124 })),
125 ),
126 )
127 .child(
128 div()
129 .id("gpui-devtools-content")
130 .flex_1()
131 .overflow_y_scroll()
132 .p_3()
133 .flex()
134 .flex_col()
135 .gap_3()
136 .when_some(active_element, |panel, id| {
137 panel.child(render_element_id(&id, config))
138 })
139 .children(inspector.render_inspector_states(window, cx)),
140 )
141}
142
143fn render_element_id(id: &InspectorElementId, config: &Config) -> Div {
144 let location = source_location(id);
145
146 section("Element", config)
147 .child(property("Source", location, config))
148 .child(property("Instance", id.instance_id.to_string(), config))
149 .child(property("Global ID", id.path.global_id.to_string(), config))
150}
151
152fn render_div_state(state: &DivInspectorState, config: &Config) -> Div {
153 section("Layout", config)
154 .child(property("Origin", state.bounds.origin.to_string(), config))
155 .child(property("Size", state.bounds.size.to_string(), config))
156 .child(property("Content", state.content_size.to_string(), config))
157 .child(property(
158 "Style refinement",
159 format!("{:#?}", state.base_style),
160 config,
161 ))
162}
163
164fn section(title: &'static str, config: &Config) -> Div {
165 div()
166 .p_3()
167 .flex()
168 .flex_col()
169 .gap_2()
170 .rounded_md()
171 .bg(rgb(config.panel_background))
172 .border_1()
173 .border_color(rgb(config.border))
174 .child(div().font_weight(gpui::FontWeight::SEMIBOLD).child(title))
175}
176
177fn property(label: &'static str, value: String, config: &Config) -> Div {
178 div()
179 .flex()
180 .flex_col()
181 .gap_1()
182 .child(
183 div()
184 .text_xs()
185 .text_color(rgb(config.muted_text))
186 .child(label),
187 )
188 .child(div().text_sm().font_family("monospace").child(value))
189}
190
191fn source_location(id: &InspectorElementId) -> String {
192 let location = id.path.source_location;
193 format!(
194 "{}:{}:{}",
195 location.file(),
196 location.line(),
197 location.column()
198 )
199}
200
201const fn default_key_binding() -> &'static str {
202 if cfg!(target_os = "macos") {
203 DEFAULT_MACOS_KEY_BINDING
204 } else {
205 DEFAULT_OTHER_KEY_BINDING
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn config_can_disable_the_default_key_binding() {
215 assert_eq!(Config::default().key_binding(None).key_binding, None);
216 }
217
218 #[test]
219 fn default_key_binding_matches_the_platform() {
220 let expected = if cfg!(target_os = "macos") {
221 "cmd-alt-i"
222 } else {
223 "ctrl-alt-i"
224 };
225 assert_eq!(default_key_binding(), expected);
226 }
227}