gpui_component/
inspector.rs

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