code-editor: Show hover popover for diagnostics. (#924)

<img width="719" alt="image"
src="https://github.com/user-attachments/assets/75fa9737-09e1-4baa-b7ac-304e847005c8"
/>
This commit is contained in:
Jason Lee 2025-06-09 16:16:15 +08:00 committed by GitHub
parent c3ec1deb97
commit 6aab9d3345
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 326 additions and 54 deletions

View file

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

View file

@ -247,22 +247,32 @@ pub struct StatusColors {
error: Option<Hsla>,
#[serde(rename = "error.background")]
error_background: Option<Hsla>,
#[serde(rename = "error.border")]
error_border: Option<Hsla>,
#[serde(rename = "warning")]
warning: Option<Hsla>,
#[serde(rename = "warning.background")]
warning_background: Option<Hsla>,
#[serde(rename = "warning.border")]
warning_border: Option<Hsla>,
#[serde(rename = "info")]
info: Option<Hsla>,
#[serde(rename = "info.background")]
info_background: Option<Hsla>,
#[serde(rename = "info.border")]
info_border: Option<Hsla>,
#[serde(rename = "success")]
success: Option<Hsla>,
#[serde(rename = "success.background")]
success_background: Option<Hsla>,
#[serde(rename = "success.border")]
success_border: Option<Hsla>,
#[serde(rename = "hint")]
hint: Option<Hsla>,
#[serde(rename = "hint.background")]
hint_background: Option<Hsla>,
#[serde(rename = "hint.border")]
hint_border: Option<Hsla>,
}
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())
}
}

View file

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

View file

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

View file

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

View file

@ -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<InputState>,
pub(super) marker: Rc<Marker>,
bounds: Bounds<Pixels>,
open: bool,
}
impl DiagnosticPopover {
pub fn new(marker: &Marker, state: Entity<InputState>, cx: &mut App) -> Entity<Self> {
let marker = Rc::new(marker.clone());
cx.new(|_| Self {
marker,
state,
bounds: Bounds::default(),
open: true,
})
}
fn origin(&self, cx: &App) -> Option<Point<Pixels>> {
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>) {
self.open = true;
cx.notify();
}
pub(super) fn hide(&mut self, cx: &mut Context<Self>) {
self.open = false;
cx.notify();
}
pub(super) fn check_to_hide(&mut self, mouse_position: Point<Pixels>, cx: &mut Context<Self>) {
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<Self>) -> 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()
}
}

View file

@ -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<Range<usize>>,
/// 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<Range<usize>> {
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::<usize>();
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()),

View file

@ -2,6 +2,7 @@ mod blink_cursor;
mod change;
mod clear_button;
mod element;
mod hover_popover;
mod marker;
mod mask_pattern;
mod mode;

View file

@ -51,7 +51,7 @@ pub enum InputMode {
line_number: bool,
language: SharedString,
highlighter: Rc<RefCell<Option<SyntaxHighlighter>>>,
markers: Vec<Marker>,
markers: Rc<Vec<Marker>>,
},
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<Marker>> {
match &self {
pub(super) fn markers(&self) -> Option<&Rc<Vec<Marker>>> {
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<Marker>) {
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)]

View file

@ -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<Entity<DiagnosticPopover>>,
/// To remember the horizontal column (x-coordinate) of the cursor position.
preferred_x_offset: Option<Pixels>,
_subscriptions: Vec<Subscription>,
@ -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<Marker>,
_: &mut Window,
cx: &mut Context<Self>,
) {
if let InputMode::CodeEditor { markers, .. } = &mut self.mode {
*markers = new_markers;
cx.notify();
pub fn set_markers(&mut self, markers: Vec<Marker>, _: &mut Window, _: &mut Context<Self>) {
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<Self>) {
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<Point<Pixels>>) {
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<Self>,
) {
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<Point<Pixels>>, cx: &mut Context<Self>) {
@ -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<usize>) -> Range<usize> {
pub(super) fn range_to_utf16(&self, range: &Range<usize>) -> Range<usize> {
self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end)
}
fn range_from_utf16(&self, range_utf16: &Range<usize>) -> Range<usize> {
pub(super) fn range_from_utf16(&self, range_utf16: &Range<usize>) -> Range<usize> {
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())
}
}

View file

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