Skip to content

Commit 11c4588

Browse files
committed
input: revise CustomHighlighter to token-name model + add example
Reshapes the trait per maintainer feedback on PR longbridge#2328: - fn styles(&self, range: Range<usize>, cx: &App) - -> Vec<(Range<usize>, HighlightStyle)>; + fn tokens(&self, range: Range<usize>) + -> Vec<(Range<usize>, SharedString)>; The previous shape returned pre-resolved `HighlightStyle` and took `&App`, forcing implementors to resolve theme tokens on the render thread every frame. That bypassed upstream's own tree-sitter path, which emits scope-name strings resolved through `HighlightTheme::style()` at `highlighter.rs:719`. Per Monaco Editor's design (referenced in review), tokenizers emit named scopes; a separate theme map resolves names to styles. The trait now constrains the hot path to a `SharedString` read so heavy parsing can be arranged off-thread (subscribe to `InputEvent::Change`, cache, hand back ranges on call) -- addressing the 200k-line performance concern by construction. Token names use upstream's existing scope vocabulary (`"keyword"`, `"string"`, `"comment"`, `"variable.special"`, ...); `.`-namespaced names fall back to their prefix via `SyntaxColors::style`. Unrecognized names render with the default style. Adds a worked example at `crates/story/examples/editor.rs` (`MarkerHighlighter`): tags `TODO` / `FIXME` / `XXX` / `HACK` / `NOTE` markers with `keyword.special`, recomputed on `InputEvent::Change`. A syntect or language-server consumer would follow the same shape with a different parser inside `refresh()`. Tests updated to the new method signature; both still verify install / clear round-trip and tree-sitter composition through `combine_highlights`.
1 parent 08a737e commit 11c4588

4 files changed

Lines changed: 162 additions & 60 deletions

File tree

crates/story/examples/editor.rs

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ use gpui_component::{
1313
ActiveTheme, IconName, Sizable, WindowExt,
1414
button::{Button, ButtonVariants as _},
1515
h_flex,
16-
highlighter::{Diagnostic, DiagnosticSeverity, Language, LanguageConfig, LanguageRegistry},
16+
highlighter::{
17+
CustomHighlighter, Diagnostic, DiagnosticSeverity, Language, LanguageConfig,
18+
LanguageRegistry,
19+
},
1720
input::{
1821
self, CodeActionProvider, CompletionProvider, DefinitionProvider, DocumentColorProvider,
1922
HoverProvider, Input, InputEvent, InputState, Position, Rope, RopeExt, TabSize,
@@ -66,6 +69,57 @@ fn init() {
6669
);
6770
}
6871

72+
/// Example consumer of [`CustomHighlighter`]: tags `TODO` / `FIXME` / `XXX`
73+
/// / `HACK` / `NOTE` markers anywhere in the buffer with the
74+
/// `keyword.special` token so they stand out against the tree-sitter
75+
/// comment colour.
76+
///
77+
/// Demonstrates the stateful pattern the trait expects — heavy work happens
78+
/// off the render thread (here: in response to `InputEvent::Change`) and
79+
/// the per-frame [`CustomHighlighter::tokens`] call is a pure read of
80+
/// pre-computed state. A real consumer plugging in syntect or a language
81+
/// server would follow the same shape with a different parser inside
82+
/// [`refresh`](MarkerHighlighter::refresh).
83+
#[derive(Default)]
84+
struct MarkerHighlighter {
85+
tokens: RwLock<Vec<(Range<usize>, SharedString)>>,
86+
}
87+
88+
impl MarkerHighlighter {
89+
fn new() -> Self {
90+
Self::default()
91+
}
92+
93+
fn refresh(&self, text: &Rope) {
94+
const MARKERS: &[&str] = &["TODO", "FIXME", "XXX", "HACK", "NOTE"];
95+
let token: SharedString = "keyword.special".into();
96+
let s = text.to_string();
97+
let mut new_tokens: Vec<(Range<usize>, SharedString)> = Vec::new();
98+
for marker in MARKERS {
99+
let mut start = 0usize;
100+
while let Some(rel) = s[start..].find(marker) {
101+
let abs = start + rel;
102+
new_tokens.push((abs..abs + marker.len(), token.clone()));
103+
start = abs + marker.len();
104+
}
105+
}
106+
new_tokens.sort_by_key(|(r, _)| r.start);
107+
*self.tokens.write().unwrap() = new_tokens;
108+
}
109+
}
110+
111+
impl CustomHighlighter for MarkerHighlighter {
112+
fn tokens(&self, range: Range<usize>) -> Vec<(Range<usize>, SharedString)> {
113+
self.tokens
114+
.read()
115+
.unwrap()
116+
.iter()
117+
.filter(|(r, _)| r.start >= range.start && r.end <= range.end)
118+
.cloned()
119+
.collect()
120+
}
121+
}
122+
69123
pub struct Example {
70124
editor: Entity<InputState>,
71125
tree_state: Entity<TreeState>,
@@ -77,6 +131,7 @@ pub struct Example {
77131
show_whitespaces: bool,
78132
folding: bool,
79133
lsp_store: ExampleLspStore,
134+
marker_highlighter: Arc<MarkerHighlighter>,
80135
_subscriptions: Vec<Subscription>,
81136
_lint_task: Task<()>,
82137
}
@@ -690,6 +745,7 @@ impl Example {
690745
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
691746
let default_language = Lang::BuiltIn(Language::Rust);
692747
let lsp_store = ExampleLspStore::new();
748+
let marker_highlighter = Arc::new(MarkerHighlighter::new());
693749

694750
let editor = cx.new(|cx| {
695751
let mut editor = InputState::new(window, cx)
@@ -711,6 +767,14 @@ impl Example {
711767
editor.lsp.definition_provider = Some(lsp_store.clone());
712768
editor.lsp.document_color_provider = Some(lsp_store.clone());
713769

770+
// Install the example custom highlighter and seed it from the
771+
// initial buffer text so markers are tagged on first paint.
772+
marker_highlighter.refresh(editor.text());
773+
editor.set_custom_highlighter(
774+
Some(marker_highlighter.clone() as Arc<dyn CustomHighlighter>),
775+
cx,
776+
);
777+
714778
editor
715779
});
716780

@@ -726,7 +790,9 @@ impl Example {
726790
let tree_state = cx.new(|cx| TreeState::new(cx));
727791
Self::load_files(tree_state.clone(), PathBuf::from("./"), cx);
728792

729-
let _subscriptions = vec![cx.subscribe(&editor, |this, _, _: &InputEvent, cx| {
793+
let _subscriptions = vec![cx.subscribe(&editor, |this, editor, _: &InputEvent, cx| {
794+
let text = editor.read(cx).text().clone();
795+
this.marker_highlighter.refresh(&text);
730796
this.lint_document(cx);
731797
})];
732798

@@ -741,6 +807,7 @@ impl Example {
741807
show_whitespaces: false,
742808
folding: true,
743809
lsp_store,
810+
marker_highlighter,
744811
_subscriptions,
745812
_lint_task: Task::ready(()),
746813
}
Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
use std::ops::Range;
22

3-
use gpui::{App, HighlightStyle};
3+
use gpui::SharedString;
44

5-
/// A consumer-supplied highlighter that contributes additional styled byte
5+
/// A consumer-supplied highlighter that contributes additional named token
66
/// ranges to the [`Input`](crate::input::InputState) element's render
77
/// pipeline, alongside the built-in tree-sitter
88
/// [`SyntaxHighlighter`](crate::highlighter::SyntaxHighlighter) and
@@ -13,9 +13,23 @@ use gpui::{App, HighlightStyle};
1313
/// - Plugging a different parser engine (syntect, regex-based tokenizers,
1414
/// language server semantic tokens) for languages tree-sitter does not
1515
/// cover.
16-
/// - Layering decorative highlights (search match emphasis, code folding
17-
/// indicators, scope-aware accent colors) without rebuilding the
18-
/// diagnostic pipeline.
16+
/// - Layering decorative highlights (search-match emphasis, scope-aware
17+
/// accent colors) without rebuilding the diagnostic pipeline.
18+
///
19+
/// # Token-name vocabulary
20+
///
21+
/// Returned token names are resolved against the active
22+
/// [`HighlightTheme`](crate::highlighter::HighlightTheme) — the same
23+
/// vocabulary the tree-sitter highlighter emits (`"keyword"`, `"string"`,
24+
/// `"comment"`, `"variable.special"`, …). Names with a `.`-namespace fall
25+
/// back to their prefix (`"keyword.modifier"` → `"keyword"`). Unrecognized
26+
/// names render with the default style.
27+
///
28+
/// This decouples token classification from styling: the theme is the
29+
/// single source of color, so theme switches propagate without implementor
30+
/// cooperation. It also lets a third-party highlighter share a vocabulary
31+
/// with the built-in tree-sitter path so multiple sources colour the same
32+
/// logical token consistently.
1933
///
2034
/// # Composition
2135
///
@@ -26,18 +40,24 @@ use gpui::{App, HighlightStyle};
2640
///
2741
/// # Threading and performance
2842
///
29-
/// [`styles`](Self::styles) is called from the render thread inside the
30-
/// `Input` element's per-frame highlight pass. Implementations should be
31-
/// `Send + Sync` and inexpensive — caching parsed state across calls is the
32-
/// implementor's responsibility.
43+
/// [`tokens`](Self::tokens) is called from the render thread on every
44+
/// frame the input is visible. Implementations should be `Send + Sync` and
45+
/// inexpensive — typically a read of pre-computed state. Heavy parsing
46+
/// should happen off-thread (for example, in response to a text-change
47+
/// event the implementor subscribes to) and cache its output for the
48+
/// render thread to consume.
3349
///
3450
/// The viewport-clamping that the built-in tree-sitter path applies for
35-
/// long-line skipping does **not** apply to custom highlighter output;
36-
/// implementations are responsible for their own performance characteristics.
51+
/// long-line skipping does **not** apply to custom-highlighter output;
52+
/// implementations are responsible for their own performance
53+
/// characteristics.
3754
pub trait CustomHighlighter: Send + Sync {
38-
/// Return styled byte ranges within the requested viewport range.
55+
/// Return token-name-tagged byte ranges within the requested viewport
56+
/// range.
3957
///
40-
/// Returned ranges should be a subset of `range`; ranges outside `range`
41-
/// are silently dropped during composition.
42-
fn styles(&self, range: Range<usize>, cx: &App) -> Vec<(Range<usize>, HighlightStyle)>;
58+
/// Returned ranges should be a subset of `range`; ranges outside
59+
/// `range` are silently dropped during composition. Token names are
60+
/// resolved against the active theme — see the type-level docs for
61+
/// the vocabulary.
62+
fn tokens(&self, range: Range<usize>) -> Vec<(Range<usize>, SharedString)>;
4363
}

crates/ui/src/input/element.rs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1317,11 +1317,23 @@ impl TextElement {
13171317

13181318
let diagnostic_styles = diagnostics.styles_for_range(&visible_byte_range, cx);
13191319

1320-
// Custom highlighter styles, layered between tree-sitter (base) and
1321-
// diagnostics (top). Empty Vec when no custom highlighter is set, so
1322-
// `combine_highlights` short-circuits.
1320+
// Custom highlighter tokens, resolved through the active highlight
1321+
// theme so the custom source shares the same colour vocabulary as
1322+
// the tree-sitter path. Empty Vec when no custom highlighter is set,
1323+
// so `combine_highlights` short-circuits.
13231324
let custom_styles = match &custom_highlighter {
1324-
Some(h) => h.styles(visible_byte_range.clone(), cx),
1325+
Some(h) => {
1326+
let highlight_theme = &cx.theme().highlight_theme;
1327+
h.tokens(visible_byte_range.clone())
1328+
.into_iter()
1329+
.map(|(range, name)| {
1330+
(
1331+
range,
1332+
highlight_theme.style(name.as_ref()).unwrap_or_default(),
1333+
)
1334+
})
1335+
.collect::<Vec<_>>()
1336+
}
13251337
None => Vec::new(),
13261338
};
13271339

crates/ui/src/input/state.rs

Lines changed: 42 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -658,11 +658,14 @@ impl InputState {
658658
cx.notify();
659659
}
660660

661-
/// Set a custom highlighter that contributes additional styles to the
662-
/// render pipeline alongside the built-in tree-sitter highlighter.
661+
/// Set a custom highlighter that contributes additional named token
662+
/// ranges to the render pipeline alongside the built-in tree-sitter
663+
/// highlighter.
663664
///
664665
/// The custom highlighter does **not** replace the built-in. Both run
665-
/// (when set), and their outputs are combined via
666+
/// (when set); the custom output is resolved against the active
667+
/// [`HighlightTheme`](crate::highlighter::HighlightTheme) and combined
668+
/// with the tree-sitter and diagnostic styles via
666669
/// [`gpui::combine_highlights`]. Pass `None` to remove a previously-set
667670
/// custom highlighter.
668671
///
@@ -3135,32 +3138,19 @@ ORDER BY id
31353138
fn test_custom_highlighter_composes_with_tree_sitter(cx: &mut TestAppContext) {
31363139
use crate::highlighter::{CustomHighlighter, HighlightTheme};
31373140
use crate::input::mode::InputMode;
3138-
use gpui::{HighlightStyle, Hsla};
3141+
use gpui::{HighlightStyle, SharedString};
31393142
use std::ops::Range;
31403143
use std::sync::Arc;
31413144

3142-
// Custom highlighter that paints a single bright-red range.
3143-
struct RedRange {
3145+
// Custom highlighter that tags a fixed byte range with a token name
3146+
// resolvable by the active highlight theme. Picking "keyword" guarantees
3147+
// a non-default style on the default themes.
3148+
struct KeywordRange {
31443149
range: Range<usize>,
31453150
}
3146-
impl CustomHighlighter for RedRange {
3147-
fn styles(
3148-
&self,
3149-
_range: Range<usize>,
3150-
_cx: &App,
3151-
) -> Vec<(Range<usize>, HighlightStyle)> {
3152-
vec![(
3153-
self.range.clone(),
3154-
HighlightStyle {
3155-
color: Some(Hsla {
3156-
h: 0.0,
3157-
s: 1.0,
3158-
l: 0.5,
3159-
a: 1.0,
3160-
}),
3161-
..Default::default()
3162-
},
3163-
)]
3151+
impl CustomHighlighter for KeywordRange {
3152+
fn tokens(&self, _range: Range<usize>) -> Vec<(Range<usize>, SharedString)> {
3153+
vec![(self.range.clone(), "keyword".into())]
31643154
}
31653155
}
31663156

@@ -3177,20 +3167,21 @@ ORDER BY id
31773167
});
31783168
cx.run_until_parked();
31793169

3180-
// Custom highlighter paints bytes [0..6) (covers "SELECT").
3181-
let custom: Arc<dyn CustomHighlighter> = Arc::new(RedRange { range: 0..6 });
3170+
// Custom highlighter tags bytes [0..6) (covers "SELECT") as "keyword".
3171+
let custom: Arc<dyn CustomHighlighter> = Arc::new(KeywordRange { range: 0..6 });
31823172
cx.update(|_, cx| {
31833173
input.update(cx, |state, cx| {
31843174
state.set_custom_highlighter(Some(custom), cx);
31853175
});
31863176
});
31873177
cx.run_until_parked();
31883178

3189-
// Mirror element.rs::highlight_lines: pull both sources from state.mode
3190-
// and combine them in the documented order.
3179+
// Mirror element.rs::highlight_lines: pull both sources from state.mode,
3180+
// resolve the custom tokens against the highlight theme, and combine
3181+
// in the documented order.
31913182
let theme = HighlightTheme::default_dark();
3192-
let combined: Vec<(Range<usize>, HighlightStyle)> = cx.update(|_, cx| {
3193-
input.read_with(cx, |state, cx| {
3183+
let combined: Vec<(Range<usize>, HighlightStyle)> = cx.update(|_, _cx| {
3184+
input.read_with(_cx, |state, _| {
31943185
let visible_range = 0..text.len();
31953186

31963187
let (ts_styles, custom_styles) = match &state.mode {
@@ -3204,9 +3195,16 @@ ORDER BY id
32043195
.as_ref()
32053196
.map(|h| h.styles(&visible_range, &theme))
32063197
.unwrap_or_default();
3207-
let custom_styles = custom_highlighter
3198+
let custom_styles: Vec<(Range<usize>, HighlightStyle)> = custom_highlighter
32083199
.as_ref()
3209-
.map(|h| h.styles(visible_range.clone(), cx))
3200+
.map(|h| {
3201+
h.tokens(visible_range.clone())
3202+
.into_iter()
3203+
.map(|(r, name)| {
3204+
(r, theme.style(name.as_ref()).unwrap_or_default())
3205+
})
3206+
.collect()
3207+
})
32103208
.unwrap_or_default();
32113209
(ts_styles, custom_styles)
32123210
}
@@ -3223,6 +3221,15 @@ ORDER BY id
32233221
"custom highlighter produced no styles"
32243222
);
32253223

3224+
// The custom "keyword" token should resolve to a non-default
3225+
// style on the default-dark theme — proves theme integration.
3226+
assert!(
3227+
custom_styles
3228+
.iter()
3229+
.any(|(_, s)| *s != HighlightStyle::default()),
3230+
"custom token name 'keyword' did not resolve to a styled output"
3231+
);
3232+
32263233
// Compose in the same order as element.rs::highlight_lines.
32273234
gpui::combine_highlights(custom_styles, ts_styles).collect()
32283235
})
@@ -3245,19 +3252,15 @@ ORDER BY id
32453252
fn test_set_custom_highlighter_round_trip(cx: &mut TestAppContext) {
32463253
use crate::highlighter::CustomHighlighter;
32473254
use crate::input::mode::InputMode;
3248-
use gpui::HighlightStyle;
3255+
use gpui::SharedString;
32493256
use std::ops::Range;
32503257
use std::sync::Arc;
32513258

32523259
struct DummyHighlighter;
32533260

32543261
impl CustomHighlighter for DummyHighlighter {
3255-
fn styles(
3256-
&self,
3257-
range: Range<usize>,
3258-
_cx: &App,
3259-
) -> Vec<(Range<usize>, HighlightStyle)> {
3260-
vec![(range, HighlightStyle::default())]
3262+
fn tokens(&self, range: Range<usize>) -> Vec<(Range<usize>, SharedString)> {
3263+
vec![(range, "keyword".into())]
32613264
}
32623265
}
32633266

0 commit comments

Comments
 (0)