From 6aab9d33451f83ac31d6f9971aa2235eb4909719 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 9 Jun 2025 16:16:15 +0800 Subject: [PATCH] code-editor: Show hover popover for diagnostics. (#924) image --- crates/story/examples/code-editor.rs | 2 +- crates/ui/src/highlighter/registry.rs | 47 ++++++- crates/ui/src/highlighter/themes/dark.json | 8 +- crates/ui/src/highlighter/themes/light.json | 8 +- crates/ui/src/input/element.rs | 2 +- crates/ui/src/input/hover_popover.rs | 133 ++++++++++++++++++++ crates/ui/src/input/marker.rs | 61 +++++++-- crates/ui/src/input/mod.rs | 1 + crates/ui/src/input/mode.rs | 32 ++++- crates/ui/src/input/state.rs | 85 +++++++++---- crates/ui/src/input/text_input.rs | 1 + 11 files changed, 326 insertions(+), 54 deletions(-) create mode 100644 crates/ui/src/input/hover_popover.rs diff --git a/crates/story/examples/code-editor.rs b/crates/story/examples/code-editor.rs index 12d799c0..fbb9bb1f 100644 --- a/crates/story/examples/code-editor.rs +++ b/crates/story/examples/code-editor.rs @@ -145,7 +145,7 @@ impl Example { vec![ Marker::new("warning", (2, 1), (2, 31), "Import but not used."), Marker::new("error", (16, 10), (16, 46), "Syntax error."), - Marker::new("info", (25, 10), (25, 20), "This is a info message."), + Marker::new("info", (25, 10), (25, 20), "This is a info message, this is a very long message, with **Markdown** support."), Marker::new("hint", (36, 9), (40, 10), "This is a hint message."), ], window, diff --git a/crates/ui/src/highlighter/registry.rs b/crates/ui/src/highlighter/registry.rs index a9e39c4e..5a40c8a2 100644 --- a/crates/ui/src/highlighter/registry.rs +++ b/crates/ui/src/highlighter/registry.rs @@ -247,22 +247,32 @@ pub struct StatusColors { error: Option, #[serde(rename = "error.background")] error_background: Option, + #[serde(rename = "error.border")] + error_border: Option, #[serde(rename = "warning")] warning: Option, #[serde(rename = "warning.background")] warning_background: Option, + #[serde(rename = "warning.border")] + warning_border: Option, #[serde(rename = "info")] info: Option, #[serde(rename = "info.background")] info_background: Option, + #[serde(rename = "info.border")] + info_border: Option, #[serde(rename = "success")] success: Option, #[serde(rename = "success.background")] success_background: Option, + #[serde(rename = "success.border")] + success_border: Option, #[serde(rename = "hint")] hint: Option, #[serde(rename = "hint.background")] hint_background: Option, + #[serde(rename = "hint.border")] + hint_border: Option, } impl StatusColors { @@ -273,7 +283,12 @@ impl StatusColors { #[inline] pub fn error_background(&self) -> Hsla { - self.error_background.unwrap_or(self.error()) + self.error_background.unwrap_or(crate::red_200()) + } + + #[inline] + pub fn error_border(&self) -> Hsla { + self.error_border.unwrap_or(crate::red_500()) } #[inline] @@ -283,7 +298,12 @@ impl StatusColors { #[inline] pub fn warning_background(&self) -> Hsla { - self.warning_background.unwrap_or(self.warning()) + self.warning_background.unwrap_or(crate::yellow_200()) + } + + #[inline] + pub fn warning_border(&self) -> Hsla { + self.warning_border.unwrap_or(crate::yellow_500()) } #[inline] @@ -293,7 +313,12 @@ impl StatusColors { #[inline] pub fn info_background(&self) -> Hsla { - self.info_background.unwrap_or(self.info()) + self.info_background.unwrap_or(crate::blue_200()) + } + + #[inline] + pub fn info_border(&self) -> Hsla { + self.info_border.unwrap_or(crate::blue_500()) } #[inline] @@ -303,17 +328,27 @@ impl StatusColors { #[inline] pub fn success_background(&self) -> Hsla { - self.success_background.unwrap_or(self.success()) + self.success_background.unwrap_or(crate::green_200()) + } + + #[inline] + pub fn success_border(&self) -> Hsla { + self.success_border.unwrap_or(crate::green_500()) } #[inline] pub fn hint(&self) -> Hsla { - self.hint.unwrap_or(crate::cyan_500().opacity(0.5)) + self.hint.unwrap_or(crate::gray_500()) } #[inline] pub fn hint_background(&self) -> Hsla { - self.hint_background.unwrap_or(self.hint()) + self.hint_background.unwrap_or(crate::gray_200()) + } + + #[inline] + pub fn hint_border(&self) -> Hsla { + self.hint_border.unwrap_or(crate::gray_500()) } } diff --git a/crates/ui/src/highlighter/themes/dark.json b/crates/ui/src/highlighter/themes/dark.json index 66c6b207..7b7c0427 100644 --- a/crates/ui/src/highlighter/themes/dark.json +++ b/crates/ui/src/highlighter/themes/dark.json @@ -22,9 +22,9 @@ "hidden": "#9E9E9E", "hidden.background": null, "hidden.border": null, - "hint": null, - "hint.background": "#0C194D", - "hint.border": "#082190", + "hint": "#b283f8", + "hint.background": "#250c4b", + "hint.border": "#3f0891", "ignored": null, "ignored.background": null, "ignored.border": null, @@ -162,4 +162,4 @@ } } } -} +} \ No newline at end of file diff --git a/crates/ui/src/highlighter/themes/light.json b/crates/ui/src/highlighter/themes/light.json index 74dc55f1..0dd662c2 100644 --- a/crates/ui/src/highlighter/themes/light.json +++ b/crates/ui/src/highlighter/themes/light.json @@ -22,9 +22,9 @@ "hidden": "#6D6D6D", "hidden.background": null, "hidden.border": null, - "hint": null, - "hint.background": "#E5F2FF", - "hint.border": "#99CCFF", + "hint": "#9e5dff", + "hint.background": "#ece6ff", + "hint.border": "#bc8fff", "ignored": null, "ignored.background": null, "ignored.border": null, @@ -172,4 +172,4 @@ } } } -} +} \ No newline at end of file diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index 424781e8..f7fc2d71 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -407,7 +407,7 @@ impl TextElement { let mut marker_styles = vec![]; for marker in markers.iter() { - if let Some(range) = marker.byte_range(&state) { + if let Some(range) = &marker.range { if range.start < skipped_offset { continue; } diff --git a/crates/ui/src/input/hover_popover.rs b/crates/ui/src/input/hover_popover.rs new file mode 100644 index 00000000..6bee89ed --- /dev/null +++ b/crates/ui/src/input/hover_popover.rs @@ -0,0 +1,133 @@ +use std::rc::Rc; + +use gpui::{ + canvas, deferred, div, px, App, AppContext as _, Bounds, Context, Empty, Entity, + InteractiveElement, IntoElement, ParentElement as _, Pixels, Point, Render, Styled, Window, +}; + +use crate::{ + highlighter::LanguageRegistry, + input::{InputState, Marker}, + text::TextView, + ActiveTheme as _, +}; + +pub struct DiagnosticPopover { + state: Entity, + pub(super) marker: Rc, + bounds: Bounds, + open: bool, +} + +impl DiagnosticPopover { + pub fn new(marker: &Marker, state: Entity, cx: &mut App) -> Entity { + let marker = Rc::new(marker.clone()); + + cx.new(|_| Self { + marker, + state, + bounds: Bounds::default(), + open: true, + }) + } + + fn origin(&self, cx: &App) -> Option> { + let Some(range) = self.marker.range.as_ref() else { + return None; + }; + + let (_, _, start_pos) = self + .state + .read(cx) + .line_and_position_for_offset(range.start); + + start_pos + } + + pub(super) fn show(&mut self, cx: &mut Context) { + self.open = true; + cx.notify(); + } + + pub(super) fn hide(&mut self, cx: &mut Context) { + self.open = false; + cx.notify(); + } + + pub(super) fn check_to_hide(&mut self, mouse_position: Point, cx: &mut Context) { + if !self.open { + return; + } + + let padding = px(5.); + let bounds = Bounds { + origin: self.bounds.origin.map(|v| v - padding), + size: self.bounds.size.map(|v| v + padding * 2.), + }; + + if !bounds.contains(&mouse_position) { + self.hide(cx); + } + } +} + +impl Render for DiagnosticPopover { + fn render(&mut self, window: &mut Window, cx: &mut gpui::Context) -> impl IntoElement { + if !self.open { + return Empty.into_any_element(); + } + + let view = cx.entity(); + let theme = LanguageRegistry::global(cx).theme(cx.theme().is_dark()); + + let message = self.marker.message.clone(); + let Some(pos) = self.origin(cx) else { + return Empty.into_any_element(); + }; + let (border, bg, fg) = ( + self.marker.severity.border(theme), + self.marker.severity.bg(theme), + self.marker.severity.fg(theme), + ); + + let scroll_origin = self.state.read(cx).scroll_handle.offset(); + + let y = pos.y - self.bounds.size.height + scroll_origin.y; + let x = pos.x + scroll_origin.x; + let max_width = px(500.).min(window.bounds().size.width - x); + + deferred( + div() + .id("code-editor-diagnostic-popover") + .absolute() + .left(x) + .top(y) + .px_1() + .py_0p5() + .text_xs() + .bg(bg) + .w(max_width) + .text_color(fg) + .border_1() + .border_color(border) + .rounded(cx.theme().radius) + .shadow_sm() + .child(TextView::markdown("message", message)) + .child( + canvas( + move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds), + |_, _, _, _| {}, + ) + .top_0() + .left_0() + .absolute() + .size_full(), + ) + .on_mouse_down_out(cx.listener(|this, _, _, cx| { + this.open = false; + cx.notify(); + })), + ) + .into_any_element() + } +} diff --git a/crates/ui/src/input/marker.rs b/crates/ui/src/input/marker.rs index 33268b5c..1c207616 100644 --- a/crates/ui/src/input/marker.rs +++ b/crates/ui/src/input/marker.rs @@ -1,5 +1,5 @@ use crate::{highlighter::HighlightTheme, input::InputState}; -use gpui::{px, HighlightStyle, SharedString, UnderlineStyle}; +use gpui::{px, HighlightStyle, Hsla, SharedString, UnderlineStyle}; use itertools::Itertools; use std::ops::Range; @@ -9,6 +9,7 @@ pub struct Marker { pub severity: MarkerSeverity, pub start: LineColumn, pub end: LineColumn, + pub(super) range: Option>, /// The message associated with the marker, typically a description of the issue. pub message: SharedString, } @@ -26,22 +27,34 @@ impl Marker { start: start.into(), end: end.into(), message: message.into(), + range: None, } } - /// Returns the range (zero-based) of bytes in the source code that this marker covers. - pub(super) fn byte_range(&self, state: &InputState) -> Option> { - let start_line = state + /// Prepare the marker to convert line, column to byte offsets. + pub(super) fn prepare(&mut self, state: &InputState) { + let Some(start_line) = state .text_wrapper .lines - .get(self.start.line.saturating_sub(1))?; - let start_line_str = state.text.get(start_line.range.clone())?; + .get(self.start.line.saturating_sub(1)) + else { + return; + }; - let end_line = state + let Some(start_line_str) = state.text.get(start_line.range.clone()) else { + return; + }; + + let Some(end_line) = state .text_wrapper .lines - .get(self.end.line.saturating_sub(1))?; - let end_line_str = state.text.get(end_line.range.clone())?; + .get(self.end.line.saturating_sub(1)) + else { + return; + }; + let Some(end_line_str) = state.text.get(end_line.range.clone()) else { + return; + }; let start_byte = start_line.range.start + start_line_str @@ -58,7 +71,7 @@ impl Marker { .values() .sum::(); - Some(start_byte..end_byte) + self.range = Some(start_byte..end_byte); } } @@ -104,7 +117,33 @@ impl From<&str> for MarkerSeverity { } impl MarkerSeverity { - /// Returns the [`HighlightStyle`] for the marker severity with the given theme style. + pub(super) fn bg(&self, theme: &HighlightTheme) -> Hsla { + match self { + Self::Error => theme.style.status.error_background(), + Self::Warning => theme.style.status.warning_background(), + Self::Info => theme.style.status.info_background(), + Self::Hint => theme.style.status.hint_background(), + } + } + + pub(super) fn fg(&self, theme: &HighlightTheme) -> Hsla { + match self { + Self::Error => theme.style.status.error(), + Self::Warning => theme.style.status.warning(), + Self::Info => theme.style.status.info(), + Self::Hint => theme.style.status.hint(), + } + } + + pub(super) fn border(&self, theme: &HighlightTheme) -> Hsla { + match self { + Self::Error => theme.style.status.error_border(), + Self::Warning => theme.style.status.warning_border(), + Self::Info => theme.style.status.info_border(), + Self::Hint => theme.style.status.hint_border(), + } + } + pub(super) fn highlight_style(&self, theme: &HighlightTheme) -> HighlightStyle { let color = match self { Self::Error => Some(theme.style.status.error()), diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs index d84cf0b4..c34d213e 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -2,6 +2,7 @@ mod blink_cursor; mod change; mod clear_button; mod element; +mod hover_popover; mod marker; mod mask_pattern; mod mode; diff --git a/crates/ui/src/input/mode.rs b/crates/ui/src/input/mode.rs index 543f11ce..5da9c756 100644 --- a/crates/ui/src/input/mode.rs +++ b/crates/ui/src/input/mode.rs @@ -51,7 +51,7 @@ pub enum InputMode { line_number: bool, language: SharedString, highlighter: Rc>>, - markers: Vec, + markers: Rc>, }, AutoGrow { rows: usize, @@ -182,20 +182,42 @@ impl InputMode { } } + pub(super) fn clear_markers(&mut self) { + match self { + InputMode::CodeEditor { markers, .. } => *markers = Rc::new(vec![]), + _ => {} + } + } + #[allow(unused)] - pub(super) fn markers(&self) -> Option<&Vec> { - match &self { + pub(super) fn markers(&self) -> Option<&Rc>> { + match self { InputMode::CodeEditor { markers, .. } => Some(markers), _ => None, } } - pub(super) fn clear_markers(&mut self) { + pub(super) fn set_markers(&mut self, new_markers: Vec) { match self { - InputMode::CodeEditor { markers, .. } => markers.clear(), + InputMode::CodeEditor { markers, .. } => *markers = Rc::new(new_markers), _ => {} } } + + pub(super) fn marker_for_offset(&self, offset: usize) -> Option<&Marker> { + let Some(markers) = self.markers() else { + return None; + }; + + for marker in markers.iter() { + if let Some(range) = marker.range.as_ref() { + if range.contains(&offset) { + return Some(marker); + } + } + } + None + } } #[cfg(test)] diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index d6c76662..0aa343c6 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -30,6 +30,7 @@ use super::{ number_input, text_wrapper::TextWrapper, }; +use crate::input::hover_popover::DiagnosticPopover; use crate::input::marker::Marker; use crate::{history::History, scroll::ScrollbarState, Root}; @@ -258,6 +259,9 @@ pub struct InputState { pub(crate) mask_pattern: MaskPattern, pub(super) placeholder: SharedString, + /// Popover + diagnostic_popover: Option>, + /// To remember the horizontal column (x-coordinate) of the cursor position. preferred_x_offset: Option, _subscriptions: Vec, @@ -329,6 +333,7 @@ impl InputState { line_number_width: px(0.), placeholder: SharedString::default(), mask_pattern: MaskPattern::default(), + diagnostic_popover: None, _subscriptions, } } @@ -382,7 +387,7 @@ impl InputState { highlighter: Rc::new(RefCell::new(None)), line_number: true, height: Some(relative(1.)), - markers: vec![], + markers: Rc::new(vec![]), }; self } @@ -465,16 +470,12 @@ impl InputState { /// Set markers, only for [`InputMode::CodeEditor`] mode. /// /// For example to set the diagnostic markers in the code editor. - pub fn set_markers( - &mut self, - new_markers: Vec, - _: &mut Window, - cx: &mut Context, - ) { - if let InputMode::CodeEditor { markers, .. } = &mut self.mode { - *markers = new_markers; - cx.notify(); + pub fn set_markers(&mut self, markers: Vec, _: &mut Window, _: &mut Context) { + let mut markers = markers; + for marker in &mut markers { + marker.prepare(self); } + self.mode.set_markers(markers); } /// Set placeholder @@ -490,13 +491,12 @@ impl InputState { /// Called after moving the cursor. Updates preferred_x_offset if we know where the cursor now is. fn update_preferred_x_offset(&mut self, _cx: &mut Context) { - if let (Some(lines), Some(bounds)) = (&self.last_layout, &self.last_bounds) { + if let (Some(_), Some(bounds)) = (&self.last_layout, &self.last_bounds) { let offset = self.cursor_offset(); - let line_height = self.last_line_height; // Find which line and sub-line the cursor is on and its position let (_line_index, _sub_line_index, cursor_pos) = - self.line_and_position_for_offset(offset, lines, line_height); + self.line_and_position_for_offset(offset); if let Some(pos) = cursor_pos { // Adjust by scroll offset @@ -507,19 +507,29 @@ impl InputState { } /// Find which line and sub-line the given offset belongs to, along with the position within that sub-line. - fn line_and_position_for_offset( + /// + /// Returns: + /// + /// - The index of the line (zero-based) containing the offset. + /// - The index of the sub-line (zero-based) within the line containing the offset. + /// - The position of the offset. + pub(super) fn line_and_position_for_offset( &self, offset: usize, - lines: &[WrappedLine], - line_height: Pixels, ) -> (usize, usize, Option>) { + let Some(lines) = &self.last_layout else { + return (0, 0, None); + }; + let line_height = self.last_line_height; + let line_number_width = self.line_number_width; + let mut prev_lines_offset = 0; let mut y_offset = px(0.); for (line_index, line) in lines.iter().enumerate() { let local_offset = offset.saturating_sub(prev_lines_offset); if let Some(pos) = line.position_for_index(local_offset, line_height) { let sub_line_index = (pos.y.0 / line_height.0) as usize; - let adjusted_pos = point(pos.x, pos.y + y_offset); + let adjusted_pos = point(pos.x + line_number_width, pos.y + y_offset); return (line_index, sub_line_index, Some(adjusted_pos)); } @@ -543,7 +553,7 @@ impl InputState { let offset = self.cursor_offset(); let line_height = self.last_line_height; let (current_line_index, current_sub_line, current_pos) = - self.line_and_position_for_offset(offset, lines, line_height); + self.line_and_position_for_offset(offset); let Some(current_pos) = current_pos else { return; @@ -1419,6 +1429,35 @@ impl InputState { self.selected_word_range = None; } + pub(super) fn on_mouse_move( + &mut self, + event: &MouseMoveEvent, + window: &mut Window, + cx: &mut Context, + ) { + let offset = self.index_for_mouse_position(event.position, window, cx); + if let Some(marker) = self.mode.marker_for_offset(offset) { + if let Some(diagnostic_popover) = self.diagnostic_popover.as_ref() { + if diagnostic_popover.read(cx).marker.range == marker.range { + diagnostic_popover.update(cx, |this, cx| { + this.show(cx); + }); + + return; + } + } + + self.diagnostic_popover = Some(DiagnosticPopover::new(marker, cx.entity(), cx)); + cx.notify(); + } else { + if let Some(diagnostic_popover) = self.diagnostic_popover.as_mut() { + diagnostic_popover.update(cx, |this, cx| { + this.check_to_hide(event.position, cx); + }) + } + } + } + pub(super) fn on_scroll_wheel( &mut self, event: &ScrollWheelEvent, @@ -1427,6 +1466,7 @@ impl InputState { ) { let delta = event.delta.pixel_delta(self.last_line_height); self.update_scroll_offset(Some(self.scroll_handle.offset() + delta), cx); + self.diagnostic_popover = None; } fn update_scroll_offset(&mut self, offset: Option>, cx: &mut Context) { @@ -1751,7 +1791,7 @@ impl InputState { cx.notify() } - fn offset_from_utf16(&self, offset: usize) -> usize { + pub(super) fn offset_from_utf16(&self, offset: usize) -> usize { let mut utf8_offset = 0; let mut utf16_count = 0; @@ -1766,7 +1806,7 @@ impl InputState { utf8_offset } - fn offset_to_utf16(&self, offset: usize) -> usize { + pub(super) fn offset_to_utf16(&self, offset: usize) -> usize { let mut utf16_offset = 0; let mut utf8_count = 0; @@ -1781,11 +1821,11 @@ impl InputState { utf16_offset } - fn range_to_utf16(&self, range: &Range) -> Range { + pub(super) fn range_to_utf16(&self, range: &Range) -> Range { self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end) } - fn range_from_utf16(&self, range_utf16: &Range) -> Range { + pub(super) fn range_from_utf16(&self, range_utf16: &Range) -> Range { self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end) } @@ -2164,5 +2204,6 @@ impl Render for InputState { .flex_grow() .overflow_x_hidden() .child(TextElement::new(cx.entity().clone()).placeholder(self.placeholder.clone())) + .children(self.diagnostic_popover.clone()) } } diff --git a/crates/ui/src/input/text_input.rs b/crates/ui/src/input/text_input.rs index 355a0ed2..308fee9f 100644 --- a/crates/ui/src/input/text_input.rs +++ b/crates/ui/src/input/text_input.rs @@ -229,6 +229,7 @@ impl RenderOnce for TextInput { MouseButton::Left, window.listener_for(&self.state, InputState::on_mouse_up), ) + .on_mouse_move(window.listener_for(&self.state, InputState::on_mouse_move)) .on_scroll_wheel(window.listener_for(&self.state, InputState::on_scroll_wheel)) .size_full() .line_height(LINE_HEIGHT)