From 1c11b5fb554488744f2bb298440c469e260b3b32 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 26 May 2025 16:44:11 +0800 Subject: [PATCH] inspector: Add Inspector. (#897) image --- assets/icons/inspector.svg | 18 ++ crates/story/examples/code-editor.rs | 17 +- crates/story/examples/html.rs | 17 +- crates/story/examples/markdown.rs | 18 +- crates/story/src/title_bar.rs | 4 +- crates/ui/src/description_list.rs | 6 + crates/ui/src/highlighter/highlight.rs | 23 +-- crates/ui/src/icon.rs | 2 + crates/ui/src/input/state.rs | 8 +- crates/ui/src/input/text_input.rs | 12 +- crates/ui/src/inspector.rs | 222 +++++++++++++++++++++++++ crates/ui/src/lib.rs | 3 + crates/ui/src/text/text_view.rs | 2 +- 13 files changed, 310 insertions(+), 42 deletions(-) create mode 100644 assets/icons/inspector.svg create mode 100644 crates/ui/src/inspector.rs diff --git a/assets/icons/inspector.svg b/assets/icons/inspector.svg new file mode 100644 index 00000000..c3517a42 --- /dev/null +++ b/assets/icons/inspector.svg @@ -0,0 +1,18 @@ + diff --git a/crates/story/examples/code-editor.rs b/crates/story/examples/code-editor.rs index 603f4a1d..ffa7793d 100644 --- a/crates/story/examples/code-editor.rs +++ b/crates/story/examples/code-editor.rs @@ -1,5 +1,3 @@ -use std::sync::LazyLock; - use gpui::*; use gpui_component::{ checkbox::Checkbox, @@ -11,9 +9,6 @@ use gpui_component::{ }; use story::Assets; -static LIGHT_THEME: LazyLock = LazyLock::new(|| HighlightTheme::default_light()); -static DARK_THEME: LazyLock = LazyLock::new(|| HighlightTheme::default_dark()); - pub struct Example { input_state: Entity, language_state: Entity>>, @@ -31,7 +26,7 @@ impl Example { let default_language: SharedString = LANGUAGES[0].into(); let input_state = cx.new(|cx| { InputState::new(window, cx) - .code_editor(Some(&default_language), &LIGHT_THEME) + .code_editor(Some(&default_language), &HighlightTheme::default_light()) .line_number(true) .tab_size(TabSize { tab_size: 4, @@ -89,9 +84,15 @@ impl Example { self.is_dark = is_dark; self.input_state.update(cx, |state, cx| { if is_dark { - state.set_highlighter(Highlighter::new(Some(language), &DARK_THEME), cx); + state.set_highlighter( + Highlighter::new(Some(language), &HighlightTheme::default_dark()), + cx, + ); } else { - state.set_highlighter(Highlighter::new(Some(language), &LIGHT_THEME), cx); + state.set_highlighter( + Highlighter::new(Some(language), &HighlightTheme::default_dark()), + cx, + ); } }); } diff --git a/crates/story/examples/html.rs b/crates/story/examples/html.rs index 8468d8d9..b87d7cd5 100644 --- a/crates/story/examples/html.rs +++ b/crates/story/examples/html.rs @@ -1,5 +1,3 @@ -use std::sync::LazyLock; - use gpui::*; use gpui_component::{ highlighter::{HighlightTheme, Highlighter}, @@ -10,9 +8,6 @@ use gpui_component::{ }; use story::Assets; -static LIGHT_THEME: LazyLock = LazyLock::new(|| HighlightTheme::default_light()); -static DARK_THEME: LazyLock = LazyLock::new(|| HighlightTheme::default_dark()); - pub struct Example { input_state: Entity, resizable_state: Entity, @@ -27,7 +22,7 @@ impl Example { pub fn new(window: &mut Window, cx: &mut Context) -> Self { let input_state = cx.new(|cx| { InputState::new(window, cx) - .code_editor(Some(LANG), &LIGHT_THEME) + .code_editor(Some(LANG), &HighlightTheme::default_light()) .tab_size(TabSize { tab_size: 4, hard_tabs: false, @@ -65,9 +60,15 @@ impl Render for Example { self.is_dark = is_dark; self.input_state.update(cx, |state, cx| { if is_dark { - state.set_highlighter(Highlighter::new(Some(LANG), &DARK_THEME), cx); + state.set_highlighter( + Highlighter::new(Some(LANG), &HighlightTheme::default_dark()), + cx, + ); } else { - state.set_highlighter(Highlighter::new(Some(LANG), &LIGHT_THEME), cx); + state.set_highlighter( + Highlighter::new(Some(LANG), &HighlightTheme::default_light()), + cx, + ); } }); } diff --git a/crates/story/examples/markdown.rs b/crates/story/examples/markdown.rs index 873e8ff8..44652106 100644 --- a/crates/story/examples/markdown.rs +++ b/crates/story/examples/markdown.rs @@ -1,4 +1,4 @@ -use std::{rc::Rc, sync::LazyLock}; +use std::rc::Rc; use gpui::*; use gpui_component::{ @@ -10,8 +10,6 @@ use gpui_component::{ }; use story::Assets; -static LIGHT_THEME: LazyLock = LazyLock::new(|| HighlightTheme::default_light()); -static DARK_THEME: LazyLock = LazyLock::new(|| HighlightTheme::default_dark()); const LANG: &str = "markdown"; pub struct Example { @@ -26,7 +24,7 @@ impl Example { pub fn new(window: &mut Window, cx: &mut Context) -> Self { let input_state = cx.new(|cx| { InputState::new(window, cx) - .code_editor(Some(LANG), &LIGHT_THEME) + .code_editor(Some(LANG), &HighlightTheme::default_light()) .line_number(false) .tab_size(TabSize { tab_size: 2, @@ -66,9 +64,15 @@ impl Render for Example { self.is_dark = is_dark; self.input_state.update(cx, |state, cx| { if is_dark { - state.set_highlighter(Highlighter::new(Some(LANG), &DARK_THEME), cx); + state.set_highlighter( + Highlighter::new(Some(LANG), &HighlightTheme::default_dark()), + cx, + ); } else { - state.set_highlighter(Highlighter::new(Some(LANG), &LIGHT_THEME), cx); + state.set_highlighter( + Highlighter::new(Some(LANG), &HighlightTheme::default_light()), + cx, + ); } }); } @@ -92,7 +96,7 @@ impl Render for Example { .child( TextView::markdown("preview", self.input_state.read(cx).value()).style( TextViewStyle { - highlight_theme: Rc::new(theme), + highlight_theme: Rc::new(theme.clone()), ..Default::default() }, ), diff --git a/crates/story/src/title_bar.rs b/crates/story/src/title_bar.rs index 1ea61435..0e6cc5d6 100644 --- a/crates/story/src/title_bar.rs +++ b/crates/story/src/title_bar.rs @@ -13,7 +13,7 @@ use gpui_component::{ popup_menu::PopupMenuExt as _, scroll::ScrollbarShow, set_locale, ActiveTheme as _, ContextModal as _, IconName, Sizable as _, Theme, ThemeMode, - TitleBar, + TitleBar, ToggleInspector, }; use crate::{SelectFont, SelectLocale, SelectRadius, SelectScrollbarShow}; @@ -312,6 +312,8 @@ impl Render for FontSizeSelector { scroll_show == ScrollbarShow::Always, Box::new(SelectScrollbarShow(ScrollbarShow::Always)), ) + .separator() + .menu("Inspector", Box::new(ToggleInspector)) }) .anchor(Corner::TopRight), ) diff --git a/crates/ui/src/description_list.rs b/crates/ui/src/description_list.rs index ce23bcfd..429e8f30 100644 --- a/crates/ui/src/description_list.rs +++ b/crates/ui/src/description_list.rs @@ -57,6 +57,12 @@ impl From for DescriptionText { } } +impl From for DescriptionText { + fn from(text: String) -> Self { + DescriptionText::String(SharedString::from(text)) + } +} + impl RenderOnce for DescriptionText { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { match self { diff --git a/crates/ui/src/highlighter/highlight.rs b/crates/ui/src/highlighter/highlight.rs index d978fd4e..566a2549 100644 --- a/crates/ui/src/highlighter/highlight.rs +++ b/crates/ui/src/highlighter/highlight.rs @@ -18,6 +18,15 @@ static DEFAULT_DARK: LazyLock> = LazyLock::new(|| { Arc::new(highlighting::ThemeSet::load_from_reader(&mut cursor).unwrap()) }); +pub static LIGHT_THEME: LazyLock = LazyLock::new(|| HighlightTheme { + name: "default-light".into(), + inner: DEFAULT_LIGHT.clone(), +}); +pub static DARK_THEME: LazyLock = LazyLock::new(|| HighlightTheme { + name: "default-dark".into(), + inner: DEFAULT_DARK.clone(), +}); + /// Represents a theme for syntax highlighting. #[derive(Debug, Clone, PartialEq)] pub struct HighlightTheme { @@ -27,19 +36,13 @@ pub struct HighlightTheme { impl HighlightTheme { /// Default light theme. - pub fn default_light() -> Self { - Self { - name: "default-light".into(), - inner: DEFAULT_LIGHT.clone(), - } + pub fn default_light() -> &'static Self { + &LIGHT_THEME } /// Default dark theme. - pub fn default_dark() -> Self { - Self { - name: "default-dark".into(), - inner: DEFAULT_DARK.clone(), - } + pub fn default_dark() -> &'static Self { + &DARK_THEME } /// Parse a theme from a string (tmTheme) diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index 43f79731..06b02674 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -44,6 +44,7 @@ pub enum IconName { HeartOff, Inbox, Info, + Inspector, LayoutDashboard, Loader, LoaderCircle, @@ -122,6 +123,7 @@ impl IconName { Self::HeartOff => "icons/heart-off.svg", Self::Inbox => "icons/inbox.svg", Self::Info => "icons/info.svg", + Self::Inspector => "icons/inspector.svg", Self::LayoutDashboard => "icons/layout-dashboard.svg", Self::Loader => "icons/loader.svg", Self::LoaderCircle => "icons/loader-circle.svg", diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index 598ff35d..ecbfdfbf 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -614,7 +614,13 @@ impl InputState { self.replace_text(value, window, cx); self.history.ignore = false; // Ensure cursor to start when set text - self.selected_range = self.text.len()..self.text.len(); + if self.is_single_line() { + self.selected_range = self.text.len()..self.text.len(); + } else { + self.selected_range = 0..0; + } + // Move scroll to top + self.scroll_handle.set_offset(point(px(0.), px(0.))); cx.notify(); } diff --git a/crates/ui/src/input/text_input.rs b/crates/ui/src/input/text_input.rs index ad5204e1..91dd9ab6 100644 --- a/crates/ui/src/input/text_input.rs +++ b/crates/ui/src/input/text_input.rs @@ -180,6 +180,12 @@ impl RenderOnce for TextInput { .on_action(window.listener_for(&self.state, InputState::delete_next_word)) .on_action(window.listener_for(&self.state, InputState::enter)) .on_action(window.listener_for(&self.state, InputState::escape)) + .on_action(window.listener_for(&self.state, InputState::indent)) + .on_action(window.listener_for(&self.state, InputState::outdent)) + .on_action(window.listener_for(&self.state, InputState::paste)) + .on_action(window.listener_for(&self.state, InputState::cut)) + .on_action(window.listener_for(&self.state, InputState::undo)) + .on_action(window.listener_for(&self.state, InputState::redo)) }) .on_action(window.listener_for(&self.state, InputState::left)) .on_action(window.listener_for(&self.state, InputState::right)) @@ -190,8 +196,6 @@ impl RenderOnce for TextInput { .on_action(window.listener_for(&self.state, InputState::down)) .on_action(window.listener_for(&self.state, InputState::select_up)) .on_action(window.listener_for(&self.state, InputState::select_down)) - .on_action(window.listener_for(&self.state, InputState::indent)) - .on_action(window.listener_for(&self.state, InputState::outdent)) }) .on_action(window.listener_for(&self.state, InputState::select_all)) .on_action(window.listener_for(&self.state, InputState::select_to_start_of_line)) @@ -208,10 +212,6 @@ impl RenderOnce for TextInput { .on_action(window.listener_for(&self.state, InputState::select_to_end)) .on_action(window.listener_for(&self.state, InputState::show_character_palette)) .on_action(window.listener_for(&self.state, InputState::copy)) - .on_action(window.listener_for(&self.state, InputState::paste)) - .on_action(window.listener_for(&self.state, InputState::cut)) - .on_action(window.listener_for(&self.state, InputState::undo)) - .on_action(window.listener_for(&self.state, InputState::redo)) .on_key_down(window.listener_for(&self.state, InputState::on_key_down)) .on_mouse_down( MouseButton::Left, diff --git a/crates/ui/src/inspector.rs b/crates/ui/src/inspector.rs new file mode 100644 index 00000000..f86d043f --- /dev/null +++ b/crates/ui/src/inspector.rs @@ -0,0 +1,222 @@ +use std::cell::OnceCell; + +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, +}; + +use crate::{ + button::{Button, ButtonVariants}, + clipboard::Clipboard, + description_list::DescriptionList, + h_flex, + highlighter::HighlightTheme, + input::{InputState, TextInput}, + link::Link, + v_flex, ActiveTheme, IconName, Selectable, Sizable, TITLE_BAR_HEIGHT, +}; + +actions!(inspector, [ToggleInspector]); + +/// Initialize the inspector and register the action to toggle it. +pub fn init(cx: &mut App) { + cx.bind_keys(vec![ + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-alt-i", ToggleInspector, None), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-shift-i", ToggleInspector, None), + ]); + + cx.on_action(|_: &ToggleInspector, cx| { + let Some(active_window) = cx.active_window() else { + return; + }; + + cx.defer(move |cx| { + _ = active_window.update(cx, |_, window, cx| { + window.toggle_inspector(cx); + }); + }); + }); + + let inspector_el = OnceCell::new(); + 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.render(window, cx).into_any_element() + }) + }); + + cx.set_inspector_renderer(Box::new(render_inspector)); +} + +pub struct DivInspector { + inspector_id: Option, + inspector_state: Option, + input_state: Entity, +} + +impl DivInspector { + pub fn new(window: &mut Window, cx: &mut App) -> Self { + let theme = if cx.theme().is_dark() { + HighlightTheme::default_dark() + } else { + HighlightTheme::default_light() + }; + + let input_state = cx.new(|cx| { + let mut state = InputState::new(window, cx) + .code_editor(Some("json"), theme) + .line_number(false); + state.set_disabled(true, window, cx); + state + }); + + Self { + inspector_id: None, + inspector_state: None, + input_state, + } + } + + pub fn set_inspector_state( + &mut self, + inspector_id: InspectorElementId, + state: DivInspectorState, + cx: &mut Context, + ) { + self.inspector_id = Some(inspector_id); + self.inspector_state = Some(state); + cx.notify(); + } +} + +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(), + |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) + .label_width(px(110.)) + .bordered(false) + .child("Origin", format!("{}", state.bounds.origin), 1) + .child("Size", format!("{}", state.bounds.size), 1) + .child("Content Size", format!("{}", state.content_size), 1), + ) + .child( + v_flex() + .w_full() + .flex_1() + .gap_1() + .text_sm() + .text_color(cx.theme().description_list_label_foreground) + .child("Styles") + .child( + div() + .flex_1() + .w_full() + .font_family("Monaco") + .text_size(px(12.)) + .child(TextInput::new(&input_state).h_full()), + ), + ) + }, + ) + } +} + +fn render_inspector( + inspector: &mut Inspector, + window: &mut Window, + cx: &mut Context, +) -> AnyElement { + let inspector_element_id = inspector.active_element_id(); + let source_location = + inspector_element_id.map(|id| SharedString::new(format!("{}", id.path.source_location))); + + v_flex() + .id("inspector") + .size_full() + .bg(cx.theme().background) + .border_l_1() + .border_color(cx.theme().border) + .text_color(cx.theme().foreground) + .child( + h_flex() + .w_full() + .justify_between() + .gap_2() + .h(TITLE_BAR_HEIGHT) + .line_height(TITLE_BAR_HEIGHT) + .overflow_hidden() + .px_2() + .border_b_1() + .border_color(cx.theme().title_bar_border) + .bg(cx.theme().title_bar) + .child( + h_flex() + .gap_2() + .text_sm() + .child( + Button::new("inspect") + .icon(IconName::Inspector) + .selected(inspector.is_picking()) + .small() + .ghost() + .on_click(cx.listener(|this, _, window, _| { + this.start_picking(); + window.refresh(); + })), + ) + .child("Inspector"), + ) + .child( + Button::new("close") + .icon(IconName::Close) + .small() + .ghost() + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(ToggleInspector), cx); + }), + ), + ) + .child( + v_flex() + .flex_1() + .p_3() + .gap_3() + .when_some(source_location, |this, source_location| { + this.child( + h_flex() + .gap_1() + .text_sm() + .child( + Link::new("source-location") + .href(format!("file://{}", source_location)) + .child(source_location.clone()), + ) + .child(Clipboard::new("copy-source-location").value(source_location)), + ) + }) + .children(inspector.render_inspector_states(window, cx)), + ) + .into_any_element() +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index f6415797..6762bad3 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -2,6 +2,7 @@ mod colors; mod event; mod focusable; mod icon; +mod inspector; mod kbd; mod menu; mod root; @@ -64,6 +65,7 @@ pub use wry; pub use crate::Disableable; pub use event::InteractiveElementExt; pub use focusable::FocusableCycle; +pub use inspector::*; pub use menu::{context_menu, popup_menu}; pub use root::{ContextModal, Root}; pub use styled::*; @@ -88,6 +90,7 @@ rust_i18n::i18n!("locales", fallback = "en"); /// You can initialize the UI module at your application's entry point. pub fn init(cx: &mut App) { theme::init(cx); + inspector::init(cx); date_picker::init(cx); dock::init(cx); drawer::init(cx); diff --git a/crates/ui/src/text/text_view.rs b/crates/ui/src/text/text_view.rs index e9f8b2a7..e7867d97 100644 --- a/crates/ui/src/text/text_view.rs +++ b/crates/ui/src/text/text_view.rs @@ -84,7 +84,7 @@ impl Default for TextViewStyle { Self { paragraph_gap: rems(1.), heading_base_font_size: px(14.), - highlight_theme: Rc::new(HighlightTheme::default_light()), + highlight_theme: Rc::new(HighlightTheme::default_light().clone()), } } }