From a8f5ced4e661281670ea5bbe81c690ce0af14740 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 5 Jun 2025 23:04:01 +0800 Subject: [PATCH] code-editor: Add markers for display diagnostics to CodeEditor. (#920) Ref: https://microsoft.github.io/monaco-editor/typedoc/functions/editor.setModelMarkers.html ![image](https://github.com/user-attachments/assets/aad6a813-54a2-43d5-9623-c57a662f7125) ![image](https://github.com/user-attachments/assets/a88b9ac9-293f-4d1e-a525-49bfa0466885) --- crates/story/examples/code-editor.rs | 22 +- crates/ui/src/highlighter/highlighter.rs | 32 +-- crates/ui/src/highlighter/registry.rs | 83 +++++- crates/ui/src/highlighter/themes/dark.json | 284 ++++++++++-------- crates/ui/src/highlighter/themes/light.json | 304 +++++++++++--------- crates/ui/src/input/code_highlighter.rs | 36 --- crates/ui/src/input/element.rs | 77 +++-- crates/ui/src/input/marker.rs | 125 ++++++++ crates/ui/src/input/mod.rs | 3 +- crates/ui/src/input/mode.rs | 18 +- crates/ui/src/input/state.rs | 19 ++ crates/ui/src/input/text_wrapper.rs | 6 +- 12 files changed, 674 insertions(+), 335 deletions(-) delete mode 100644 crates/ui/src/input/code_highlighter.rs create mode 100644 crates/ui/src/input/marker.rs diff --git a/crates/story/examples/code-editor.rs b/crates/story/examples/code-editor.rs index 6bb82c37..cd2988cb 100644 --- a/crates/story/examples/code-editor.rs +++ b/crates/story/examples/code-editor.rs @@ -4,7 +4,7 @@ use gpui_component::{ dropdown::{Dropdown, DropdownEvent, DropdownState}, h_flex, highlighter::Language, - input::{InputEvent, InputState, TabSize, TextInput}, + input::{InputEvent, InputState, Marker, TabSize, TextInput}, v_flex, }; use story::Assets; @@ -84,6 +84,25 @@ impl Example { cx.new(|cx| Self::new(window, cx)) } + fn set_markers(&mut self, window: &mut Window, cx: &mut Context) { + if self.language.name() != "rust" { + return; + } + + self.input_state.update(cx, |state, cx| { + state.set_markers( + 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("hint", (36, 9), (40, 10), "This is a hint message."), + ], + window, + cx, + ); + }); + } + fn update_highlighter(&mut self, window: &mut Window, cx: &mut Context) { if !self.need_update { return; @@ -103,6 +122,7 @@ impl Example { impl Render for Example { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.update_highlighter(window, cx); + self.set_markers(window, cx); v_flex() .size_full() diff --git a/crates/ui/src/highlighter/highlighter.rs b/crates/ui/src/highlighter/highlighter.rs index b9e065f9..335a0a2e 100644 --- a/crates/ui/src/highlighter/highlighter.rs +++ b/crates/ui/src/highlighter/highlighter.rs @@ -38,9 +38,10 @@ pub struct SyntaxHighlighter { /// Cache of highlight, the range is offset of the token in the tree. /// - /// The BTreeMap is ordered by the range from 0 to the end of the line. + /// The BTreeMap is ordered by the range in the entire text. /// - /// The `key` is the `start` of the range. + /// - The `key` is the `start` of the range. + /// -The `value` is a tuple of the range (in the entire text) and the highlight name. cache: BTreeMap, String)>, } @@ -539,42 +540,37 @@ impl SyntaxHighlighter { ) -> Vec<(Range, HighlightStyle)> { let mut styles = vec![]; let start_offset = range.start; - let line_len = range.len(); - let mut last_range = 0..0; + let mut last_range = start_offset..start_offset; // NOTE: the ranges in the cache may have duplicates, so we need to merge them. - for (_, (node_range, highlight_name)) in self.cache.range(range.start..) { + for (_, (node_range, name)) in self.cache.range(range.start..) { // TODO: If break, the `comment.doc` will not work. // Ref: https://github.com/longbridge/gpui-component/pull/904/commits/d8f886939d3b472f228c1ce72154a951e98f32c5 if node_range.end > range.end { break; } - let range_in_line = node_range.start.saturating_sub(start_offset) - ..node_range.end.saturating_sub(start_offset); + // let range_in_line = node_range.start..node_range.end; // Ensure every range is connected. - if last_range.end < range_in_line.start { - styles.push(( - last_range.end..range_in_line.start, - HighlightStyle::default(), - )); + if last_range.end < node_range.start { + styles.push((last_range.end..node_range.start, HighlightStyle::default())); } - let style = theme.style(&highlight_name).unwrap_or_default(); + let style = theme.style(&name).unwrap_or_default(); - styles.push((range_in_line.clone(), style)); - last_range = range_in_line; + styles.push((node_range.clone(), style)); + last_range = node_range.clone(); } // If the matched styles is empty, return a default range. if styles.len() == 0 { - return vec![(0..line_len, HighlightStyle::default())]; + return vec![(start_offset..range.end, HighlightStyle::default())]; } // Ensure the last range is connected to the end of the line. - if last_range.end < line_len { - styles.push((last_range.end..line_len, HighlightStyle::default())); + if last_range.end < range.end { + styles.push((last_range.end..range.end, HighlightStyle::default())); } styles diff --git a/crates/ui/src/highlighter/registry.rs b/crates/ui/src/highlighter/registry.rs index ec50a1d3..64e9d66a 100644 --- a/crates/ui/src/highlighter/registry.rs +++ b/crates/ui/src/highlighter/registry.rs @@ -65,7 +65,7 @@ const DEFAULT_LIGHT: LazyLock = LazyLock::new(|| { /// Theme for Tree-sitter Highlight /// /// https://docs.rs/tree-sitter-highlight/0.25.4/tree_sitter_highlight/ -#[derive(Debug, Clone, PartialEq, Eq, Hash, JsonSchema, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, JsonSchema, Serialize, Deserialize)] pub struct SyntaxColors { pub attribute: Option, pub boolean: Option, @@ -232,7 +232,83 @@ impl SyntaxColors { } } -#[derive(Debug, Clone, PartialEq, Eq, Hash, JsonSchema, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, JsonSchema, Serialize, Deserialize)] +pub struct StatusColors { + #[serde(rename = "error")] + error: Option, + #[serde(rename = "error.background")] + error_background: Option, + #[serde(rename = "warning")] + warning: Option, + #[serde(rename = "warning.background")] + warning_background: Option, + #[serde(rename = "info")] + info: Option, + #[serde(rename = "info.background")] + info_background: Option, + #[serde(rename = "success")] + success: Option, + #[serde(rename = "success.background")] + success_background: Option, + #[serde(rename = "hint")] + hint: Option, + #[serde(rename = "hint.background")] + hint_background: Option, +} + +impl StatusColors { + #[inline] + pub fn error(&self) -> Hsla { + self.error.unwrap_or(crate::red_500()) + } + + #[inline] + pub fn error_background(&self) -> Hsla { + self.error_background.unwrap_or(self.error()) + } + + #[inline] + pub fn warning(&self) -> Hsla { + self.warning.unwrap_or(crate::yellow_500()) + } + + #[inline] + pub fn warning_background(&self) -> Hsla { + self.warning_background.unwrap_or(self.warning()) + } + + #[inline] + pub fn info(&self) -> Hsla { + self.info.unwrap_or(crate::blue_500()) + } + + #[inline] + pub fn info_background(&self) -> Hsla { + self.info_background.unwrap_or(self.info()) + } + + #[inline] + pub fn success(&self) -> Hsla { + self.success.unwrap_or(crate::green_500()) + } + + #[inline] + pub fn success_background(&self) -> Hsla { + self.success_background.unwrap_or(self.success()) + } + + #[inline] + pub fn hint(&self) -> Hsla { + self.hint.unwrap_or(crate::cyan_500().opacity(0.5)) + } + + #[inline] + pub fn hint_background(&self) -> Hsla { + self.hint_background.unwrap_or(self.hint()) + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, JsonSchema, Serialize, Deserialize)] pub struct HighlightThemeStyle { #[serde(rename = "editor.background")] pub background: Option, @@ -244,6 +320,9 @@ pub struct HighlightThemeStyle { pub line_number: Option, #[serde(rename = "editor.active_line_number")] pub active_line_number: Option, + #[serde(flatten)] + pub status: StatusColors, + #[serde(rename = "syntax")] pub syntax: SyntaxColors, } diff --git a/crates/ui/src/highlighter/themes/dark.json b/crates/ui/src/highlighter/themes/dark.json index 3be8a520..66c6b207 100644 --- a/crates/ui/src/highlighter/themes/dark.json +++ b/crates/ui/src/highlighter/themes/dark.json @@ -1,123 +1,165 @@ { - "name": "macOS Classic Dark", - "appearance": "dark", - "style": { - "editor.foreground": "#DDDDDD", - "editor.background": "#131313", - "editor.active_line.background": "#272727", - "editor.line_number": "#8F8F8F", - "editor.active_line_number": "#DDDDDD", - "syntax": { - "attribute": { - "color": "#be9a52", - "font_style": null, - "font_weight": null - }, - "boolean": { - "color": "#E1D797", - "font_style": null, - "font_weight": null - }, - "comment": { - "color": "#9E9E9E", - "font_style": null, - "font_weight": null - }, - "comment.doc": { - "color": "#9E9E9E", - "font_style": null, - "font_weight": null - }, - "constant": { - "color": "#E1D797", - "font_style": null, - "font_weight": null - }, - "constructor": { - "color": "#b5af9a", - "font_style": null, - "font_weight": null - }, - "embedded": { - "color": "#CACCCA", - "font_style": null, - "font_weight": null - }, - "function": { - "color": "#E1D797", - "font_style": null, - "font_weight": null - }, - "keyword": { - "color": "#E19773", - "font_style": null, - "font_weight": null - }, - "link_text": { - "color": "#A86D3B", - "font_style": "normal", - "font_weight": null - }, - "link_uri": { - "color": "#6F6D66", - "font_style": "italic", - "font_weight": null - }, - "number": { - "color": "#E19773", - "font_style": null, - "font_weight": null - }, - "string": { - "color": "#76BA53", - "font_style": null, - "font_weight": null - }, - "string.escape": { - "color": "#76BA53", - "font_style": null, - "font_weight": null - }, - "string.regex": { - "color": "#76BA53", - "font_style": null, - "font_weight": null - }, - "string.special": { - "color": "#E1D797", - "font_style": null, - "font_weight": null - }, - "string.special.symbol": { - "color": "#E1D797", - "font_style": null, - "font_weight": null - }, - "tag": { - "color": "#b5af9a", - "font_style": null, - "font_weight": null - }, - "text.literal": { - "color": "#E1D797", - "font_style": null, - "font_weight": null - }, - "title": { - "color": "#A76D3B", - "font_style": null, - "font_weight": 600 - }, - "type": { - "color": "#A86D3B", - "font_style": null, - "font_weight": null - }, - "variable.special": { - "color": "#E19773", - "font_style": null, - "font_weight": null - } - } + "name": "macOS Classic Dark", + "appearance": "dark", + "style": { + "editor.foreground": "#DDDDDD", + "editor.background": "#131313", + "editor.active_line.background": "#272727", + "editor.line_number": "#8F8F8F", + "editor.active_line_number": "#DDDDDD", + "conflict": "#D2602D", + "conflict.background": null, + "conflict.border": null, + "created": "#3f72e2", + "created.background": "#0C4619", + "created.border": null, + "deleted": null, + "deleted.background": "#46190C", + "deleted.border": null, + "error": null, + "error.background": "#46190C", + "error.border": "#802207", + "hidden": "#9E9E9E", + "hidden.background": null, + "hidden.border": null, + "hint": null, + "hint.background": "#0C194D", + "hint.border": "#082190", + "ignored": null, + "ignored.background": null, + "ignored.border": null, + "info": null, + "info.background": "#0C194D", + "info.border": "#082190", + "modified": "#B0A878", + "modified.background": "#3A310E", + "modified.border": null, + "predictive": "#5D5945", + "predictive.background": null, + "predictive.border": null, + "renamed": null, + "renamed.background": null, + "renamed.border": null, + "success": null, + "success.background": "#0C4619", + "success.border": null, + "unreachable": null, + "unreachable.background": null, + "unreachable.border": null, + "warning": null, + "warning.background": "#3A310E", + "warning.border": "#7B6508", + "syntax": { + "attribute": { + "color": "#be9a52", + "font_style": null, + "font_weight": null + }, + "boolean": { + "color": "#E1D797", + "font_style": null, + "font_weight": null + }, + "comment": { + "color": "#9E9E9E", + "font_style": null, + "font_weight": null + }, + "comment.doc": { + "color": "#9E9E9E", + "font_style": null, + "font_weight": null + }, + "constant": { + "color": "#E1D797", + "font_style": null, + "font_weight": null + }, + "constructor": { + "color": "#b5af9a", + "font_style": null, + "font_weight": null + }, + "embedded": { + "color": "#CACCCA", + "font_style": null, + "font_weight": null + }, + "function": { + "color": "#E1D797", + "font_style": null, + "font_weight": null + }, + "keyword": { + "color": "#E19773", + "font_style": null, + "font_weight": null + }, + "link_text": { + "color": "#A86D3B", + "font_style": "normal", + "font_weight": null + }, + "link_uri": { + "color": "#6F6D66", + "font_style": "italic", + "font_weight": null + }, + "number": { + "color": "#E19773", + "font_style": null, + "font_weight": null + }, + "string": { + "color": "#76BA53", + "font_style": null, + "font_weight": null + }, + "string.escape": { + "color": "#76BA53", + "font_style": null, + "font_weight": null + }, + "string.regex": { + "color": "#76BA53", + "font_style": null, + "font_weight": null + }, + "string.special": { + "color": "#E1D797", + "font_style": null, + "font_weight": null + }, + "string.special.symbol": { + "color": "#E1D797", + "font_style": null, + "font_weight": null + }, + "tag": { + "color": "#b5af9a", + "font_style": null, + "font_weight": null + }, + "text.literal": { + "color": "#E1D797", + "font_style": null, + "font_weight": null + }, + "title": { + "color": "#A76D3B", + "font_style": null, + "font_weight": 600 + }, + "type": { + "color": "#A86D3B", + "font_style": null, + "font_weight": null + }, + "variable.special": { + "color": "#E19773", + "font_style": null, + "font_weight": null + } } -} \ 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 35a92507..74dc55f1 100644 --- a/crates/ui/src/highlighter/themes/light.json +++ b/crates/ui/src/highlighter/themes/light.json @@ -1,133 +1,175 @@ { - "name": "macOS Classic Light", - "appearance": "light", - "style": { - "editor.foreground": "#000000", - "editor.background": "#ffffff", - "editor.active_line.background": "#F0F0F0", - "editor.line_number": "#929292", - "editor.active_line_number": "#000000", - "syntax": { - "attribute": { - "color": "#957931", - "font_style": null, - "font_weight": null - }, - "boolean": { - "color": "#C5060B", - "font_style": null, - "font_weight": null - }, - "comment": { - "color": "#007fff", - "font_style": null, - "font_weight": null - }, - "comment.doc": { - "color": "#007fff", - "font_style": null, - "font_weight": null - }, - "constant": { - "color": "#C5060B", - "font_style": null, - "font_weight": null - }, - "constructor": { - "color": "#0433ff", - "font_style": null, - "font_weight": null - }, - "embedded": { - "color": "#333333", - "font_style": null, - "font_weight": null - }, - "function": { - "color": "#0000A2", - "font_style": null, - "font_weight": null - }, - "keyword": { - "color": "#0433ff", - "font_style": null, - "font_weight": null - }, - "link_text": { - "color": "#0000A2", - "font_style": "normal", - "font_weight": null - }, - "link_uri": { - "color": "#6A7293", - "font_style": "italic", - "font_weight": null - }, - "number": { - "color": "#0433ff", - "font_style": null, - "font_weight": null - }, - "string": { - "color": "#036A07", - "font_style": null, - "font_weight": null - }, - "string.escape": { - "color": "#036A07", - "font_style": null, - "font_weight": null - }, - "string.regex": { - "color": "#036A07", - "font_style": null, - "font_weight": null - }, - "string.special": { - "color": "#d21f07", - "font_style": null, - "font_weight": null - }, - "string.special.symbol": { - "color": "#d21f07", - "font_style": null, - "font_weight": null - }, - "tag": { - "color": "#0433ff", - "font_style": null, - "font_weight": null - }, - "text.literal": { - "color": "#6F42C1", - "font_style": null, - "font_weight": null - }, - "title": { - "color": "#0433FF", - "font_style": null, - "font_weight": null - }, - "type": { - "color": "#6f42c1", - "font_style": null, - "font_weight": null - }, - "property": { - "color": "#333333", - "font_style": null, - "font_weight": null - }, - "variable": { - "color": "#333333", - "font_style": null, - "font_weight": null - }, - "variable.special": { - "color": "#C5060B", - "font_style": null, - "font_weight": null - } - } + "name": "macOS Classic Light", + "appearance": "light", + "style": { + "editor.foreground": "#000000", + "editor.background": "#ffffff", + "editor.active_line.background": "#F0F0F0", + "editor.line_number": "#929292", + "editor.active_line_number": "#000000", + "conflict": "#C5060B", + "conflict.background": null, + "conflict.border": null, + "created": "#1642FF", + "created.background": "#e5ffe9", + "created.border": null, + "deleted": null, + "deleted.background": "#FBEAE5", + "deleted.border": null, + "error": null, + "error.background": "#FBEAE5", + "error.border": "#EC9F89", + "hidden": "#6D6D6D", + "hidden.background": null, + "hidden.border": null, + "hint": null, + "hint.background": "#E5F2FF", + "hint.border": "#99CCFF", + "ignored": null, + "ignored.background": null, + "ignored.border": null, + "info": null, + "info.background": "#E5EAFF", + "info.border": "#8DA1FF", + "modified": "#9e7008", + "modified.background": "#fff2e5", + "modified.border": null, + "predictive": "#A4ABB6", + "predictive.background": null, + "predictive.border": null, + "renamed": null, + "renamed.background": null, + "renamed.border": null, + "success": null, + "success.background": "#E5FFE5", + "success.border": null, + "unreachable": null, + "unreachable.background": null, + "unreachable.border": null, + "warning": "#C99401", + "warning.background": "#FFFBE5", + "warning.border": "#D9CC89", + "syntax": { + "attribute": { + "color": "#957931", + "font_style": null, + "font_weight": null + }, + "boolean": { + "color": "#C5060B", + "font_style": null, + "font_weight": null + }, + "comment": { + "color": "#007fff", + "font_style": null, + "font_weight": null + }, + "comment.doc": { + "color": "#007fff", + "font_style": null, + "font_weight": null + }, + "constant": { + "color": "#C5060B", + "font_style": null, + "font_weight": null + }, + "constructor": { + "color": "#0433ff", + "font_style": null, + "font_weight": null + }, + "embedded": { + "color": "#333333", + "font_style": null, + "font_weight": null + }, + "function": { + "color": "#0000A2", + "font_style": null, + "font_weight": null + }, + "keyword": { + "color": "#0433ff", + "font_style": null, + "font_weight": null + }, + "link_text": { + "color": "#0000A2", + "font_style": "normal", + "font_weight": null + }, + "link_uri": { + "color": "#6A7293", + "font_style": "italic", + "font_weight": null + }, + "number": { + "color": "#0433ff", + "font_style": null, + "font_weight": null + }, + "string": { + "color": "#036A07", + "font_style": null, + "font_weight": null + }, + "string.escape": { + "color": "#036A07", + "font_style": null, + "font_weight": null + }, + "string.regex": { + "color": "#036A07", + "font_style": null, + "font_weight": null + }, + "string.special": { + "color": "#d21f07", + "font_style": null, + "font_weight": null + }, + "string.special.symbol": { + "color": "#d21f07", + "font_style": null, + "font_weight": null + }, + "tag": { + "color": "#0433ff", + "font_style": null, + "font_weight": null + }, + "text.literal": { + "color": "#6F42C1", + "font_style": null, + "font_weight": null + }, + "title": { + "color": "#0433FF", + "font_style": null, + "font_weight": null + }, + "type": { + "color": "#6f42c1", + "font_style": null, + "font_weight": null + }, + "property": { + "color": "#333333", + "font_style": null, + "font_weight": null + }, + "variable": { + "color": "#333333", + "font_style": null, + "font_weight": null + }, + "variable.special": { + "color": "#C5060B", + "font_style": null, + "font_weight": null + } } -} \ No newline at end of file + } +} diff --git a/crates/ui/src/input/code_highlighter.rs b/crates/ui/src/input/code_highlighter.rs deleted file mode 100644 index aa47d260..00000000 --- a/crates/ui/src/input/code_highlighter.rs +++ /dev/null @@ -1,36 +0,0 @@ -use gpui::{HighlightStyle, TextRun, TextStyle}; -use std::{ops::Range, rc::Rc}; - -#[derive(Debug, Clone)] -pub(crate) struct LineHighlightStyle { - pub(crate) offset: usize, - pub(crate) styles: Rc, HighlightStyle)>>, -} - -impl LineHighlightStyle { - pub(super) fn to_run( - &self, - text_style: &TextStyle, - marked_range: &Option>, - marked_run: &TextRun, - ) -> Vec { - self.styles - .iter() - .map(|(range, style)| { - let mut run = text_style.clone().highlight(*style).to_run(range.len()); - if let Some(marked_range) = marked_range { - if self.offset + range.start >= marked_range.start - && self.offset + range.end <= marked_range.end - { - run.color = marked_run.color; - run.strikethrough = marked_run.strikethrough; - run.underline = marked_run.underline; - } - } - run - }) - // Add last `\n` Run with len 1 - .chain(std::iter::once(text_style.clone().to_run(1))) - .collect() - } -} diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index af396ae6..1033eadf 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -2,14 +2,15 @@ use std::{ops::Range, rc::Rc}; use gpui::{ fill, point, px, relative, size, App, Bounds, Corners, Element, ElementId, ElementInputHandler, - Entity, GlobalElementId, IntoElement, LayoutId, MouseButton, MouseMoveEvent, Path, Pixels, - Point, SharedString, Size, Style, TextAlign, TextRun, UnderlineStyle, Window, WrappedLine, + Entity, GlobalElementId, HighlightStyle, IntoElement, LayoutId, MouseButton, MouseMoveEvent, + Path, Pixels, Point, SharedString, Size, Style, TextAlign, TextRun, UnderlineStyle, Window, + WrappedLine, }; use smallvec::SmallVec; use crate::{highlighter::LanguageRegistry, ActiveTheme as _, Root}; -use super::{code_highlighter::LineHighlightStyle, mode::InputMode, InputState, LastLayout}; +use super::{mode::InputMode, InputState, LastLayout}; const RIGHT_MARGIN: Pixels = px(5.); const BOTTOM_MARGIN_ROWS: usize = 1; @@ -358,19 +359,25 @@ impl TextElement { &mut self, visible_range: &Range, cx: &mut App, - ) -> Option<(usize, Vec)> { + ) -> Option<(usize, Vec<(Range, HighlightStyle)>)> { let theme = LanguageRegistry::global(cx) .theme(cx.theme().is_dark()) .clone(); - self.input.update(cx, |state, _| match &mut state.mode { - InputMode::CodeEditor { highlighter, .. } => { + self.input.update(cx, |state, _| match &state.mode { + InputMode::CodeEditor { + highlighter, + markers, + .. + } => { let mut offset = 0; let mut skipped_offset = 0; - let mut lines = vec![]; + let mut styles = vec![]; for (ix, line) in state.text.split('\n').enumerate() { + // +1 for last `\n`. + let line_len = line.len() + 1; if ix < visible_range.start { - offset += line.len() + 1; + offset += line_len; skipped_offset = offset; continue; } @@ -378,16 +385,34 @@ impl TextElement { break; } - let range = offset..offset + line.len(); - let styles = highlighter.borrow().styles(&range, &theme); + let range = offset..offset + line_len; + let line_styles = highlighter.borrow().styles(&range, &theme); - lines.push(LineHighlightStyle { - offset, - styles: Rc::new(styles), - }); - offset += line.len() + 1; + styles = gpui::combine_highlights(styles, line_styles).collect(); + + offset = range.end; } - Some((skipped_offset, lines)) + + let mut marker_styles = vec![]; + for marker in markers.iter() { + if let Some(range) = marker.byte_range(&state) { + if range.start < skipped_offset { + continue; + } + + let node_range = range.start..range.end; + if node_range.start >= visible_range.start + || node_range.end <= visible_range.end + { + marker_styles + .push((node_range, marker.severity.highlight_style(&theme))); + } + } + } + + styles = gpui::combine_highlights(marker_styles, styles).collect(); + + Some((skipped_offset, styles)) } _ => None, }) @@ -502,7 +527,7 @@ impl Element for TextElement { let line_height = window.line_height(); let visible_range = self.calculate_visible_range(&state, line_height, &bounds); - let highlight_lines = self.highlight_lines(&visible_range, cx); + let highlight_styles = self.highlight_lines(&visible_range, cx); let multi_line = self.input.read(cx).is_multi_line(); let input = self.input.read(cx); @@ -572,7 +597,7 @@ impl Element for TextElement { }; let runs = if !is_empty { - if let Some((skipped_offset, highlight_lines)) = highlight_lines { + if let Some((skipped_offset, highlight_styles)) = highlight_styles { let mut runs = vec![]; if skipped_offset > 0 { runs.push(TextRun { @@ -581,9 +606,19 @@ impl Element for TextElement { }); } - for style in highlight_lines { - runs.extend(style.to_run(&text_style, &input.marked_range, &marked_run)); - } + runs.extend(highlight_styles.iter().map(|(range, style)| { + let mut run = text_style.clone().highlight(*style).to_run(range.len()); + if let Some(marked_range) = &input.marked_range { + if range.start >= marked_range.start && range.end <= marked_range.end { + run.color = marked_run.color; + run.strikethrough = marked_run.strikethrough; + run.underline = marked_run.underline; + } + } + + run + })); + runs.into_iter().filter(|run| run.len > 0).collect() } else { vec![run] diff --git a/crates/ui/src/input/marker.rs b/crates/ui/src/input/marker.rs new file mode 100644 index 00000000..33268b5c --- /dev/null +++ b/crates/ui/src/input/marker.rs @@ -0,0 +1,125 @@ +use crate::{highlighter::HighlightTheme, input::InputState}; +use gpui::{px, HighlightStyle, SharedString, UnderlineStyle}; +use itertools::Itertools; +use std::ops::Range; + +/// Marker represents a diagnostic message, such as an error or warning, in the code editor. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Marker { + pub severity: MarkerSeverity, + pub start: LineColumn, + pub end: LineColumn, + /// The message associated with the marker, typically a description of the issue. + pub message: SharedString, +} + +impl Marker { + /// Creates a new marker with the specified severity, start and end positions, and message. + pub fn new( + severity: impl Into, + start: impl Into, + end: impl Into, + message: impl Into, + ) -> Self { + Self { + severity: severity.into(), + start: start.into(), + end: end.into(), + message: message.into(), + } + } + + /// 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 + .text_wrapper + .lines + .get(self.start.line.saturating_sub(1))?; + let start_line_str = state.text.get(start_line.range.clone())?; + + let end_line = state + .text_wrapper + .lines + .get(self.end.line.saturating_sub(1))?; + let end_line_str = state.text.get(end_line.range.clone())?; + + let start_byte = start_line.range.start + + start_line_str + .chars() + .take(self.start.column.saturating_sub(1)) + .counts_by(|c| c.len_utf8()) + .values() + .sum::(); + let end_byte = end_line.range.start + + end_line_str + .chars() + .take(self.end.column.saturating_sub(1)) + .counts_by(|c| c.len_utf8()) + .values() + .sum::(); + + Some(start_byte..end_byte) + } +} + +/// Line and column position (1-based) in the source code. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct LineColumn { + /// Line number (1-based) + pub line: usize, + /// Column number (1-based) + pub column: usize, +} + +impl From<(usize, usize)> for LineColumn { + fn from(value: (usize, usize)) -> Self { + Self { + line: value.0.max(1), + column: value.1.max(1), + } + } +} + +/// Severity of the marker. +#[allow(unused)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MarkerSeverity { + #[default] + Hint, + Error, + Warning, + Info, +} + +impl From<&str> for MarkerSeverity { + fn from(value: &str) -> Self { + match value { + "error" => Self::Error, + "warning" => Self::Warning, + "info" => Self::Info, + "hint" => Self::Hint, + _ => Self::Info, // Default to Info if unknown + } + } +} + +impl MarkerSeverity { + /// Returns the [`HighlightStyle`] for the marker severity with the given theme style. + pub(super) fn highlight_style(&self, theme: &HighlightTheme) -> HighlightStyle { + let color = match self { + Self::Error => Some(theme.style.status.error()), + Self::Warning => Some(theme.style.status.warning()), + Self::Info => Some(theme.style.status.info()), + Self::Hint => Some(theme.style.status.hint()), + }; + + let mut style = HighlightStyle::default(); + style.underline = Some(UnderlineStyle { + color: color, + thickness: px(1.), + wavy: true, + }); + + style + } +} diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs index d8f1e6cf..d84cf0b4 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -1,8 +1,8 @@ mod blink_cursor; mod change; mod clear_button; -mod code_highlighter; mod element; +mod marker; mod mask_pattern; mod mode; mod number_input; @@ -12,6 +12,7 @@ mod text_input; mod text_wrapper; pub(crate) use clear_button::*; +pub use marker::*; pub use mask_pattern::MaskPattern; pub use mode::TabSize; pub use number_input::{NumberInput, NumberInputEvent, StepAction}; diff --git a/crates/ui/src/input/mode.rs b/crates/ui/src/input/mode.rs index 72eb007c..c9b3353a 100644 --- a/crates/ui/src/input/mode.rs +++ b/crates/ui/src/input/mode.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use gpui::{DefiniteLength, SharedString}; -use crate::highlighter::SyntaxHighlighter; +use crate::{highlighter::SyntaxHighlighter, input::marker::Marker}; use super::text_wrapper::TextWrapper; @@ -50,6 +50,7 @@ pub enum InputMode { /// Show line number line_number: bool, highlighter: Rc>, + markers: Vec, }, AutoGrow { rows: usize, @@ -160,6 +161,21 @@ impl InputMode { _ => None, } } + + #[allow(unused)] + pub(super) fn markers(&self) -> Option<&Vec> { + match &self { + InputMode::CodeEditor { markers, .. } => Some(markers), + _ => None, + } + } + + pub(super) fn clear_markers(&mut self) { + match self { + InputMode::CodeEditor { markers, .. } => markers.clear(), + _ => {} + } + } } #[cfg(test)] diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index b260707a..26030431 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -31,6 +31,7 @@ use super::{ text_wrapper::TextWrapper, }; use crate::highlighter::SyntaxHighlighter; +use crate::input::marker::Marker; use crate::{history::History, scroll::ScrollbarState, Root}; #[derive(Clone, PartialEq, Eq, Deserialize)] @@ -381,6 +382,7 @@ impl InputState { highlighter: Rc::new(RefCell::new(SyntaxHighlighter::new(&language))), line_number: true, height: Some(relative(1.)), + markers: vec![], }; self } @@ -451,6 +453,21 @@ impl InputState { cx.notify(); } + /// 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(); + } + } + /// Set placeholder pub fn set_placeholder( &mut self, @@ -1986,6 +2003,7 @@ impl EntityInputHandler for InputState { .borrow_mut() .update(&range, self.text.clone(), &new_text, cx); } + self.mode.clear_markers(); self.text_wrapper.update(self.text.clone(), false, cx); self.selected_range = new_pos..new_pos; self.marked_range.take(); @@ -2029,6 +2047,7 @@ impl EntityInputHandler for InputState { .borrow_mut() .update(&range, self.text.clone(), &new_text, cx); } + self.mode.clear_markers(); self.text_wrapper.update(self.text.clone(), false, cx); if new_text.is_empty() { // Cancel selection, when cancel IME input. diff --git a/crates/ui/src/input/text_wrapper.rs b/crates/ui/src/input/text_wrapper.rs index 31dff58b..e486c293 100644 --- a/crates/ui/src/input/text_wrapper.rs +++ b/crates/ui/src/input/text_wrapper.rs @@ -71,7 +71,7 @@ impl TextWrapper { .line_wrapper(self.font.clone(), self.font_size); let mut prev_line_ix = 0; - for line in text.lines() { + for line in text.split('\n') { let mut line_wraps = vec![]; let mut prev_boundary_ix = 0; @@ -83,7 +83,7 @@ impl TextWrapper { lines.push(LineWrap { wrap_lines: line_wraps.len(), - range: prev_line_ix..line.len(), + range: prev_line_ix..prev_line_ix + line.len(), }); wrapped_lines.extend(line_wraps); @@ -92,7 +92,7 @@ impl TextWrapper { wrapped_lines.push(prev_line_ix + prev_boundary_ix..prev_line_ix + line.len()); } - prev_line_ix += line.len(); + prev_line_ix += line.len() + 1; } // Add last empty line.