Skip to main content

gpui_component/
inspector.rs

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