Skip to main content

gpui_component/
inspector.rs

1use std::{cell::OnceCell, collections::HashMap, fmt::Write as _, rc::Rc, sync::OnceLock};
2
3use anyhow::Result;
4use gpui::{
5    AnyElement, App, AppContext, Context, DivInspectorState, Entity, Inspector, InspectorElementId,
6    InteractiveElement as _, IntoElement, KeyBinding, ParentElement as _, Refineable as _, Render,
7    SharedString, StyleRefinement, Styled, Subscription, Task, Window, actions, div,
8    inspector_reflection::FunctionReflection, prelude::FluentBuilder, px,
9};
10use lsp_types::{
11    CompletionItem, CompletionItemKind, CompletionResponse, CompletionTextEdit, Diagnostic,
12    DiagnosticSeverity, Position, TextEdit,
13};
14use ropey::Rope;
15
16use crate::{
17    ActiveTheme, IconName, Selectable, Sizable, TITLE_BAR_HEIGHT,
18    alert::Alert,
19    button::{Button, ButtonVariants},
20    clipboard::Clipboard,
21    description_list::DescriptionList,
22    h_flex,
23    input::{CompletionProvider, Editor, EditorState, InputEvent, RopeExt, TabSize},
24    link::Link,
25    v_flex,
26};
27
28actions!(inspector, [ToggleInspector]);
29
30/// Initialize the inspector and register the action to toggle it.
31pub(crate) fn init(cx: &mut App) {
32    cx.bind_keys(vec![
33        #[cfg(target_os = "macos")]
34        KeyBinding::new("cmd-alt-i", ToggleInspector, None),
35        #[cfg(not(target_os = "macos"))]
36        KeyBinding::new("ctrl-shift-i", ToggleInspector, None),
37    ]);
38
39    cx.on_action(|_: &ToggleInspector, cx| {
40        let Some(active_window) = cx.active_window() else {
41            return;
42        };
43
44        cx.defer(move |cx| {
45            _ = active_window.update(cx, |_, window, cx| {
46                window.toggle_inspector(cx);
47            });
48        });
49    });
50
51    let inspector_el = OnceCell::new();
52    cx.register_inspector_element(move |id, state: &DivInspectorState, window, cx| {
53        let el = inspector_el.get_or_init(|| cx.new(|cx| DivInspector::new(window, cx)));
54        el.update(cx, |this, cx| {
55            this.update_inspected_element(id, state.clone(), window, cx);
56            this.render(window, cx).into_any_element()
57        })
58    });
59
60    cx.set_inspector_renderer(Box::new(render_inspector));
61}
62
63struct InspectorEditor {
64    /// The input state for the editor.
65    state: Entity<EditorState>,
66    /// Error to display from parsing the input, or if serialization errors somehow occur.
67    error: Option<SharedString>,
68    /// Whether the editor is currently being edited.
69    editing: bool,
70}
71
72pub struct DivInspector {
73    inspector_id: Option<InspectorElementId>,
74    inspector_state: Option<DivInspectorState>,
75    rust_state: InspectorEditor,
76    json_state: InspectorEditor,
77    /// Initial style before any edits
78    initial_style: StyleRefinement,
79    /// Part of the initial style that could not be converted to Rust code
80    unconvertible_style: StyleRefinement,
81    _subscriptions: Vec<Subscription>,
82}
83
84impl DivInspector {
85    pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
86        let lsp_provider = Rc::new(LspProvider {});
87
88        let json_input_state = cx.new(|cx| {
89            EditorState::new(window, cx)
90                .language("json")
91                .line_number(false)
92        });
93
94        let rust_input_state = cx.new(|cx| {
95            EditorState::new(window, cx)
96                .language("rust")
97                .line_number(false)
98                .tab_size(TabSize {
99                    tab_size: 4,
100                    hard_tabs: false,
101                })
102        });
103        rust_input_state.update(cx, |state, cx| {
104            state.lsp_mut().completion_provider = Some(lsp_provider.clone());
105            cx.notify();
106        });
107
108        let _subscriptions = vec![
109            cx.subscribe_in(
110                &json_input_state,
111                window,
112                |this: &mut DivInspector, state, event: &InputEvent, window, cx| match event {
113                    InputEvent::Change => {
114                        let new_style = state.read(cx).value();
115                        this.edit_json(new_style.as_str(), window, cx);
116                    }
117                    _ => {}
118                },
119            ),
120            cx.subscribe_in(
121                &rust_input_state,
122                window,
123                |this: &mut DivInspector, state, event: &InputEvent, window, cx| match event {
124                    InputEvent::Change => {
125                        let new_style = state.read(cx).value();
126                        this.edit_rust(new_style.as_str(), window, cx);
127                    }
128                    _ => {}
129                },
130            ),
131        ];
132
133        let rust_state = InspectorEditor {
134            state: rust_input_state,
135            error: None,
136            editing: false,
137        };
138
139        let json_state = InspectorEditor {
140            state: json_input_state,
141            error: None,
142            editing: false,
143        };
144
145        Self {
146            inspector_id: None,
147            inspector_state: None,
148            rust_state,
149            json_state,
150            initial_style: Default::default(),
151            unconvertible_style: Default::default(),
152            _subscriptions,
153        }
154    }
155
156    pub fn update_inspected_element(
157        &mut self,
158        inspector_id: InspectorElementId,
159        state: DivInspectorState,
160        window: &mut Window,
161        cx: &mut Context<Self>,
162    ) {
163        // Skip updating if the inspector ID hasn't changed
164        if self.inspector_id.as_ref() == Some(&inspector_id) {
165            return;
166        }
167
168        let initial_style = state.base_style.as_ref();
169        self.initial_style = initial_style.clone();
170        self.json_state.editing = false;
171        self.update_json_from_style(initial_style, window, cx);
172        self.rust_state.editing = false;
173        let rust_style = self.update_rust_from_style(initial_style, window, cx);
174        self.unconvertible_style = initial_style.subtract(&rust_style);
175        self.inspector_id = Some(inspector_id);
176        self.inspector_state = Some(state);
177        cx.notify();
178    }
179
180    fn edit_json(&mut self, code: &str, window: &mut Window, cx: &mut Context<Self>) {
181        if !self.json_state.editing {
182            self.json_state.editing = true;
183            return;
184        }
185
186        match serde_json::from_str::<StyleRefinement>(code) {
187            Ok(new_style) => {
188                self.json_state.error = None;
189                self.rust_state.error = None;
190                self.rust_state.editing = false;
191                let rust_style = self.update_rust_from_style(&new_style, window, cx);
192                self.unconvertible_style = new_style.subtract(&rust_style);
193                self.update_element_style(new_style, window, cx);
194            }
195            Err(e) => {
196                self.json_state.error = Some(e.to_string().trim_end().to_string().into());
197                window.refresh();
198            }
199        }
200    }
201
202    fn edit_rust(&mut self, code: &str, window: &mut Window, cx: &mut Context<Self>) {
203        if !self.rust_state.editing {
204            self.rust_state.editing = true;
205            return;
206        }
207
208        let (new_style, diagnostics) = rust_to_style(self.unconvertible_style.clone(), code);
209        self.rust_state.state.update(cx, |state, cx| {
210            if let Some(set) = state.diagnostics_mut() {
211                set.clear();
212                set.extend(diagnostics);
213            }
214            cx.notify();
215        });
216        self.json_state.error = None;
217        self.json_state.editing = false;
218        self.update_json_from_style(&new_style, window, cx);
219        self.update_element_style(new_style, window, cx);
220    }
221
222    fn update_element_style(
223        &self,
224        style: StyleRefinement,
225        window: &mut Window,
226        cx: &mut Context<Self>,
227    ) {
228        window.with_inspector_state::<DivInspectorState, _>(
229            self.inspector_id.as_ref(),
230            cx,
231            |state, _window| {
232                if let Some(state) = state {
233                    *state.base_style = style;
234                }
235            },
236        );
237        window.refresh();
238    }
239
240    fn reset_style(&mut self, window: &mut Window, cx: &mut Context<Self>) {
241        self.rust_state.editing = false;
242        let rust_style = self.update_rust_from_style(&self.initial_style, window, cx);
243        self.unconvertible_style = self.initial_style.subtract(&rust_style);
244        self.json_state.editing = false;
245        self.update_json_from_style(&self.initial_style, window, cx);
246        if let Some(state) = self.inspector_state.as_mut() {
247            *state.base_style = self.initial_style.clone();
248        }
249    }
250
251    fn update_json_from_style(
252        &self,
253        style: &StyleRefinement,
254        window: &mut Window,
255        cx: &mut Context<Self>,
256    ) {
257        self.json_state.state.update(cx, |state, cx| {
258            state.set_value(style_to_json(style), window, cx);
259        });
260    }
261
262    fn update_rust_from_style(
263        &self,
264        style: &StyleRefinement,
265        window: &mut Window,
266        cx: &mut Context<Self>,
267    ) -> StyleRefinement {
268        self.rust_state.state.update(cx, |state, cx| {
269            let (rust_code, rust_style) = style_to_rust(style);
270            state.set_value(rust_code, window, cx);
271            rust_style
272        })
273    }
274}
275
276fn style_to_json(style: &StyleRefinement) -> String {
277    serde_json::to_string_pretty(style).unwrap_or_else(|e| format!("{{ \"error\": \"{}\" }}", e))
278}
279
280struct StyleMethods {
281    table: Vec<(Box<StyleRefinement>, FunctionReflection<StyleRefinement>)>,
282    map: HashMap<&'static str, FunctionReflection<StyleRefinement>>,
283}
284
285impl StyleMethods {
286    fn get() -> &'static Self {
287        static STYLE_METHODS: OnceLock<StyleMethods> = OnceLock::new();
288        STYLE_METHODS.get_or_init(|| {
289            let table: Vec<_> = [
290                gpui_base::styled_ext_reflection_methods::<StyleRefinement>(),
291                gpui::styled_reflection::methods::<StyleRefinement>(),
292            ]
293            .into_iter()
294            .flatten()
295            .map(|method| (Box::new(method.invoke(StyleRefinement::default())), method))
296            .collect();
297            let map = table
298                .iter()
299                .map(|(_, method)| (method.name, method.clone()))
300                .collect();
301
302            Self { table, map }
303        })
304    }
305}
306
307fn style_to_rust(input_style: &StyleRefinement) -> (String, StyleRefinement) {
308    let methods: Vec<_> = StyleMethods::get()
309        .table
310        .iter()
311        .filter_map(|(style, method)| {
312            if input_style.is_superset_of(style) {
313                Some(method)
314            } else {
315                None
316            }
317        })
318        .collect();
319    let mut code = "fn build() -> Div {\n    div()\n".to_string();
320    let mut style = StyleRefinement::default();
321    for method in methods {
322        let before_invoke = style.clone();
323        style = method.invoke(style);
324        if style != before_invoke {
325            _ = write!(code, "        .{}()\n", method.name);
326        }
327    }
328    code.push_str("}");
329    (code, style)
330}
331
332fn rust_to_style(mut style: StyleRefinement, source: &str) -> (StyleRefinement, Vec<Diagnostic>) {
333    let rope = Rope::from(source);
334    let Some(begin) = source.find("div()").map(|i| i + "div()".len()) else {
335        let start_pos = Position::new(0, 0);
336        let end_pos = rope.offset_to_position(rope.len());
337
338        return (
339            style,
340            vec![Diagnostic {
341                range: lsp_types::Range::new(start_pos, end_pos),
342                severity: Some(DiagnosticSeverity::ERROR),
343                message: "expected `div()`".into(),
344                ..Default::default()
345            }],
346        );
347    };
348
349    let mut methods = vec![];
350    let mut offset = 0;
351    let mut method_offset = 0;
352    let mut method = String::new();
353    for line in rope.iter_lines() {
354        if line.to_string().trim().starts_with("//") {
355            offset += line.len() + 1;
356            continue;
357        }
358
359        for c in line.chars() {
360            offset += c.len_utf8();
361            if offset < begin {
362                continue;
363            }
364
365            if c.is_ascii_alphanumeric() || c == '_' {
366                method.push(c);
367                method_offset = offset;
368            } else {
369                if !method.is_empty() {
370                    methods.push((method_offset, method.clone()));
371                }
372                method.clear();
373            }
374        }
375
376        // +1 \n
377        offset += 1;
378    }
379
380    let mut diagnostics = vec![];
381    let style_methods = StyleMethods::get();
382
383    for (offset, method) in methods {
384        match style_methods.map.get(method.as_str()) {
385            Some(method_reflection) => style = method_reflection.invoke(style),
386            None => {
387                let message = format!("unknown method `{}`", method);
388                let start = rope.offset_to_position(offset.saturating_sub(method.len()));
389                let end = rope.offset_to_position(offset);
390                let diagnostic = lsp_types::Diagnostic {
391                    range: lsp_types::Range::new(start, end),
392                    severity: Some(DiagnosticSeverity::ERROR),
393                    message,
394                    ..Default::default()
395                };
396
397                diagnostics.push(diagnostic);
398            }
399        }
400    }
401
402    (style, diagnostics)
403}
404
405impl Render for DivInspector {
406    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
407        v_flex().size_full().gap_y_4().text_sm().when_some(
408            self.inspector_state.as_ref(),
409            |this, state| {
410                this.child(
411                    DescriptionList::new()
412                        .columns(1)
413                        .label_width(px(110.))
414                        .bordered(false)
415                        .item("Origin", format!("{}", state.bounds.origin), 1)
416                        .item("Size", format!("{}", state.bounds.size), 1)
417                        .item("Content Size", format!("{}", state.content_size), 1),
418                )
419                .child(
420                    v_flex()
421                        .flex_1()
422                        .h_2_5()
423                        .gap_y_3()
424                        .child(
425                            h_flex()
426                                .justify_between()
427                                .gap_x_2()
428                                .child("Rust Styles")
429                                .child(Button::new("rust-reset").label("Reset").small().on_click(
430                                    cx.listener(|this, _, window, cx| {
431                                        this.reset_style(window, cx);
432                                    }),
433                                )),
434                        )
435                        .child(
436                            v_flex()
437                                .flex_1()
438                                .gap_y_1()
439                                .font_family(cx.theme().mono_font_family.clone())
440                                .text_size(cx.theme().mono_font_size)
441                                .child(Editor::new(&self.rust_state.state).h(gpui::relative(1.)))
442                                .when_some(self.rust_state.error.clone(), |this, err| {
443                                    this.child(Alert::error("rust-error", err).text_xs())
444                                }),
445                        ),
446                )
447                .child(
448                    v_flex()
449                        .flex_1()
450                        .gap_y_3()
451                        .h_2_5()
452                        .flex_shrink_0()
453                        .child(
454                            h_flex()
455                                .gap_x_2()
456                                .child(div().flex_1().child("JSON Styles"))
457                                .child(Button::new("json-reset").label("Reset").small().on_click(
458                                    cx.listener(|this, _, window, cx| {
459                                        this.reset_style(window, cx);
460                                    }),
461                                )),
462                        )
463                        .child(
464                            v_flex()
465                                .flex_1()
466                                .gap_y_1()
467                                .font_family(cx.theme().mono_font_family.clone())
468                                .text_size(cx.theme().mono_font_size)
469                                .child(Editor::new(&self.json_state.state).h(gpui::relative(1.)))
470                                .when_some(self.json_state.error.clone(), |this, err| {
471                                    this.child(Alert::error("json-error", err).text_xs())
472                                }),
473                        ),
474                )
475            },
476        )
477    }
478}
479
480fn render_inspector(
481    inspector: &mut Inspector,
482    window: &mut Window,
483    cx: &mut Context<Inspector>,
484) -> AnyElement {
485    let inspector_element_id = inspector.active_element_id();
486    let source_location =
487        inspector_element_id.map(|id| SharedString::new(format!("{}", id.path.source_location)));
488    let element_global_id = inspector_element_id.map(|id| format!("{}", id.path.global_id));
489
490    v_flex()
491        .id("inspector")
492        .font_family(cx.theme().font_family.clone())
493        .size_full()
494        .bg(cx.theme().tokens.background)
495        .border_l_1()
496        .border_color(cx.theme().border)
497        .text_color(cx.theme().foreground)
498        .child(
499            h_flex()
500                .w_full()
501                .justify_between()
502                .gap_2()
503                .h(TITLE_BAR_HEIGHT)
504                .line_height(TITLE_BAR_HEIGHT)
505                .overflow_x_hidden()
506                .px_2()
507                .border_b_1()
508                .border_color(cx.theme().title_bar_border)
509                .bg(cx.theme().tokens.title_bar)
510                .child(
511                    h_flex()
512                        .gap_2()
513                        .text_sm()
514                        .child(
515                            Button::new("inspect")
516                                .icon(IconName::Inspector)
517                                .selected(inspector.is_picking())
518                                .toggled(inspector.is_picking())
519                                .small()
520                                .ghost()
521                                .on_click(cx.listener(|this, _, window, _| {
522                                    this.start_picking();
523                                    window.refresh();
524                                })),
525                        )
526                        .child("Inspector"),
527                )
528                .child(
529                    Button::new("close")
530                        .icon(IconName::Close)
531                        .small()
532                        .ghost()
533                        .on_click(|_, window, cx| {
534                            window.dispatch_action(Box::new(ToggleInspector), cx);
535                        }),
536                ),
537        )
538        .child(
539            v_flex()
540                .flex_1()
541                .p_3()
542                .gap_y_3()
543                .text_sm()
544                .when_some(source_location, |this, source_location| {
545                    this.child(
546                        h_flex()
547                            .gap_x_2()
548                            .text_sm()
549                            .child(
550                                Link::new("source-location")
551                                    .href(format!("file://{}", source_location))
552                                    .child(source_location.clone())
553                                    .flex_1()
554                                    .overflow_x_hidden(),
555                            )
556                            .child(Clipboard::new("copy-source-location").value(source_location)),
557                    )
558                })
559                .children(element_global_id)
560                .children(inspector.render_inspector_states(window, cx)),
561        )
562        .into_any_element()
563}
564
565struct LspProvider {}
566
567impl CompletionProvider for LspProvider {
568    fn completions(
569        &self,
570        rope: &ropey::Rope,
571        offset: usize,
572        _: lsp_types::CompletionContext,
573        _: &mut Window,
574        cx: &mut App,
575    ) -> Task<Result<CompletionResponse>> {
576        let mut left_offset = 0;
577        while left_offset < 100 {
578            match rope.char_at(offset.saturating_sub(left_offset)) {
579                Some('.') => {
580                    break;
581                }
582                None => break,
583                _ => {}
584            }
585            left_offset += 1;
586        }
587        let start = offset.saturating_sub(left_offset);
588        let trigger_character = rope.slice(start..offset).to_string();
589        if !trigger_character.starts_with('.') {
590            return Task::ready(Ok(CompletionResponse::Array(vec![])));
591        }
592
593        let start_pos = rope.offset_to_position(start);
594        let end_pos = rope.offset_to_position(offset);
595
596        cx.background_spawn(async move {
597            let styles = StyleMethods::get()
598                .map
599                .iter()
600                .filter_map(|(name, method)| {
601                    let prefix = &trigger_character[1..];
602                    if name.starts_with(&prefix) {
603                        Some(CompletionItem {
604                            label: name.to_string(),
605                            filter_text: Some(prefix.to_string()),
606                            kind: Some(CompletionItemKind::METHOD),
607                            detail: Some("()".to_string()),
608                            documentation: method
609                                .documentation
610                                .as_ref()
611                                .map(|doc| lsp_types::Documentation::String(doc.to_string())),
612                            text_edit: Some(CompletionTextEdit::Edit(TextEdit {
613                                range: lsp_types::Range {
614                                    start: start_pos,
615                                    end: end_pos,
616                                },
617                                new_text: format!(".{}()", name),
618                            })),
619                            ..Default::default()
620                        })
621                    } else {
622                        None
623                    }
624                })
625                .collect::<Vec<_>>();
626
627            Ok(CompletionResponse::Array(styles))
628        })
629    }
630
631    fn is_completion_trigger(&self, _: usize, _: &str, _: &mut App) -> bool {
632        true
633    }
634}
635
636#[cfg(test)]
637mod tests {
638    use gpui::{AbsoluteLength, DefiniteLength, Length, rems};
639    use indoc::indoc;
640    use lsp_types::Position;
641
642    #[test]
643    fn test_rust_to_style() {
644        let (style, diagnostics) = super::rust_to_style(
645            Default::default(),
646            indoc! {r#"
647            fn build() -> Div {
648                div()
649                    .p_1()
650                    // This is a comment
651                    .mx_2()
652            }
653            "#},
654        );
655        assert_eq!(diagnostics, vec![]);
656        assert_eq!(
657            style.padding.left,
658            Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.25))))
659        );
660        assert_eq!(
661            style.margin.left,
662            Some(Length::Definite(DefiniteLength::Absolute(
663                AbsoluteLength::Rems(rems(0.5))
664            )))
665        );
666
667        let (_, diagnostics) = super::rust_to_style(
668            Default::default(),
669            indoc! {r#"
670            fn build() -> Div {
671                div()
672                    .p_1()
673                    // This is a comment
674                    .unknown_method
675                    .bad_method()
676            }
677            "#},
678        );
679
680        assert_eq!(diagnostics.len(), 2);
681        assert_eq!(diagnostics[0].message, "unknown method `unknown_method`");
682        assert_eq!(diagnostics[0].range.start, Position::new(4, 9));
683        assert_eq!(diagnostics[0].range.end, Position::new(4, 23));
684        assert_eq!(diagnostics[1].message, "unknown method `bad_method`");
685        assert_eq!(diagnostics[1].range.start, Position::new(5, 9));
686        assert_eq!(diagnostics[1].range.end, Position::new(5, 19));
687    }
688}