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#[derive(Clone, Copy, Default)]
63pub struct DiagnosticColors {
64    pub error: Hsla,
65    pub warning: Hsla,
66    pub info: Hsla,
67    pub hint: Hsla,
68}
69
70/// Application-owned colors and highlight resolver consumed by editor painting.
71#[derive(Clone)]
72pub struct InputEditorStyle {
73    pub foreground: Hsla,
74    pub muted_foreground: Hsla,
75    pub background: Hsla,
76    pub border: Hsla,
77    pub selection: Hsla,
78    pub caret: Hsla,
79    pub diagnostics: DiagnosticColors,
80    pub highlight_styles: SharedHighlightStyleResolver,
81    pub editor_invisible: Option<Hsla>,
82    pub editor_active_line: Option<Hsla>,
83    pub editor_gutter_background: Option<Hsla>,
84    pub fold_icon_renderer: Option<FoldIconRenderer>,
85}
86
87impl InputEditorStyle {
88    /// Fills in every colour that was left unset, from the active palette.
89    ///
90    /// `Hsla::default()` is fully transparent, and every colour on `Default` is
91    /// that — so an input nothing projected onto painted its glyphs, its caret
92    /// and its selection in nothing at all. Transparent is not a colour anyone
93    /// means for ink, which is what makes it usable as "unset" here.
94    ///
95    /// This is resolution, not assignment: whatever a consumer did project is
96    /// kept exactly. `crates/component` projects the whole style on every render and
97    /// never reaches this; a consumer that projects once at construction gets
98    /// the palette that is current now rather than the one that happened to be
99    /// installed when the state was built.
100    pub fn resolved(&self, tokens: &SemanticThemeTokens) -> Self {
101        let colors = &tokens.colors;
102        let unset = |value: Hsla| value.a == 0.;
103        let or = |value: Hsla, fallback: Hsla| if unset(value) { fallback } else { value };
104
105        let foreground = or(self.foreground, colors.foreground);
106        let mut selection = self.selection;
107        if unset(selection) {
108            selection = colors.accent;
109            // A selection must not hide the glyphs it selects.
110            selection.a = 0.4;
111        }
112
113        Self {
114            foreground,
115            muted_foreground: or(self.muted_foreground, colors.muted_foreground),
116            background: or(self.background, colors.surface),
117            border: or(self.border, colors.border),
118            selection,
119            caret: or(self.caret, foreground),
120            ..self.clone()
121        }
122    }
123}
124
125impl Default for InputEditorStyle {
126    fn default() -> Self {
127        Self {
128            foreground: Hsla::default(),
129            muted_foreground: Hsla::default(),
130            background: Hsla::default(),
131            border: Hsla::default(),
132            selection: Hsla::default(),
133            caret: Hsla::default(),
134            diagnostics: DiagnosticColors::default(),
135            highlight_styles: Arc::new(NoHighlightStyles),
136            editor_invisible: None,
137            editor_active_line: None,
138            editor_gutter_background: None,
139            fold_icon_renderer: None,
140        }
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use gpui::hsla;
147
148    use super::InputEditorStyle;
149    use crate::SemanticThemeTokens;
150
151    fn dark() -> SemanticThemeTokens {
152        let mut tokens = SemanticThemeTokens::default();
153        tokens.colors.foreground = hsla(0., 0., 0.98, 1.0);
154        tokens.colors.muted_foreground = hsla(0., 0., 0.64, 1.0);
155        tokens.colors.surface = hsla(0., 0., 0.04, 1.0);
156        tokens.colors.border = hsla(0., 0., 0.15, 1.0);
157        tokens.colors.accent = hsla(0.6, 0.5, 0.5, 1.0);
158        tokens
159    }
160
161    #[test]
162    fn an_unprojected_style_takes_its_ink_from_the_palette() {
163        let tokens = dark();
164        let resolved = InputEditorStyle::default().resolved(&tokens);
165
166        assert_eq!(resolved.foreground, tokens.colors.foreground);
167        assert_eq!(resolved.caret, tokens.colors.foreground);
168        assert_eq!(resolved.muted_foreground, tokens.colors.muted_foreground);
169        assert_eq!(resolved.background, tokens.colors.surface);
170        assert_eq!(resolved.border, tokens.colors.border);
171        // The point of the change: every one of these was transparent, so an
172        // input nothing projected onto painted its text in nothing at all.
173        for colour in [
174            resolved.foreground,
175            resolved.caret,
176            resolved.muted_foreground,
177            resolved.selection,
178        ] {
179            assert!(colour.a > 0., "{colour:?} is still invisible");
180        }
181    }
182
183    #[test]
184    fn a_selection_stays_translucent_enough_to_read_through() {
185        let resolved = InputEditorStyle::default().resolved(&dark());
186        assert_eq!(resolved.selection.a, 0.4);
187    }
188
189    #[test]
190    fn projected_colours_are_kept_verbatim() {
191        let chosen = hsla(0.3, 0.4, 0.5, 1.0);
192        let style = InputEditorStyle {
193            foreground: chosen,
194            caret: chosen,
195            ..Default::default()
196        };
197        let resolved = style.resolved(&dark());
198
199        assert_eq!(resolved.foreground, chosen);
200        assert_eq!(resolved.caret, chosen);
201        // And what was not projected still comes from the palette.
202        assert_eq!(resolved.border, dark().colors.border);
203    }
204
205    #[test]
206    fn resolution_never_consumes_its_own_output() {
207        // The projected style is kept verbatim precisely so that this holds:
208        // resolving against a second palette must follow it, not stay on the
209        // first. Resolving in place would have frozen after one pass.
210        let projected = InputEditorStyle::default();
211        let first = projected.resolved(&dark());
212
213        let mut light = SemanticThemeTokens::default();
214        light.colors.foreground = hsla(0., 0., 0.04, 1.0);
215        let second = projected.resolved(&light);
216
217        assert_ne!(first.foreground, second.foreground);
218        assert_eq!(second.foreground, light.colors.foreground);
219    }
220}