From 89928521654ebd2a3d90da1c9e653ea09b3351e2 Mon Sep 17 00:00:00 2001 From: Chuqiao Feng Date: Fri, 5 Sep 2025 11:58:36 +0800 Subject: [PATCH] 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 --- Cargo.lock | 1 + Cargo.toml | 1 + crates/ui/Cargo.toml | 3 +- crates/ui/src/inspector.rs | 415 +++++++++++++++++++++++++++++++++---- crates/ui/src/styled.rs | 4 + 5 files changed, 383 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d3dc4c5a..72910fcc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3149,6 +3149,7 @@ dependencies = [ "enum-iterator", "gpui", "gpui-component-macros", + "gpui_macros", "html5ever 0.27.0", "indoc", "itertools 0.13.0", diff --git a/Cargo.toml b/Cargo.toml index 568c3db7..7a08103e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ resolver = "2" [workspace.dependencies] 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" } sum_tree = { git = "https://github.com/zed-industries/zed.git" } gpui-component = { path = "crates/ui" } diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 3e5ff931..1b4b777b 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -13,7 +13,7 @@ doctest = false [features] decimal = ["dep:rust_decimal"] -inspector = [] +inspector = ["gpui/inspector"] webview = ["dep:wry"] # For syntax highlighting in Markdown and CodeEditor. tree-sitter-languages = [ @@ -50,6 +50,7 @@ tree-sitter-languages = [ [dependencies] gpui.workspace = true sum_tree.workspace = true +gpui_macros.workspace = true gpui-component-macros.workspace = true rust-i18n.workspace = true schemars.workspace = true diff --git a/crates/ui/src/inspector.rs b/crates/ui/src/inspector.rs index 89275a9e..5e3911e8 100644 --- a/crates/ui/src/inspector.rs +++ b/crates/ui/src/inspector.rs @@ -1,17 +1,20 @@ -use std::cell::OnceCell; +use std::{cell::OnceCell, collections::HashMap, fmt::Write as _, sync::OnceLock}; use gpui::{ - actions, div, prelude::FluentBuilder, px, AnyElement, App, AppContext as _, Context, - DivInspectorState, Entity, Inspector, InspectorElementId, InteractiveElement as _, IntoElement, - KeyBinding, ParentElement as _, Render, SharedString, Styled, Window, + actions, div, inspector_reflection::FunctionReflection, prelude::FluentBuilder, px, AnyElement, + App, AppContext, Context, DivInspectorState, Entity, Inspector, InspectorElementId, + InteractiveElement as _, IntoElement, KeyBinding, ParentElement as _, Refineable as _, Render, + SharedString, StyleRefinement, Styled, Subscription, Window, }; use crate::{ + alert::Alert, button::{Button, ButtonVariants}, clipboard::Clipboard, description_list::DescriptionList, + dropdown::{Dropdown, DropdownState, SearchableVec}, h_flex, - input::{InputState, TextInput}, + input::{InputEvent, InputState, TabSize, TextInput}, link::Link, 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| { let el = inspector_el.get_or_init(|| cx.new(|cx| DivInspector::new(window, 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() }) }); @@ -51,59 +54,340 @@ pub fn init(cx: &mut App) { cx.set_inspector_renderer(Box::new(render_inspector)); } +struct EditorState { + /// The input state for the editor. + state: Entity, + /// Error to display from parsing the input, or if serialization errors somehow occur. + error: Option, + /// Whether the editor is currently being edited. + editing: bool, +} + pub struct DivInspector { inspector_id: Option, inspector_state: Option, - input_state: Entity, + rust_dropdown: Entity>>, + 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, } impl DivInspector { - pub fn new(window: &mut Window, cx: &mut App) -> Self { - let input_state = cx.new(|cx| { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let json_input_state = cx.new(|cx| { InputState::new(window, cx) .code_editor("json") .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 { inspector_id: 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, inspector_id: InspectorElementId, state: DivInspectorState, + window: &mut Window, cx: &mut Context, ) { + // 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_state = Some(state); cx.notify(); } + + fn edit_json(&mut self, code: &str, window: &mut Window, cx: &mut Context) { + if !self.json_state.editing { + self.json_state.editing = true; + return; + } + + match serde_json::from_str::(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) { + 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, + ) { + window.with_inspector_state::( + 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.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.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, + ) -> 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) { + 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, FunctionReflection)>, + map: HashMap<&'static str, FunctionReflection>, +} + +impl StyleMethods { + fn get() -> &'static Self { + static STYLE_METHODS: OnceLock = OnceLock::new(); + STYLE_METHODS.get_or_init(|| { + let table: Vec<_> = [ + crate::styled_ext_reflection::methods::(), + gpui::styled_reflection::methods::(), + ] + .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) { + // remove line comments + let rust_code = rust_code + .lines() + .map(|line| line.find("//").map_or(line, |i| &line[..i]).trim()) + .collect::>() + .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 { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let input_state = self.input_state.clone(); - let last_styles = input_state.read(cx).value().clone(); - - v_flex().size_full().gap_3().text_sm().when_some( - self.inspector_state.clone(), + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex().size_full().gap_y_4().text_sm().when_some( + self.inspector_state.as_ref(), |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( DescriptionList::new() .columns(1) @@ -115,21 +399,70 @@ impl Render for DivInspector { ) .child( v_flex() - .w_full() .flex_1() - .gap_1() - .text_sm() - .text_color(cx.theme().description_list_label_foreground) - .child("Styles") + .gap_y_3() .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() - .w_full() + .gap_y_1() .font_family("Monaco") .text_size(px(12.)) - .border_1() - .border_color(cx.theme().border) - .child(TextInput::new(&input_state).h_full().appearance(false)), + .child(TextInput::new(&self.rust_state.state).h_full()) + .when_some(self.rust_state.error.clone(), |this, err| { + 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() .h(TITLE_BAR_HEIGHT) .line_height(TITLE_BAR_HEIGHT) - .overflow_hidden() + .overflow_x_hidden() .px_2() .border_b_1() .border_color(cx.theme().title_bar_border) @@ -176,6 +509,7 @@ fn render_inspector( .selected(inspector.is_picking()) .small() .ghost() + .cursor_pointer() .on_click(cx.listener(|this, _, window, _| { this.start_picking(); window.refresh(); @@ -188,6 +522,7 @@ fn render_inspector( .icon(IconName::Close) .small() .ghost() + .cursor_pointer() .on_click(|_, window, cx| { window.dispatch_action(Box::new(ToggleInspector), cx); }), diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs index 2b01cf15..c364dc53 100644 --- a/crates/ui/src/styled.rs +++ b/crates/ui/src/styled.rs @@ -56,6 +56,10 @@ macro_rules! font_weight { } /// Extends [`gpui::Styled`] with specific styling methods. +#[cfg_attr( + any(feature = "inspector", debug_assertions), + gpui_macros::derive_inspector_reflection +)] pub trait StyledExt: Styled + Sized { /// Refine the style of this element, applying the given style refinement. fn refine_style(mut self, style: &StyleRefinement) -> Self {