inspector: Add live editor to inspector (#1205)

Inspired by Zed, this PR implements a simple live Rust and JSON style
editor for the inspector.

The rust style editor only supports `Styled` and `StyledExt` method
calls with no arguments.




https://github.com/user-attachments/assets/df7f1746-92d7-416c-afd5-e389ac3ea9ae

---------

Co-authored-by: Floyd Wang <gassnake999@gmail.com>
This commit is contained in:
Chuqiao Feng 2025-09-05 11:58:36 +08:00 committed by GitHub
parent 16f9380a63
commit 8992852165
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 383 additions and 41 deletions

1
Cargo.lock generated
View file

@ -3149,6 +3149,7 @@ dependencies = [
"enum-iterator", "enum-iterator",
"gpui", "gpui",
"gpui-component-macros", "gpui-component-macros",
"gpui_macros",
"html5ever 0.27.0", "html5ever 0.27.0",
"indoc", "indoc",
"itertools 0.13.0", "itertools 0.13.0",

View file

@ -16,6 +16,7 @@ resolver = "2"
[workspace.dependencies] [workspace.dependencies]
gpui = { git = "https://github.com/zed-industries/zed.git" } gpui = { git = "https://github.com/zed-industries/zed.git" }
gpui_macros = { git = "https://github.com/zed-industries/zed.git" }
reqwest_client = { git = "https://github.com/zed-industries/zed.git" } reqwest_client = { git = "https://github.com/zed-industries/zed.git" }
sum_tree = { git = "https://github.com/zed-industries/zed.git" } sum_tree = { git = "https://github.com/zed-industries/zed.git" }
gpui-component = { path = "crates/ui" } gpui-component = { path = "crates/ui" }

View file

@ -13,7 +13,7 @@ doctest = false
[features] [features]
decimal = ["dep:rust_decimal"] decimal = ["dep:rust_decimal"]
inspector = [] inspector = ["gpui/inspector"]
webview = ["dep:wry"] webview = ["dep:wry"]
# For syntax highlighting in Markdown and CodeEditor. # For syntax highlighting in Markdown and CodeEditor.
tree-sitter-languages = [ tree-sitter-languages = [
@ -50,6 +50,7 @@ tree-sitter-languages = [
[dependencies] [dependencies]
gpui.workspace = true gpui.workspace = true
sum_tree.workspace = true sum_tree.workspace = true
gpui_macros.workspace = true
gpui-component-macros.workspace = true gpui-component-macros.workspace = true
rust-i18n.workspace = true rust-i18n.workspace = true
schemars.workspace = true schemars.workspace = true

View file

@ -1,17 +1,20 @@
use std::cell::OnceCell; use std::{cell::OnceCell, collections::HashMap, fmt::Write as _, sync::OnceLock};
use gpui::{ use gpui::{
actions, div, prelude::FluentBuilder, px, AnyElement, App, AppContext as _, Context, actions, div, inspector_reflection::FunctionReflection, prelude::FluentBuilder, px, AnyElement,
DivInspectorState, Entity, Inspector, InspectorElementId, InteractiveElement as _, IntoElement, App, AppContext, Context, DivInspectorState, Entity, Inspector, InspectorElementId,
KeyBinding, ParentElement as _, Render, SharedString, Styled, Window, InteractiveElement as _, IntoElement, KeyBinding, ParentElement as _, Refineable as _, Render,
SharedString, StyleRefinement, Styled, Subscription, Window,
}; };
use crate::{ use crate::{
alert::Alert,
button::{Button, ButtonVariants}, button::{Button, ButtonVariants},
clipboard::Clipboard, clipboard::Clipboard,
description_list::DescriptionList, description_list::DescriptionList,
dropdown::{Dropdown, DropdownState, SearchableVec},
h_flex, h_flex,
input::{InputState, TextInput}, input::{InputEvent, InputState, TabSize, TextInput},
link::Link, link::Link,
v_flex, ActiveTheme, IconName, Selectable, Sizable, TITLE_BAR_HEIGHT, v_flex, ActiveTheme, IconName, Selectable, Sizable, TITLE_BAR_HEIGHT,
}; };
@ -43,7 +46,7 @@ pub fn init(cx: &mut App) {
cx.register_inspector_element(move |id, state: &DivInspectorState, window, cx| { cx.register_inspector_element(move |id, state: &DivInspectorState, window, cx| {
let el = inspector_el.get_or_init(|| cx.new(|cx| DivInspector::new(window, cx))); let el = inspector_el.get_or_init(|| cx.new(|cx| DivInspector::new(window, cx)));
el.update(cx, |this, cx| { el.update(cx, |this, cx| {
this.set_inspector_state(id, state.clone(), cx); this.update_inspected_element(id, state.clone(), window, cx);
this.render(window, cx).into_any_element() this.render(window, cx).into_any_element()
}) })
}); });
@ -51,59 +54,340 @@ pub fn init(cx: &mut App) {
cx.set_inspector_renderer(Box::new(render_inspector)); cx.set_inspector_renderer(Box::new(render_inspector));
} }
struct EditorState {
/// The input state for the editor.
state: Entity<InputState>,
/// Error to display from parsing the input, or if serialization errors somehow occur.
error: Option<SharedString>,
/// Whether the editor is currently being edited.
editing: bool,
}
pub struct DivInspector { pub struct DivInspector {
inspector_id: Option<InspectorElementId>, inspector_id: Option<InspectorElementId>,
inspector_state: Option<DivInspectorState>, inspector_state: Option<DivInspectorState>,
input_state: Entity<InputState>, rust_dropdown: Entity<DropdownState<SearchableVec<SharedString>>>,
rust_state: EditorState,
json_state: EditorState,
/// Initial style before any edits
initial_style: StyleRefinement,
/// Part of the initial style that could not be converted to Rust code
unconvertible_style: StyleRefinement,
_subscriptions: Vec<Subscription>,
} }
impl DivInspector { impl DivInspector {
pub fn new(window: &mut Window, cx: &mut App) -> Self { pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input_state = cx.new(|cx| { let json_input_state = cx.new(|cx| {
InputState::new(window, cx) InputState::new(window, cx)
.code_editor("json") .code_editor("json")
.line_number(false) .line_number(false)
.disabled(true)
}); });
let rust_input_state = cx.new(|cx| {
InputState::new(window, cx)
.code_editor("rust")
.line_number(false)
.tab_size(TabSize {
tab_size: 4,
hard_tabs: false,
})
});
let rust_dropdown = cx.new(|cx| {
DropdownState::new(
SearchableVec::new({
let mut methods: Vec<_> = StyleMethods::get()
.table
.iter()
.map(|(_, method)| method.name.into())
.collect();
methods.sort();
methods
}),
None,
window,
cx,
)
});
let _subscriptions = vec![
cx.subscribe_in(
&json_input_state,
window,
|this: &mut DivInspector, _, event: &InputEvent, window, cx| match event {
InputEvent::Change(new_style) => {
this.edit_json(new_style, window, cx);
}
_ => {}
},
),
cx.subscribe_in(
&rust_input_state,
window,
|this: &mut DivInspector, _, event: &InputEvent, window, cx| match event {
InputEvent::Change(new_style) => {
this.edit_rust(new_style, window, cx);
}
_ => {}
},
),
];
let rust_state = EditorState {
state: rust_input_state,
error: None,
editing: false,
};
let json_state = EditorState {
state: json_input_state,
error: None,
editing: false,
};
Self { Self {
inspector_id: None, inspector_id: None,
inspector_state: None, inspector_state: None,
input_state, rust_dropdown,
rust_state,
json_state,
initial_style: Default::default(),
unconvertible_style: Default::default(),
_subscriptions,
} }
} }
pub fn set_inspector_state( pub fn update_inspected_element(
&mut self, &mut self,
inspector_id: InspectorElementId, inspector_id: InspectorElementId,
state: DivInspectorState, state: DivInspectorState,
window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
// Skip updating if the inspector ID hasn't changed
if self.inspector_id.as_ref() == Some(&inspector_id) {
return;
}
let initial_style = state.base_style.as_ref();
self.initial_style = initial_style.clone();
self.json_state.editing = false;
self.update_json_from_style(initial_style, window, cx);
self.rust_state.editing = false;
let rust_style = self.update_rust_from_style(initial_style, window, cx);
self.unconvertible_style = initial_style.subtract(&rust_style);
self.inspector_id = Some(inspector_id); self.inspector_id = Some(inspector_id);
self.inspector_state = Some(state); self.inspector_state = Some(state);
cx.notify(); cx.notify();
} }
fn edit_json(&mut self, code: &str, window: &mut Window, cx: &mut Context<Self>) {
if !self.json_state.editing {
self.json_state.editing = true;
return;
}
match serde_json::from_str::<StyleRefinement>(code) {
Ok(new_style) => {
self.json_state.error = None;
self.rust_state.error = None;
self.rust_state.editing = false;
let rust_style = self.update_rust_from_style(&new_style, window, cx);
self.unconvertible_style = new_style.subtract(&rust_style);
self.update_element_style(new_style, window, cx);
}
Err(e) => {
let e = format!("{}", e);
self.json_state.error = Some(e.into());
window.refresh();
}
}
}
fn edit_rust(&mut self, code: &str, window: &mut Window, cx: &mut Context<Self>) {
if !self.rust_state.editing {
self.rust_state.editing = true;
return;
}
let (new_style, err) = rust_to_style(self.unconvertible_style.clone(), code);
self.rust_state.error = err;
self.json_state.error = None;
self.json_state.editing = false;
self.update_json_from_style(&new_style, window, cx);
self.update_element_style(new_style, window, cx);
}
fn update_element_style(
&self,
style: StyleRefinement,
window: &mut Window,
cx: &mut Context<Self>,
) {
window.with_inspector_state::<DivInspectorState, _>(
self.inspector_id.as_ref(),
cx,
|state, _window| {
if let Some(state) = state {
*state.base_style = style;
}
},
);
window.refresh();
}
fn reset_style(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.rust_state.editing = false;
let rust_style = self.update_rust_from_style(&self.initial_style, window, cx);
self.unconvertible_style = self.initial_style.subtract(&rust_style);
self.json_state.editing = false;
self.update_json_from_style(&self.initial_style, window, cx);
if let Some(state) = self.inspector_state.as_mut() {
*state.base_style = self.initial_style.clone();
}
}
fn update_json_from_style(
&self,
style: &StyleRefinement,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.json_state.state.update(cx, |state, cx| {
state.set_value(style_to_json(style), window, cx);
});
}
fn update_rust_from_style(
&self,
style: &StyleRefinement,
window: &mut Window,
cx: &mut Context<Self>,
) -> StyleRefinement {
self.rust_state.state.update(cx, |state, cx| {
let (rust_code, rust_style) = style_to_rust(style);
state.set_value(rust_code, window, cx);
rust_style
})
}
fn rust_add_style(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(method) = self.rust_dropdown.read(cx).selected_value() {
let code = self.rust_state.state.read(cx).value();
let new_code = format!(" .{method}()\n");
let Some(insert_pos) = code.rfind('}') else {
self.rust_state.error = Some("Failed to add method: Could not find `}`".into());
return;
};
let code = format!("{}{}{}", &code[..insert_pos], new_code, &code[insert_pos..]);
self.rust_state.editing = true;
self.rust_state.state.update(cx, |state, cx| {
state.set_value(code, window, cx);
// an edit event will be triggered, the style will be updated there
});
}
}
}
fn style_to_json(style: &StyleRefinement) -> String {
serde_json::to_string_pretty(style).unwrap_or_else(|e| format!("{{ \"error\": \"{}\" }}", e))
}
struct StyleMethods {
table: Vec<(Box<StyleRefinement>, FunctionReflection<StyleRefinement>)>,
map: HashMap<&'static str, FunctionReflection<StyleRefinement>>,
}
impl StyleMethods {
fn get() -> &'static Self {
static STYLE_METHODS: OnceLock<StyleMethods> = OnceLock::new();
STYLE_METHODS.get_or_init(|| {
let table: Vec<_> = [
crate::styled_ext_reflection::methods::<StyleRefinement>(),
gpui::styled_reflection::methods::<StyleRefinement>(),
]
.into_iter()
.flatten()
.map(|method| (Box::new(method.invoke(StyleRefinement::default())), method))
.collect();
let map = table
.iter()
.map(|(_, method)| (method.name, method.clone()))
.collect();
Self { table, map }
})
}
}
fn style_to_rust(input_style: &StyleRefinement) -> (String, StyleRefinement) {
let methods: Vec<_> = StyleMethods::get()
.table
.iter()
.filter_map(|(style, method)| {
if input_style.is_superset_of(style) {
Some(method)
} else {
None
}
})
.collect();
let mut code = "fn build() -> Div {\n div()\n".to_string();
let mut style = StyleRefinement::default();
for method in methods {
let before_invoke = style.clone();
style = method.invoke(style);
if style != before_invoke {
_ = write!(code, " .{}()\n", method.name);
}
}
code.push_str("}");
(code, style)
}
fn rust_to_style(
mut style: StyleRefinement,
rust_code: &str,
) -> (StyleRefinement, Option<SharedString>) {
// remove line comments
let rust_code = rust_code
.lines()
.map(|line| line.find("//").map_or(line, |i| &line[..i]).trim())
.collect::<Vec<_>>()
.concat();
let Some(begin) = rust_code.find("div()").map(|i| i + "div()".len()) else {
return (style, Some("Expected `div()`".into()));
};
let mut err = String::new();
let methods = rust_code[begin..]
.split(&['.', '(', ')', '{', '}'])
.filter(|s| !s.is_empty())
.map(str::trim);
let style_methods = StyleMethods::get();
for method in methods {
match style_methods.map.get(method) {
Some(method_reflection) => style = method_reflection.invoke(style),
None => _ = writeln!(err, "Unknown method: {method}"),
}
}
let err = if err.is_empty() {
None
} else {
Some(err.into())
};
(style, err)
} }
impl Render for DivInspector { impl Render for DivInspector {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let input_state = self.input_state.clone(); v_flex().size_full().gap_y_4().text_sm().when_some(
let last_styles = input_state.read(cx).value().clone(); self.inspector_state.as_ref(),
v_flex().size_full().gap_3().text_sm().when_some(
self.inspector_state.clone(),
|this, state| { |this, state| {
let styles = serde_json::to_string_pretty(&state.base_style);
let styles: SharedString = match styles {
Ok(json) => json,
Err(e) => format!("{{ \"error\": \"{}\" }}", e),
}
.into();
if styles != last_styles {
input_state.update(cx, |s, cx| s.set_value(styles.clone(), window, cx));
}
this.child( this.child(
DescriptionList::new() DescriptionList::new()
.columns(1) .columns(1)
@ -115,21 +399,70 @@ impl Render for DivInspector {
) )
.child( .child(
v_flex() v_flex()
.w_full()
.flex_1() .flex_1()
.gap_1() .gap_y_3()
.text_sm()
.text_color(cx.theme().description_list_label_foreground)
.child("Styles")
.child( .child(
div() v_flex().gap_y_2().child("Rust Styles").child(
h_flex()
.gap_x_2()
.child(
Dropdown::new(&self.rust_dropdown)
.icon(IconName::Search)
.small()
.cleanable()
.flex_1(),
)
.child(Button::new("rust-add").label("Add").small().on_click(
cx.listener(|this, _, window, cx| {
this.rust_add_style(window, cx);
}),
))
.child(
Button::new("rust-reset").label("Reset").small().on_click(
cx.listener(|this, _, window, cx| {
this.reset_style(window, cx);
}),
),
),
),
)
.child(
v_flex()
.flex_1() .flex_1()
.w_full() .gap_y_1()
.font_family("Monaco") .font_family("Monaco")
.text_size(px(12.)) .text_size(px(12.))
.border_1() .child(TextInput::new(&self.rust_state.state).h_full())
.border_color(cx.theme().border) .when_some(self.rust_state.error.clone(), |this, err| {
.child(TextInput::new(&input_state).h_full().appearance(false)), this.child(Alert::error("rust-error", err).text_xs())
}),
),
)
.child(
v_flex()
.gap_y_3()
.h_3_5()
.flex_shrink_0()
.child(
h_flex()
.gap_x_2()
.child(div().flex_1().child("JSON Styles"))
.child(Button::new("json-reset").label("Reset").small().on_click(
cx.listener(|this, _, window, cx| {
this.reset_style(window, cx);
}),
)),
)
.child(
v_flex()
.flex_1()
.gap_y_1()
.font_family("Monaco")
.text_size(px(12.))
.child(TextInput::new(&self.json_state.state).h_full())
.when_some(self.json_state.error.clone(), |this, err| {
this.child(Alert::error("json-error", err).text_xs())
}),
), ),
) )
}, },
@ -161,7 +494,7 @@ fn render_inspector(
.gap_2() .gap_2()
.h(TITLE_BAR_HEIGHT) .h(TITLE_BAR_HEIGHT)
.line_height(TITLE_BAR_HEIGHT) .line_height(TITLE_BAR_HEIGHT)
.overflow_hidden() .overflow_x_hidden()
.px_2() .px_2()
.border_b_1() .border_b_1()
.border_color(cx.theme().title_bar_border) .border_color(cx.theme().title_bar_border)
@ -176,6 +509,7 @@ fn render_inspector(
.selected(inspector.is_picking()) .selected(inspector.is_picking())
.small() .small()
.ghost() .ghost()
.cursor_pointer()
.on_click(cx.listener(|this, _, window, _| { .on_click(cx.listener(|this, _, window, _| {
this.start_picking(); this.start_picking();
window.refresh(); window.refresh();
@ -188,6 +522,7 @@ fn render_inspector(
.icon(IconName::Close) .icon(IconName::Close)
.small() .small()
.ghost() .ghost()
.cursor_pointer()
.on_click(|_, window, cx| { .on_click(|_, window, cx| {
window.dispatch_action(Box::new(ToggleInspector), cx); window.dispatch_action(Box::new(ToggleInspector), cx);
}), }),

View file

@ -56,6 +56,10 @@ macro_rules! font_weight {
} }
/// Extends [`gpui::Styled`] with specific styling methods. /// Extends [`gpui::Styled`] with specific styling methods.
#[cfg_attr(
any(feature = "inspector", debug_assertions),
gpui_macros::derive_inspector_reflection
)]
pub trait StyledExt: Styled + Sized { pub trait StyledExt: Styled + Sized {
/// Refine the style of this element, applying the given style refinement. /// Refine the style of this element, applying the given style refinement.
fn refine_style(mut self, style: &StyleRefinement) -> Self { fn refine_style(mut self, style: &StyleRefinement) -> Self {