Skip to content

Commit c1209de

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 76d9476 commit c1209de

4 files changed

Lines changed: 170 additions & 65 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: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -860,9 +860,8 @@ impl TextElement {
860860

861861
// Second pass: create and prepaint icons
862862
let line_height = last_layout.line_height;
863-
let line_number_width = last_layout.line_number_width
864-
- LINE_NUMBER_RIGHT_MARGIN
865-
- FOLD_ICON_HITBOX_WIDTH;
863+
let line_number_width =
864+
last_layout.line_number_width - LINE_NUMBER_RIGHT_MARGIN - FOLD_ICON_HITBOX_WIDTH;
866865
let icon_relative_pos = point(
867866
(FOLD_ICON_HITBOX_WIDTH - FOLD_ICON_WIDTH).half(),
868867
(line_height - FOLD_ICON_WIDTH).half(),
@@ -1126,11 +1125,23 @@ impl TextElement {
11261125

11271126
let diagnostic_styles = diagnostics.styles_for_range(&visible_byte_range, cx);
11281127

1129-
// Custom highlighter styles, layered between tree-sitter (base) and
1130-
// diagnostics (top). Empty Vec when no custom highlighter is set, so
1131-
// `combine_highlights` short-circuits.
1128+
// Custom highlighter tokens, resolved through the active highlight
1129+
// theme so the custom source shares the same colour vocabulary as
1130+
// the tree-sitter path. Empty Vec when no custom highlighter is set,
1131+
// so `combine_highlights` short-circuits.
11321132
let custom_styles = match &custom_highlighter {
1133-
Some(h) => h.styles(visible_byte_range.clone(), cx),
1133+
Some(h) => {
1134+
let highlight_theme = &cx.theme().highlight_theme;
1135+
h.tokens(visible_byte_range.clone())
1136+
.into_iter()
1137+
.map(|(range, name)| {
1138+
(
1139+
range,
1140+
highlight_theme.style(name.as_ref()).unwrap_or_default(),
1141+
)
1142+
})
1143+
.collect::<Vec<_>>()
1144+
}
11341145
None => Vec::new(),
11351146
};
11361147

@@ -1199,7 +1210,10 @@ impl IntoElement for TextElement {
11991210

12001211
/// A debug function to print points as SVG path.
12011212
#[allow(unused)]
1202-
fn print_points_as_svg_path(line_corners: &Vec<gpui::Corners<Pixels>>, points: &Vec<Point<Pixels>>) {
1213+
fn print_points_as_svg_path(
1214+
line_corners: &Vec<gpui::Corners<Pixels>>,
1215+
points: &Vec<Point<Pixels>>,
1216+
) {
12031217
for corners in line_corners {
12041218
println!(
12051219
"tl: ({}, {}), tr: ({}, {}), bl: ({}, {}), br: ({}, {})",
@@ -1621,7 +1635,8 @@ impl Element for TextElement {
16211635
let hover_definition_hitbox = self.layout_hover_definition_hitbox(state, window, cx);
16221636
let indent_guides_path =
16231637
self.layout_indent_guides(state, &bounds, &last_layout, &text_style, window);
1624-
let fold_icon_layout = self.layout_fold_icons(original_x, &bounds, &last_layout, window, cx);
1638+
let fold_icon_layout =
1639+
self.layout_fold_icons(original_x, &bounds, &last_layout, window, cx);
16251640

16261641
PrepaintState {
16271642
bounds,

crates/ui/src/input/state.rs

Lines changed: 42 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -625,11 +625,14 @@ impl InputState {
625625
cx.notify();
626626
}
627627

628-
/// Set a custom highlighter that contributes additional styles to the
629-
/// render pipeline alongside the built-in tree-sitter highlighter.
628+
/// Set a custom highlighter that contributes additional named token
629+
/// ranges to the render pipeline alongside the built-in tree-sitter
630+
/// highlighter.
630631
///
631632
/// The custom highlighter does **not** replace the built-in. Both run
632-
/// (when set), and their outputs are combined via
633+
/// (when set); the custom output is resolved against the active
634+
/// [`HighlightTheme`](crate::highlighter::HighlightTheme) and combined
635+
/// with the tree-sitter and diagnostic styles via
633636
/// [`gpui::combine_highlights`]. Pass `None` to remove a previously-set
634637
/// custom highlighter.
635638
///
@@ -2755,32 +2758,19 @@ ORDER BY id
27552758
fn test_custom_highlighter_composes_with_tree_sitter(cx: &mut TestAppContext) {
27562759
use crate::highlighter::{CustomHighlighter, HighlightTheme};
27572760
use crate::input::mode::InputMode;
2758-
use gpui::{HighlightStyle, Hsla};
2761+
use gpui::{HighlightStyle, SharedString};
27592762
use std::ops::Range;
27602763
use std::sync::Arc;
27612764

2762-
// Custom highlighter that paints a single bright-red range.
2763-
struct RedRange {
2765+
// Custom highlighter that tags a fixed byte range with a token name
2766+
// resolvable by the active highlight theme. Picking "keyword" guarantees
2767+
// a non-default style on the default themes.
2768+
struct KeywordRange {
27642769
range: Range<usize>,
27652770
}
2766-
impl CustomHighlighter for RedRange {
2767-
fn styles(
2768-
&self,
2769-
_range: Range<usize>,
2770-
_cx: &App,
2771-
) -> Vec<(Range<usize>, HighlightStyle)> {
2772-
vec![(
2773-
self.range.clone(),
2774-
HighlightStyle {
2775-
color: Some(Hsla {
2776-
h: 0.0,
2777-
s: 1.0,
2778-
l: 0.5,
2779-
a: 1.0,
2780-
}),
2781-
..Default::default()
2782-
},
2783-
)]
2771+
impl CustomHighlighter for KeywordRange {
2772+
fn tokens(&self, _range: Range<usize>) -> Vec<(Range<usize>, SharedString)> {
2773+
vec![(self.range.clone(), "keyword".into())]
27842774
}
27852775
}
27862776

@@ -2797,20 +2787,21 @@ ORDER BY id
27972787
});
27982788
cx.run_until_parked();
27992789

2800-
// Custom highlighter paints bytes [0..6) (covers "SELECT").
2801-
let custom: Arc<dyn CustomHighlighter> = Arc::new(RedRange { range: 0..6 });
2790+
// Custom highlighter tags bytes [0..6) (covers "SELECT") as "keyword".
2791+
let custom: Arc<dyn CustomHighlighter> = Arc::new(KeywordRange { range: 0..6 });
28022792
cx.update(|_, cx| {
28032793
input.update(cx, |state, cx| {
28042794
state.set_custom_highlighter(Some(custom), cx);
28052795
});
28062796
});
28072797
cx.run_until_parked();
28082798

2809-
// Mirror element.rs::highlight_lines: pull both sources from state.mode
2810-
// and combine them in the documented order.
2799+
// Mirror element.rs::highlight_lines: pull both sources from state.mode,
2800+
// resolve the custom tokens against the highlight theme, and combine
2801+
// in the documented order.
28112802
let theme = HighlightTheme::default_dark();
2812-
let combined: Vec<(Range<usize>, HighlightStyle)> = cx.update(|_, cx| {
2813-
input.read_with(cx, |state, cx| {
2803+
let combined: Vec<(Range<usize>, HighlightStyle)> = cx.update(|_, _cx| {
2804+
input.read_with(_cx, |state, _| {
28142805
let visible_range = 0..text.len();
28152806

28162807
let (ts_styles, custom_styles) = match &state.mode {
@@ -2824,9 +2815,16 @@ ORDER BY id
28242815
.as_ref()
28252816
.map(|h| h.styles(&visible_range, &theme))
28262817
.unwrap_or_default();
2827-
let custom_styles = custom_highlighter
2818+
let custom_styles: Vec<(Range<usize>, HighlightStyle)> = custom_highlighter
28282819
.as_ref()
2829-
.map(|h| h.styles(visible_range.clone(), cx))
2820+
.map(|h| {
2821+
h.tokens(visible_range.clone())
2822+
.into_iter()
2823+
.map(|(r, name)| {
2824+
(r, theme.style(name.as_ref()).unwrap_or_default())
2825+
})
2826+
.collect()
2827+
})
28302828
.unwrap_or_default();
28312829
(ts_styles, custom_styles)
28322830
}
@@ -2843,6 +2841,15 @@ ORDER BY id
28432841
"custom highlighter produced no styles"
28442842
);
28452843

2844+
// The custom "keyword" token should resolve to a non-default
2845+
// style on the default-dark theme — proves theme integration.
2846+
assert!(
2847+
custom_styles
2848+
.iter()
2849+
.any(|(_, s)| *s != HighlightStyle::default()),
2850+
"custom token name 'keyword' did not resolve to a styled output"
2851+
);
2852+
28462853
// Compose in the same order as element.rs::highlight_lines.
28472854
gpui::combine_highlights(custom_styles, ts_styles).collect()
28482855
})
@@ -2865,19 +2872,15 @@ ORDER BY id
28652872
fn test_set_custom_highlighter_round_trip(cx: &mut TestAppContext) {
28662873
use crate::highlighter::CustomHighlighter;
28672874
use crate::input::mode::InputMode;
2868-
use gpui::HighlightStyle;
2875+
use gpui::SharedString;
28692876
use std::ops::Range;
28702877
use std::sync::Arc;
28712878

28722879
struct DummyHighlighter;
28732880

28742881
impl CustomHighlighter for DummyHighlighter {
2875-
fn styles(
2876-
&self,
2877-
range: Range<usize>,
2878-
_cx: &App,
2879-
) -> Vec<(Range<usize>, HighlightStyle)> {
2880-
vec![(range, HighlightStyle::default())]
2882+
fn tokens(&self, range: Range<usize>) -> Vec<(Range<usize>, SharedString)> {
2883+
vec![(range, "keyword".into())]
28812884
}
28822885
}
28832886

0 commit comments

Comments
 (0)