Skip to main content

gpui_base/input/editor/
highlighting.rs

1use std::{ops::Range, rc::Rc, sync::Arc};
2
3use gpui::{AnyElement, Context, HighlightStyle, Hsla, SharedString, Window};
4use ropey::Rope;
5
6use super::{EditorState, FoldRange, InputEdit};
7use crate::SemanticThemeTokens;
8
9/// Resolves semantic highlight names into renderable GPUI styles.
10///
11/// Base deliberately knows nothing about a concrete syntax theme. UI crates and
12/// applications can provide any resolver, independently of their parser.
13pub trait HighlightStyleResolver: Send + Sync {
14    fn style(&self, name: &str) -> Option<HighlightStyle>;
15}
16
17#[derive(Default)]
18struct NoHighlightStyles;
19
20impl HighlightStyleResolver for NoHighlightStyles {
21    fn style(&self, _: &str) -> Option<HighlightStyle> {
22        None
23    }
24}
25
26/// Parser-independent syntax highlighting seam consumed by the Base editor.
27///
28/// Implementations own parsing, incremental state, and language-specific
29/// behavior. Base only asks for styled ranges and fold candidates.
30pub trait InputHighlighter {
31    fn language(&self) -> SharedString;
32
33    fn update(
34        &mut self,
35        edit: Option<InputEdit>,
36        text: &Rope,
37        folding: bool,
38        window: &mut Window,
39        cx: &mut Context<EditorState>,
40    );
41
42    /// Apply several edits made as one change, such as typing with multiple
43    /// cursors. Each entry is an edit with the text as it stood right after
44    /// it, in the order the edits were applied.
45    ///
46    /// The default hands each edit to [`Self::update`] in turn. Override it to
47    /// reparse once for the whole change.
48    fn update_batch(
49        &mut self,
50        edits: &[(InputEdit, Rope)],
51        folding: bool,
52        window: &mut Window,
53        cx: &mut Context<EditorState>,
54    ) {
55        for (edit, text) in edits {
56            self.update(Some(*edit), text, folding, window, cx);
57        }
58    }
59
60    /// Return ordered, non-overlapping style runs that fully cover `range`.
61    /// Use [`HighlightStyle::default`] for text without a semantic style.
62    fn styles(
63        &self,
64        range: &Range<usize>,
65        resolver: &dyn HighlightStyleResolver,
66    ) -> Vec<(Range<usize>, HighlightStyle)>;
67
68    fn fold_ranges(&self, text: &Rope) -> Vec<FoldRange>;
69
70    fn fold_ranges_for_edit(&self, range: Range<usize>, text: &Rope) -> Vec<FoldRange> {
71        let _ = range;
72        self.fold_ranges(text)
73    }
74}
75
76pub type InputHighlighterFactory = Rc<dyn Fn(&str) -> Option<Box<dyn InputHighlighter>>>;
77pub type SharedHighlightStyleResolver = Arc<dyn HighlightStyleResolver>;
78pub type FoldIconRenderer = Rc<dyn Fn(usize, bool) -> AnyElement>;
79pub type FoldPlaceholderRenderer = Rc<dyn Fn(usize) -> AnyElement>;
80
81/// Where in the syntax tree an offset sits, for editing decisions.
82///
83/// Parser-independent: `gpui-component` answers from tree-sitter, apps may
84/// answer heuristically. `None` (no provider installed) means `Code`.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum SyntaxContext {
87    Code,
88    String,
89    Comment,
90}
91
92/// Answers syntax context for editing decisions (pairing, skip, indent).
93///
94/// Created per editor by the application's [`super::LanguageProvider`].
95/// Base never imports a parser; implementations live in UI crates or apps.
96pub trait SyntaxContextProvider {
97    fn context_at(&self, text: &Rope, offset: usize) -> SyntaxContext;
98}
99
100#[derive(Clone, Copy, Default)]
101pub struct DiagnosticColors {
102    pub error: Hsla,
103    pub warning: Hsla,
104    pub info: Hsla,
105    pub hint: Hsla,
106}
107
108/// Application-owned colors and highlight resolver consumed by editor painting.
109#[derive(Clone)]
110pub struct InputEditorStyle {
111    pub foreground: Hsla,
112    pub muted_foreground: Hsla,
113    pub background: Hsla,
114    pub border: Hsla,
115    pub selection: Hsla,
116    pub caret: Hsla,
117    pub diagnostics: DiagnosticColors,
118    pub highlight_styles: SharedHighlightStyleResolver,
119    pub editor_invisible: Option<Hsla>,
120    pub editor_active_line: Option<Hsla>,
121    pub editor_gutter_background: Option<Hsla>,
122    pub fold_icon_renderer: Option<FoldIconRenderer>,
123}
124
125impl InputEditorStyle {
126    /// Fills in every colour that was left unset, from the active palette.
127    ///
128    /// `Hsla::default()` is fully transparent, and every colour on `Default` is
129    /// that — so an input nothing projected onto painted its glyphs, its caret
130    /// and its selection in nothing at all. Transparent is not a colour anyone
131    /// means for ink, which is what makes it usable as "unset" here.
132    ///
133    /// This is resolution, not assignment: whatever a consumer did project is
134    /// kept exactly. `crates/component` projects the whole style on every render and
135    /// never reaches this; a consumer that projects once at construction gets
136    /// the palette that is current now rather than the one that happened to be
137    /// installed when the state was built.
138    pub fn resolved(&self, tokens: &SemanticThemeTokens) -> Self {
139        let colors = &tokens.colors;
140        let unset = |value: Hsla| value.a == 0.;
141        let or = |value: Hsla, fallback: Hsla| if unset(value) { fallback } else { value };
142
143        let foreground = or(self.foreground, colors.foreground);
144        let mut selection = self.selection;
145        if unset(selection) {
146            selection = colors.accent;
147            // A selection must not hide the glyphs it selects.
148            selection.a = 0.4;
149        }
150
151        Self {
152            foreground,
153            muted_foreground: or(self.muted_foreground, colors.muted_foreground),
154            background: or(self.background, colors.surface),
155            border: or(self.border, colors.border),
156            selection,
157            caret: or(self.caret, foreground),
158            ..self.clone()
159        }
160    }
161}
162
163impl Default for InputEditorStyle {
164    fn default() -> Self {
165        Self {
166            foreground: Hsla::default(),
167            muted_foreground: Hsla::default(),
168            background: Hsla::default(),
169            border: Hsla::default(),
170            selection: Hsla::default(),
171            caret: Hsla::default(),
172            diagnostics: DiagnosticColors::default(),
173            highlight_styles: Arc::new(NoHighlightStyles),
174            editor_invisible: None,
175            editor_active_line: None,
176            editor_gutter_background: None,
177            fold_icon_renderer: None,
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use gpui::hsla;
185
186    use super::InputEditorStyle;
187    use crate::SemanticThemeTokens;
188
189    fn dark() -> SemanticThemeTokens {
190        let mut tokens = SemanticThemeTokens::default();
191        tokens.colors.foreground = hsla(0., 0., 0.98, 1.0);
192        tokens.colors.muted_foreground = hsla(0., 0., 0.64, 1.0);
193        tokens.colors.surface = hsla(0., 0., 0.04, 1.0);
194        tokens.colors.border = hsla(0., 0., 0.15, 1.0);
195        tokens.colors.accent = hsla(0.6, 0.5, 0.5, 1.0);
196        tokens
197    }
198
199    #[test]
200    fn an_unprojected_style_takes_its_ink_from_the_palette() {
201        let tokens = dark();
202        let resolved = InputEditorStyle::default().resolved(&tokens);
203
204        assert_eq!(resolved.foreground, tokens.colors.foreground);
205        assert_eq!(resolved.caret, tokens.colors.foreground);
206        assert_eq!(resolved.muted_foreground, tokens.colors.muted_foreground);
207        assert_eq!(resolved.background, tokens.colors.surface);
208        assert_eq!(resolved.border, tokens.colors.border);
209        // The point of the change: every one of these was transparent, so an
210        // input nothing projected onto painted its text in nothing at all.
211        for colour in [
212            resolved.foreground,
213            resolved.caret,
214            resolved.muted_foreground,
215            resolved.selection,
216        ] {
217            assert!(colour.a > 0., "{colour:?} is still invisible");
218        }
219    }
220
221    #[test]
222    fn a_selection_stays_translucent_enough_to_read_through() {
223        let resolved = InputEditorStyle::default().resolved(&dark());
224        assert_eq!(resolved.selection.a, 0.4);
225    }
226
227    #[test]
228    fn projected_colours_are_kept_verbatim() {
229        let chosen = hsla(0.3, 0.4, 0.5, 1.0);
230        let style = InputEditorStyle {
231            foreground: chosen,
232            caret: chosen,
233            ..Default::default()
234        };
235        let resolved = style.resolved(&dark());
236
237        assert_eq!(resolved.foreground, chosen);
238        assert_eq!(resolved.caret, chosen);
239        // And what was not projected still comes from the palette.
240        assert_eq!(resolved.border, dark().colors.border);
241    }
242
243    #[test]
244    fn resolution_never_consumes_its_own_output() {
245        // The projected style is kept verbatim precisely so that this holds:
246        // resolving against a second palette must follow it, not stay on the
247        // first. Resolving in place would have frozen after one pass.
248        let projected = InputEditorStyle::default();
249        let first = projected.resolved(&dark());
250
251        let mut light = SemanticThemeTokens::default();
252        light.colors.foreground = hsla(0., 0., 0.04, 1.0);
253        let second = projected.resolved(&light);
254
255        assert_ne!(first.foreground, second.foreground);
256        assert_eq!(second.foreground, light.colors.foreground);
257    }
258}