inspector: Add Inspector. (#897)

<img width="1434" alt="image"
src="https://github.com/user-attachments/assets/f6ec2e1d-ff1c-4798-90fe-d29bef20fa9d"
/>
This commit is contained in:
Jason Lee 2025-05-26 16:44:11 +08:00 committed by GitHub
parent a1ad647527
commit 1c11b5fb55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 310 additions and 42 deletions

View file

@ -0,0 +1,18 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-square-dashed-mouse-pointer-icon lucide-square-dashed-mouse-pointer"
><path
d="M12.034 12.681a.498.498 0 0 1 .647-.647l9 3.5a.5.5 0 0 1-.033.943l-3.444 1.068a1 1 0 0 0-.66.66l-1.067 3.443a.5.5 0 0 1-.943.033z"
/><path d="M5 3a2 2 0 0 0-2 2" /><path d="M19 3a2 2 0 0 1 2 2" /><path
d="M5 21a2 2 0 0 1-2-2"
/><path d="M9 3h1" /><path d="M9 21h2" /><path d="M14 3h1" /><path
d="M3 9v1"
/><path d="M21 9v2" /><path d="M3 14v1" /></svg>

After

Width:  |  Height:  |  Size: 710 B

View file

@ -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<HighlightTheme> = LazyLock::new(|| HighlightTheme::default_light());
static DARK_THEME: LazyLock<HighlightTheme> = LazyLock::new(|| HighlightTheme::default_dark());
pub struct Example {
input_state: Entity<InputState>,
language_state: Entity<DropdownState<Vec<SharedString>>>,
@ -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,
);
}
});
}

View file

@ -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<HighlightTheme> = LazyLock::new(|| HighlightTheme::default_light());
static DARK_THEME: LazyLock<HighlightTheme> = LazyLock::new(|| HighlightTheme::default_dark());
pub struct Example {
input_state: Entity<InputState>,
resizable_state: Entity<ResizableState>,
@ -27,7 +22,7 @@ impl Example {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> 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,
);
}
});
}

View file

@ -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<HighlightTheme> = LazyLock::new(|| HighlightTheme::default_light());
static DARK_THEME: LazyLock<HighlightTheme> = 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>) -> 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()
},
),

View file

@ -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),
)

View file

@ -57,6 +57,12 @@ impl From<SharedString> for DescriptionText {
}
}
impl From<String> 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 {

View file

@ -18,6 +18,15 @@ static DEFAULT_DARK: LazyLock<Arc<highlighting::Theme>> = LazyLock::new(|| {
Arc::new(highlighting::ThemeSet::load_from_reader(&mut cursor).unwrap())
});
pub static LIGHT_THEME: LazyLock<HighlightTheme> = LazyLock::new(|| HighlightTheme {
name: "default-light".into(),
inner: DEFAULT_LIGHT.clone(),
});
pub static DARK_THEME: LazyLock<HighlightTheme> = 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)

View file

@ -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",

View file

@ -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();
}

View file

@ -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,

222
crates/ui/src/inspector.rs Normal file
View file

@ -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<InspectorElementId>,
inspector_state: Option<DivInspectorState>,
input_state: Entity<InputState>,
}
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>,
) {
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<Self>) -> 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<Inspector>,
) -> 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()
}

View file

@ -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);

View file

@ -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()),
}
}
}