Skip to main content

text_document/
highlight.rs

1//! Syntax highlighting support.
2//!
3//! Provides a [`SyntaxHighlighter`] trait inspired by Qt's `QSyntaxHighlighter`.
4//! Implementors produce shadow formatting that is merged into
5//! [`FragmentContent`](crate::FragmentContent) at layout time but never
6//! touches the stored [`InlineElement`] entities — export, cursor, undo, and
7//! search remain unaffected.
8
9use std::any::Any;
10use std::collections::HashMap;
11use std::sync::Arc;
12
13use frontend::commands::block_commands;
14
15use crate::flow::FragmentContent;
16use crate::inner::TextDocumentInner;
17use crate::{CharVerticalAlignment, Color, TextFormat, UnderlineStyle};
18
19// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
20// Public types
21// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
22
23/// Formatting applied by a syntax highlighter to a text range.
24///
25/// All fields are `Option`: `None` means "don't override the real format."
26/// Only non-`None` fields take precedence over the corresponding
27/// [`TextFormat`] field for display purposes.
28#[derive(Debug, Clone, Default, PartialEq, Eq)]
29pub struct HighlightFormat {
30    pub foreground_color: Option<Color>,
31    pub background_color: Option<Color>,
32    pub underline_color: Option<Color>,
33    pub font_family: Option<String>,
34    pub font_point_size: Option<u32>,
35    pub font_weight: Option<u32>,
36    pub font_bold: Option<bool>,
37    pub font_italic: Option<bool>,
38    pub font_underline: Option<bool>,
39    pub font_overline: Option<bool>,
40    pub font_strikeout: Option<bool>,
41    pub letter_spacing: Option<i32>,
42    pub word_spacing: Option<i32>,
43    pub underline_style: Option<UnderlineStyle>,
44    pub vertical_alignment: Option<CharVerticalAlignment>,
45    pub tooltip: Option<String>,
46}
47
48/// A single highlight span within a block.
49///
50/// `start` and `length` are block-relative **character** offsets.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct HighlightSpan {
53    pub start: usize,
54    pub length: usize,
55    pub format: HighlightFormat,
56}
57
58/// Context passed to [`SyntaxHighlighter::highlight_block`].
59///
60/// Provides methods to set highlight formatting and manage per-block state.
61pub struct HighlightContext {
62    spans: Vec<HighlightSpan>,
63    previous_state: i64,
64    current_state: i64,
65    block_id: usize,
66    user_data: Option<Box<dyn Any + Send + Sync>>,
67}
68
69impl HighlightContext {
70    /// Create a new context for highlighting a block.
71    pub fn new(
72        block_id: usize,
73        previous_state: i64,
74        user_data: Option<Box<dyn Any + Send + Sync>>,
75    ) -> Self {
76        Self {
77            spans: Vec::new(),
78            previous_state,
79            current_state: -1,
80            block_id,
81            user_data,
82        }
83    }
84
85    /// Apply a highlight format to a character range within the current block.
86    ///
87    /// Zero-length spans are silently ignored.
88    pub fn set_format(&mut self, start: usize, length: usize, format: HighlightFormat) {
89        if length == 0 {
90            return;
91        }
92        self.spans.push(HighlightSpan {
93            start,
94            length,
95            format,
96        });
97    }
98
99    /// Get the block state of the previous block (−1 if no state was set).
100    pub fn previous_block_state(&self) -> i64 {
101        self.previous_state
102    }
103
104    /// Set the block state for the current block.
105    ///
106    /// If the new state differs from the previously stored value, the next
107    /// block will be re-highlighted automatically (cascade).
108    pub fn set_current_block_state(&mut self, state: i64) {
109        self.current_state = state;
110    }
111
112    /// Get the current block state (defaults to −1).
113    pub fn current_block_state(&self) -> i64 {
114        self.current_state
115    }
116
117    /// Get the block ID.
118    pub fn block_id(&self) -> usize {
119        self.block_id
120    }
121
122    /// Set per-block user data (replaces any existing data).
123    pub fn set_user_data(&mut self, data: Box<dyn Any + Send + Sync>) {
124        self.user_data = Some(data);
125    }
126
127    /// Get a reference to the per-block user data.
128    pub fn user_data(&self) -> Option<&(dyn Any + Send + Sync)> {
129        self.user_data.as_deref()
130    }
131
132    /// Get a mutable reference to the per-block user data.
133    pub fn user_data_mut(&mut self) -> Option<&mut (dyn Any + Send + Sync)> {
134        self.user_data.as_deref_mut()
135    }
136
137    /// Consume the context and return the accumulated spans, final state,
138    /// and user data.
139    pub fn into_parts(self) -> (Vec<HighlightSpan>, i64, Option<Box<dyn Any + Send + Sync>>) {
140        (self.spans, self.current_state, self.user_data)
141    }
142}
143
144/// A user-implemented syntax highlighter that applies visual-only formatting.
145///
146/// Inspired by Qt's `QSyntaxHighlighter`. Implement this trait and attach it
147/// to a document via [`TextDocument::set_syntax_highlighter`](crate::TextDocument::set_syntax_highlighter).
148///
149/// The highlighter is called once per block when the document content changes.
150/// Use [`HighlightContext::set_format`] to apply highlight spans. Use
151/// [`HighlightContext::set_current_block_state`] and
152/// [`HighlightContext::previous_block_state`] for multi-block constructs
153/// (e.g., multiline comments).
154pub trait SyntaxHighlighter: Send + Sync {
155    /// Called for each block that needs re-highlighting.
156    fn highlight_block(&self, text: &str, ctx: &mut HighlightContext);
157}
158
159// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
160// Internal storage
161// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
162
163/// Per-block highlight state.
164pub(crate) struct BlockHighlightData {
165    pub spans: Vec<HighlightSpan>,
166    pub state: i64,
167    pub user_data: Option<Box<dyn Any + Send + Sync>>,
168}
169
170/// All highlight data for the document.
171pub(crate) struct HighlightData {
172    pub highlighter: Arc<dyn SyntaxHighlighter>,
173    pub blocks: HashMap<usize, BlockHighlightData>,
174}
175
176// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
177// Merge algorithm
178// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
179
180/// Apply highlight format overrides onto a base `TextFormat`.
181fn apply_highlight(base: &TextFormat, hl: &HighlightFormat) -> TextFormat {
182    TextFormat {
183        font_family: hl.font_family.clone().or_else(|| base.font_family.clone()),
184        font_point_size: hl.font_point_size.or(base.font_point_size),
185        font_weight: hl.font_weight.or(base.font_weight),
186        font_bold: hl.font_bold.or(base.font_bold),
187        font_italic: hl.font_italic.or(base.font_italic),
188        font_underline: hl.font_underline.or(base.font_underline),
189        font_overline: hl.font_overline.or(base.font_overline),
190        font_strikeout: hl.font_strikeout.or(base.font_strikeout),
191        letter_spacing: hl.letter_spacing.or(base.letter_spacing),
192        word_spacing: hl.word_spacing.or(base.word_spacing),
193        underline_style: hl
194            .underline_style
195            .clone()
196            .or_else(|| base.underline_style.clone()),
197        vertical_alignment: hl
198            .vertical_alignment
199            .clone()
200            .or_else(|| base.vertical_alignment.clone()),
201        tooltip: hl.tooltip.clone().or_else(|| base.tooltip.clone()),
202        foreground_color: hl.foreground_color.or(base.foreground_color),
203        background_color: hl.background_color.or(base.background_color),
204        underline_color: hl.underline_color.or(base.underline_color),
205        // Anchors are not overridden by highlights.
206        anchor_href: base.anchor_href.clone(),
207        anchor_names: base.anchor_names.clone(),
208        is_anchor: base.is_anchor,
209    }
210}
211
212/// Merge a set of overlapping highlights into a single `HighlightFormat`.
213/// Later spans override earlier spans for the same field.
214fn merge_overlapping_highlights(spans: &[&HighlightSpan]) -> HighlightFormat {
215    let mut merged = HighlightFormat::default();
216    for span in spans {
217        let f = &span.format;
218        if f.foreground_color.is_some() {
219            merged.foreground_color = f.foreground_color;
220        }
221        if f.background_color.is_some() {
222            merged.background_color = f.background_color;
223        }
224        if f.underline_color.is_some() {
225            merged.underline_color = f.underline_color;
226        }
227        if f.font_family.is_some() {
228            merged.font_family = f.font_family.clone();
229        }
230        if f.font_point_size.is_some() {
231            merged.font_point_size = f.font_point_size;
232        }
233        if f.font_weight.is_some() {
234            merged.font_weight = f.font_weight;
235        }
236        if f.font_bold.is_some() {
237            merged.font_bold = f.font_bold;
238        }
239        if f.font_italic.is_some() {
240            merged.font_italic = f.font_italic;
241        }
242        if f.font_underline.is_some() {
243            merged.font_underline = f.font_underline;
244        }
245        if f.font_overline.is_some() {
246            merged.font_overline = f.font_overline;
247        }
248        if f.font_strikeout.is_some() {
249            merged.font_strikeout = f.font_strikeout;
250        }
251        if f.letter_spacing.is_some() {
252            merged.letter_spacing = f.letter_spacing;
253        }
254        if f.word_spacing.is_some() {
255            merged.word_spacing = f.word_spacing;
256        }
257        if f.underline_style.is_some() {
258            merged.underline_style = f.underline_style.clone();
259        }
260        if f.vertical_alignment.is_some() {
261            merged.vertical_alignment = f.vertical_alignment.clone();
262        }
263        if f.tooltip.is_some() {
264            merged.tooltip = f.tooltip.clone();
265        }
266    }
267    merged
268}
269
270/// Merge highlight spans into a list of fragments.
271///
272/// Text fragments that overlap with highlight spans are split at span
273/// boundaries. The highlight format is overlaid onto the base `TextFormat`.
274/// Image fragments receive the overlay without splitting.
275pub(crate) fn merge_highlight_spans(
276    fragments: Vec<FragmentContent>,
277    spans: &[HighlightSpan],
278) -> Vec<FragmentContent> {
279    if spans.is_empty() {
280        return fragments;
281    }
282
283    let mut result = Vec::with_capacity(fragments.len());
284
285    for frag in fragments {
286        match frag {
287            FragmentContent::Text {
288                ref text,
289                ref format,
290                offset,
291                length,
292            } => {
293                let frag_end = offset + length;
294
295                // Collect highlight boundaries within this fragment's range.
296                let mut boundaries = Vec::new();
297                boundaries.push(offset);
298                boundaries.push(frag_end);
299
300                for span in spans {
301                    let span_end = span.start + span.length;
302                    // Does this span overlap the fragment?
303                    if span.start < frag_end && span_end > offset {
304                        if span.start > offset && span.start < frag_end {
305                            boundaries.push(span.start);
306                        }
307                        if span_end > offset && span_end < frag_end {
308                            boundaries.push(span_end);
309                        }
310                    }
311                }
312
313                boundaries.sort_unstable();
314                boundaries.dedup();
315
316                // Split the text at each boundary and apply overlapping highlights.
317                let chars: Vec<char> = text.chars().collect();
318                for window in boundaries.windows(2) {
319                    let sub_start = window[0];
320                    let sub_end = window[1];
321                    let sub_len = sub_end - sub_start;
322                    if sub_len == 0 {
323                        continue;
324                    }
325
326                    // Collect all highlight spans overlapping [sub_start, sub_end).
327                    let active: Vec<&HighlightSpan> = spans
328                        .iter()
329                        .filter(|s| {
330                            let s_end = s.start + s.length;
331                            s.start < sub_end && s_end > sub_start
332                        })
333                        .collect();
334
335                    let char_start = sub_start - offset;
336                    let char_end = char_start + sub_len;
337                    let sub_text: String = chars[char_start..char_end].iter().collect();
338
339                    let sub_format = if active.is_empty() {
340                        format.clone()
341                    } else {
342                        let merged_hl = merge_overlapping_highlights(&active);
343                        apply_highlight(format, &merged_hl)
344                    };
345
346                    result.push(FragmentContent::Text {
347                        text: sub_text,
348                        format: sub_format,
349                        offset: sub_start,
350                        length: sub_len,
351                    });
352                }
353            }
354            FragmentContent::Image {
355                ref name,
356                width,
357                height,
358                quality,
359                ref format,
360                offset,
361            } => {
362                // Find overlapping highlights for this single-char position.
363                let active: Vec<&HighlightSpan> = spans
364                    .iter()
365                    .filter(|s| {
366                        let s_end = s.start + s.length;
367                        s.start < offset + 1 && s_end > offset
368                    })
369                    .collect();
370
371                let img_format = if active.is_empty() {
372                    format.clone()
373                } else {
374                    let merged_hl = merge_overlapping_highlights(&active);
375                    apply_highlight(format, &merged_hl)
376                };
377
378                result.push(FragmentContent::Image {
379                    name: name.clone(),
380                    width,
381                    height,
382                    quality,
383                    format: img_format,
384                    offset,
385                });
386            }
387        }
388    }
389
390    result
391}
392
393// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
394// Re-highlighting
395// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
396
397/// Get all block IDs sorted by document_position.
398fn ordered_block_ids(inner: &TextDocumentInner) -> Vec<(u64, String)> {
399    let mut blocks = block_commands::get_all_block(&inner.ctx).unwrap_or_default();
400    blocks.sort_by_key(|b| b.document_position);
401    blocks.into_iter().map(|b| (b.id, b.plain_text)).collect()
402}
403
404impl TextDocumentInner {
405    /// Re-highlight all blocks in the document.
406    pub(crate) fn rehighlight_all(&mut self) {
407        let hl = match self.highlight {
408            Some(ref mut hl) => hl,
409            None => return,
410        };
411
412        let highlighter = Arc::clone(&hl.highlighter);
413        hl.blocks.clear();
414
415        let blocks = ordered_block_ids(self);
416        let mut previous_state: i64 = -1;
417
418        for (block_id, text) in &blocks {
419            let bid = *block_id as usize;
420            let mut ctx = HighlightContext::new(bid, previous_state, None);
421            highlighter.highlight_block(text, &mut ctx);
422            let (spans, state, user_data) = ctx.into_parts();
423
424            previous_state = state;
425
426            // Only store if there's something to store.
427            let hl = self.highlight.as_mut().unwrap();
428            hl.blocks.insert(
429                bid,
430                BlockHighlightData {
431                    spans,
432                    state,
433                    user_data,
434                },
435            );
436        }
437    }
438
439    /// Re-highlight starting from a specific block, cascading until the
440    /// block state stabilizes or the end of the document is reached.
441    pub(crate) fn rehighlight_from_block(&mut self, start_block_id: usize) {
442        let hl = match self.highlight {
443            Some(ref hl) => hl,
444            None => return,
445        };
446
447        let highlighter = Arc::clone(&hl.highlighter);
448        let blocks = ordered_block_ids(self);
449
450        // Find the starting index.
451        let start_idx = match blocks
452            .iter()
453            .position(|(id, _)| *id as usize == start_block_id)
454        {
455            Some(idx) => idx,
456            None => return,
457        };
458
459        for i in start_idx..blocks.len() {
460            let (block_id, ref text) = blocks[i];
461            let bid = block_id as usize;
462
463            let hl = self.highlight.as_ref().unwrap();
464
465            // Get previous block's state.
466            let previous_state = if i == 0 {
467                -1
468            } else {
469                let prev_bid = blocks[i - 1].0 as usize;
470                hl.blocks.get(&prev_bid).map_or(-1, |d| d.state)
471            };
472
473            // Take existing user data if available.
474            let user_data = self
475                .highlight
476                .as_mut()
477                .unwrap()
478                .blocks
479                .get_mut(&bid)
480                .and_then(|d| d.user_data.take());
481
482            let old_state = self
483                .highlight
484                .as_ref()
485                .unwrap()
486                .blocks
487                .get(&bid)
488                .map_or(-1, |d| d.state);
489
490            let mut ctx = HighlightContext::new(bid, previous_state, user_data);
491            highlighter.highlight_block(text, &mut ctx);
492            let (spans, state, user_data) = ctx.into_parts();
493
494            let hl = self.highlight.as_mut().unwrap();
495            hl.blocks.insert(
496                bid,
497                BlockHighlightData {
498                    spans,
499                    state,
500                    user_data,
501                },
502            );
503
504            // If we are past the initial block and the state didn't change,
505            // stop cascading.
506            if i > start_idx && state == old_state {
507                break;
508            }
509        }
510    }
511
512    /// Re-highlight blocks affected by a content change at the given
513    /// document position.
514    pub(crate) fn rehighlight_affected(&mut self, position: usize) {
515        if self.highlight.is_none() {
516            return;
517        }
518
519        let blocks = ordered_block_ids(self);
520
521        // Find the block that contains `position`.
522        let target_bid = blocks
523            .iter()
524            .rev()
525            .find_map(|(id, _)| {
526                let dto = block_commands::get_block(&self.ctx, id).ok().flatten()?;
527                let bp = dto.document_position as usize;
528                if position >= bp {
529                    Some(*id as usize)
530                } else {
531                    None
532                }
533            })
534            .unwrap_or_else(|| blocks.first().map_or(0, |(id, _)| *id as usize));
535
536        if blocks.is_empty() {
537            return;
538        }
539
540        self.rehighlight_from_block(target_bid);
541    }
542}