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    /// Return ordered, non-overlapping style runs that fully cover `range`.
43    /// Use [`HighlightStyle::default`] for text without a semantic style.
44    fn styles(
45        &self,
46        range: &Range<usize>,
47        resolver: &dyn HighlightStyleResolver,
48    ) -> Vec<(Range<usize>, HighlightStyle)>;
49
50    fn fold_ranges(&self, text: &Rope) -> Vec<FoldRange>;
51
52    fn fold_ranges_for_edit(&self, range: Range<usize>, text: &Rope) -> Vec<FoldRange> {
53        let _ = range;
54        self.fold_ranges(text)
55    }
56}
57
58pub type InputHighlighterFactory = Rc<dyn Fn(&str) -> Option<Box<dyn InputHighlighter>>>;
59pub type SharedHighlightStyleResolver = Arc<dyn HighlightStyleResolver>;
60pub type FoldIconRenderer = Rc<dyn Fn(usize, bool) -> AnyElement>;
61
62/// Where in the syntax tree an offset sits, for editing decisions.
63///
64/// Parser-independent: `gpui-component` answers from tree-sitter, apps may
65/// answer heuristically. `None` (no provider installed) means `Code`.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum SyntaxContext {
68    Code,
69    String,
70    Comment,
71}
72
73/// Answers syntax context for editing decisions (pairing, skip, indent).
74///
75/// Created per editor by the application's [`super::LanguageProvider`].
76/// Base never imports a parser; implementations live in UI crates or apps.
77pub trait SyntaxContextProvider {
78    fn context_at(&self, text: &Rope, offset: usize) -> SyntaxContext;
79}
80
81#[derive(Clone, Copy, Default)]
82pub struct DiagnosticColors {
83    pub error: Hsla,
84    pub warning: Hsla,
85    pub info: Hsla,
86    pub hint: Hsla,
87}
88
89/// Application-owned colors and highlight resolver consumed by editor painting.
90#[derive(Clone)]
91pub struct InputEditorStyle {
92    pub foreground: Hsla,
93    pub muted_foreground: Hsla,
94    pub background: Hsla,
95    pub border: Hsla,
96    pub selection: Hsla,
97    pub caret: Hsla,
98    pub diagnostics: DiagnosticColors,
99    pub highlight_styles: SharedHighlightStyleResolver,
100    pub editor_invisible: Option<Hsla>,
101    pub editor_active_line: Option<Hsla>,
102    pub editor_gutter_background: Option<Hsla>,
103    pub fold_icon_renderer: Option<FoldIconRenderer>,
104}
105
106impl InputEditorStyle {
107    /// Fills in every colour that was left unset, from the active palette.
108    ///
109    /// `Hsla::default()` is fully transparent, and every colour on `Default` is
110    /// that — so an input nothing projected onto painted its glyphs, its caret
111    /// and its selection in nothing at all. Transparent is not a colour anyone
112    /// means for ink, which is what makes it usable as "unset" here.
113    ///
114    /// This is resolution, not assignment: whatever a consumer did project is
115    /// kept exactly. `crates/component` projects the whole style on every render and
116    /// never reaches this; a consumer that projects once at construction gets
117    /// the palette that is current now rather than the one that happened to be
118    /// installed when the state was built.
119    pub fn resolved(&self, tokens: &SemanticThemeTokens) -> Self {
120        let colors = &tokens.colors;
121        let unset = |value: Hsla| value.a == 0.;
122        let or = |value: Hsla, fallback: Hsla| if unset(value) { fallback } else { value };
123
124        let foreground = or(self.foreground, colors.foreground);
125        let mut selection = self.selection;
126        if unset(selection) {
127            selection = colors.accent;
128            // A selection must not hide the glyphs it selects.
129            selection.a = 0.4;
130        }
131
132        Self {
133            foreground,
134            muted_foreground: or(self.muted_foreground, colors.muted_foreground),
135            background: or(self.background, colors.surface),
136            border: or(self.border, colors.border),
137            selection,
138            caret: or(self.caret, foreground),
139            ..self.clone()
140        }
141    }
142}
143
144impl Default for InputEditorStyle {
145    fn default() -> Self {
146        Self {
147            foreground: Hsla::default(),
148            muted_foreground: Hsla::default(),
149            background: Hsla::default(),
150            border: Hsla::default(),
151            selection: Hsla::default(),
152            caret: Hsla::default(),
153            diagnostics: DiagnosticColors::default(),
154            highlight_styles: Arc::new(NoHighlightStyles),
155            editor_invisible: None,
156            editor_active_line: None,
157            editor_gutter_background: None,
158            fold_icon_renderer: None,
159        }
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use gpui::hsla;
166
167    use super::InputEditorStyle;
168    use crate::SemanticThemeTokens;
169
170    fn dark() -> SemanticThemeTokens {
171        let mut tokens = SemanticThemeTokens::default();
172        tokens.colors.foreground = hsla(0., 0., 0.98, 1.0);
173        tokens.colors.muted_foreground = hsla(0., 0., 0.64, 1.0);
174        tokens.colors.surface = hsla(0., 0., 0.04, 1.0);
175        tokens.colors.border = hsla(0., 0., 0.15, 1.0);
176        tokens.colors.accent = hsla(0.6, 0.5, 0.5, 1.0);
177        tokens
178    }
179
180    #[test]
181    fn an_unprojected_style_takes_its_ink_from_the_palette() {
182        let tokens = dark();
183        let resolved = InputEditorStyle::default().resolved(&tokens);
184
185        assert_eq!(resolved.foreground, tokens.colors.foreground);
186        assert_eq!(resolved.caret, tokens.colors.foreground);
187        assert_eq!(resolved.muted_foreground, tokens.colors.muted_foreground);
188        assert_eq!(resolved.background, tokens.colors.surface);
189        assert_eq!(resolved.border, tokens.colors.border);
190        // The point of the change: every one of these was transparent, so an
191        // input nothing projected onto painted its text in nothing at all.
192        for colour in [
193            resolved.foreground,
194            resolved.caret,
195            resolved.muted_foreground,
196            resolved.selection,
197        ] {
198            assert!(colour.a > 0., "{colour:?} is still invisible");
199        }
200    }
201
202    #[test]
203    fn a_selection_stays_translucent_enough_to_read_through() {
204        let resolved = InputEditorStyle::default().resolved(&dark());
205        assert_eq!(resolved.selection.a, 0.4);
206    }
207
208    #[test]
209    fn projected_colours_are_kept_verbatim() {
210        let chosen = hsla(0.3, 0.4, 0.5, 1.0);
211        let style = InputEditorStyle {
212            foreground: chosen,
213            caret: chosen,
214            ..Default::default()
215        };
216        let resolved = style.resolved(&dark());
217
218        assert_eq!(resolved.foreground, chosen);
219        assert_eq!(resolved.caret, chosen);
220        // And what was not projected still comes from the palette.
221        assert_eq!(resolved.border, dark().colors.border);
222    }
223
224    #[test]
225    fn resolution_never_consumes_its_own_output() {
226        // The projected style is kept verbatim precisely so that this holds:
227        // resolving against a second palette must follow it, not stay on the
228        // first. Resolving in place would have frozen after one pass.
229        let projected = InputEditorStyle::default();
230        let first = projected.resolved(&dark());
231
232        let mut light = SemanticThemeTokens::default();
233        light.colors.foreground = hsla(0., 0., 0.04, 1.0);
234        let second = projected.resolved(&light);
235
236        assert_ne!(first.foreground, second.foreground);
237        assert_eq!(second.foreground, light.colors.foreground);
238    }
239}