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
30pub(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 state: Entity<EditorState>,
67 error: Option<SharedString>,
69 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: StyleRefinement,
80 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 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 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}