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`] at layout time but never touches the stored
6//! `format_runs` / `block_images` tables — 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/// Classification of the active highlighter's output.
177///
178/// Drives whether highlights are merged into the shaping input
179/// (`fragments`) or kept as a separate post-shape recolor overlay.
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub(crate) enum HighlighterKind {
182    /// No highlighter attached.
183    None,
184    /// Every span touches only paint attributes (colors, underline
185    /// style, underline/overline/strikeout, tooltip). Glyph metrics are
186    /// unchanged, so the layout engine can recolor without reshaping —
187    /// `fragments` stay base and the spans ride as a paint overlay.
188    PaintOnly,
189    /// At least one span touches a metric-affecting field. Highlights
190    /// are merged into `fragments` (reshape required on change).
191    Metric,
192}
193
194/// Returns `true` if any span sets a metric-affecting field, i.e. one
195/// that changes glyph advances or line height: font family / size /
196/// weight / bold / italic, letter / word spacing, or vertical
197/// alignment (sub/superscript). The color and underline-decoration
198/// fields are paint-only and never trigger `true`.
199pub(crate) fn spans_touch_metrics(spans: &[HighlightSpan]) -> bool {
200    spans.iter().any(|s| {
201        let f = &s.format;
202        f.font_family.is_some()
203            || f.font_point_size.is_some()
204            || f.font_weight.is_some()
205            || f.font_bold.is_some()
206            || f.font_italic.is_some()
207            || f.letter_spacing.is_some()
208            || f.word_spacing.is_some()
209            || f.vertical_alignment.is_some()
210    })
211}
212
213// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
214// Merge algorithm
215// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
216
217/// Apply highlight format overrides onto a base `TextFormat`.
218fn apply_highlight(base: &TextFormat, hl: &HighlightFormat) -> TextFormat {
219    TextFormat {
220        font_family: hl.font_family.clone().or_else(|| base.font_family.clone()),
221        font_point_size: hl.font_point_size.or(base.font_point_size),
222        font_weight: hl.font_weight.or(base.font_weight),
223        font_bold: hl.font_bold.or(base.font_bold),
224        font_italic: hl.font_italic.or(base.font_italic),
225        font_underline: hl.font_underline.or(base.font_underline),
226        font_overline: hl.font_overline.or(base.font_overline),
227        font_strikeout: hl.font_strikeout.or(base.font_strikeout),
228        letter_spacing: hl.letter_spacing.or(base.letter_spacing),
229        word_spacing: hl.word_spacing.or(base.word_spacing),
230        underline_style: hl
231            .underline_style
232            .clone()
233            .or_else(|| base.underline_style.clone()),
234        vertical_alignment: hl
235            .vertical_alignment
236            .clone()
237            .or_else(|| base.vertical_alignment.clone()),
238        tooltip: hl.tooltip.clone().or_else(|| base.tooltip.clone()),
239        foreground_color: hl.foreground_color.or(base.foreground_color),
240        background_color: hl.background_color.or(base.background_color),
241        underline_color: hl.underline_color.or(base.underline_color),
242        // Anchors are not overridden by highlights.
243        anchor_href: base.anchor_href.clone(),
244        anchor_names: base.anchor_names.clone(),
245        is_anchor: base.is_anchor,
246    }
247}
248
249/// Merge a set of overlapping highlights into a single `HighlightFormat`.
250/// Later spans override earlier spans for the same field.
251fn merge_overlapping_highlights(spans: &[&HighlightSpan]) -> HighlightFormat {
252    let mut merged = HighlightFormat::default();
253    for span in spans {
254        let f = &span.format;
255        if f.foreground_color.is_some() {
256            merged.foreground_color = f.foreground_color;
257        }
258        if f.background_color.is_some() {
259            merged.background_color = f.background_color;
260        }
261        if f.underline_color.is_some() {
262            merged.underline_color = f.underline_color;
263        }
264        if f.font_family.is_some() {
265            merged.font_family = f.font_family.clone();
266        }
267        if f.font_point_size.is_some() {
268            merged.font_point_size = f.font_point_size;
269        }
270        if f.font_weight.is_some() {
271            merged.font_weight = f.font_weight;
272        }
273        if f.font_bold.is_some() {
274            merged.font_bold = f.font_bold;
275        }
276        if f.font_italic.is_some() {
277            merged.font_italic = f.font_italic;
278        }
279        if f.font_underline.is_some() {
280            merged.font_underline = f.font_underline;
281        }
282        if f.font_overline.is_some() {
283            merged.font_overline = f.font_overline;
284        }
285        if f.font_strikeout.is_some() {
286            merged.font_strikeout = f.font_strikeout;
287        }
288        if f.letter_spacing.is_some() {
289            merged.letter_spacing = f.letter_spacing;
290        }
291        if f.word_spacing.is_some() {
292            merged.word_spacing = f.word_spacing;
293        }
294        if f.underline_style.is_some() {
295            merged.underline_style = f.underline_style.clone();
296        }
297        if f.vertical_alignment.is_some() {
298            merged.vertical_alignment = f.vertical_alignment.clone();
299        }
300        if f.tooltip.is_some() {
301            merged.tooltip = f.tooltip.clone();
302        }
303    }
304    merged
305}
306
307/// Flatten a block's stored highlight spans into a list of
308/// [`PaintHighlightSpan`](crate::flow::PaintHighlightSpan)s for the
309/// paint-overlay path.
310///
311/// Only called when the active highlighter is [`HighlighterKind::PaintOnly`],
312/// so metric fields are guaranteed absent and ignored here. Overlapping
313/// spans are resolved exactly like `merge_highlight_spans` (split at every
314/// boundary, last-wins per field) so the overlay matches what the merged
315/// path would have produced. `block_len` is the block's character length.
316/// Sub-ranges with no paint field set are skipped.
317pub(crate) fn extract_paint_spans(
318    spans: &[HighlightSpan],
319    block_len: usize,
320) -> Vec<crate::flow::PaintHighlightSpan> {
321    if spans.is_empty() || block_len == 0 {
322        return Vec::new();
323    }
324
325    // Collect and dedupe all span boundaries within (0, block_len).
326    let mut boundaries = vec![0usize, block_len];
327    for s in spans {
328        let end = s.start.saturating_add(s.length);
329        if s.start > 0 && s.start < block_len {
330            boundaries.push(s.start);
331        }
332        if end > 0 && end < block_len {
333            boundaries.push(end);
334        }
335    }
336    boundaries.sort_unstable();
337    boundaries.dedup();
338
339    let mut result = Vec::new();
340    for w in boundaries.windows(2) {
341        let (sub_start, sub_end) = (w[0], w[1]);
342        if sub_end <= sub_start {
343            continue;
344        }
345        let active: Vec<&HighlightSpan> = spans
346            .iter()
347            .filter(|s| s.start < sub_end && s.start + s.length > sub_start)
348            .collect();
349        if active.is_empty() {
350            continue;
351        }
352        let merged = merge_overlapping_highlights(&active);
353        if merged.foreground_color.is_none()
354            && merged.background_color.is_none()
355            && merged.underline_color.is_none()
356            && merged.underline_style.is_none()
357            && merged.font_underline.is_none()
358            && merged.font_overline.is_none()
359            && merged.font_strikeout.is_none()
360        {
361            continue;
362        }
363        result.push(crate::flow::PaintHighlightSpan {
364            start: sub_start,
365            length: sub_end - sub_start,
366            foreground_color: merged.foreground_color,
367            background_color: merged.background_color,
368            underline_color: merged.underline_color,
369            underline_style: merged.underline_style,
370            font_underline: merged.font_underline,
371            font_overline: merged.font_overline,
372            font_strikeout: merged.font_strikeout,
373        });
374    }
375    result
376}
377
378/// Merge highlight spans into a list of fragments.
379///
380/// Text fragments that overlap with highlight spans are split at span
381/// boundaries. The highlight format is overlaid onto the base `TextFormat`.
382/// Image fragments receive the overlay without splitting.
383/// Local copy of the word-start computation from `text_block.rs`:
384/// returns character indices (not byte offsets) where a Unicode word
385/// starts, per UAX #29. Mirrors the upstream helper so highlight
386/// splits produce accessibility-correct word_starts for each
387/// sub-fragment without reaching into `text_block`.
388fn compute_word_starts_local(text: &str) -> Vec<u8> {
389    use unicode_segmentation::UnicodeSegmentation;
390    let mut result = Vec::new();
391    let mut byte_to_char: Vec<(usize, usize)> = Vec::new();
392    for (ci, (bi, _)) in text.char_indices().enumerate() {
393        byte_to_char.push((bi, ci));
394    }
395    for (byte_off, _word) in text.unicode_word_indices() {
396        let char_idx = byte_to_char
397            .iter()
398            .find(|(bi, _)| *bi == byte_off)
399            .map(|(_, ci)| *ci)
400            .unwrap_or(0);
401        if let Ok(idx) = u8::try_from(char_idx) {
402            result.push(idx);
403        } else {
404            break;
405        }
406    }
407    result
408}
409
410pub(crate) fn merge_highlight_spans(
411    fragments: Vec<FragmentContent>,
412    spans: &[HighlightSpan],
413) -> Vec<FragmentContent> {
414    if spans.is_empty() {
415        return fragments;
416    }
417
418    let mut result = Vec::with_capacity(fragments.len());
419
420    for frag in fragments {
421        match frag {
422            FragmentContent::Text {
423                ref text,
424                ref format,
425                offset,
426                length,
427                element_id,
428                word_starts: _,
429            } => {
430                let frag_end = offset + length;
431
432                // Collect highlight boundaries within this fragment's range.
433                let mut boundaries = Vec::new();
434                boundaries.push(offset);
435                boundaries.push(frag_end);
436
437                for span in spans {
438                    let span_end = span.start + span.length;
439                    // Does this span overlap the fragment?
440                    if span.start < frag_end && span_end > offset {
441                        if span.start > offset && span.start < frag_end {
442                            boundaries.push(span.start);
443                        }
444                        if span_end > offset && span_end < frag_end {
445                            boundaries.push(span_end);
446                        }
447                    }
448                }
449
450                boundaries.sort_unstable();
451                boundaries.dedup();
452
453                // Split the text at each boundary and apply overlapping highlights.
454                let chars: Vec<char> = text.chars().collect();
455                for window in boundaries.windows(2) {
456                    let sub_start = window[0];
457                    let sub_end = window[1];
458                    let sub_len = sub_end - sub_start;
459                    if sub_len == 0 {
460                        continue;
461                    }
462
463                    // Collect all highlight spans overlapping [sub_start, sub_end).
464                    let active: Vec<&HighlightSpan> = spans
465                        .iter()
466                        .filter(|s| {
467                            let s_end = s.start + s.length;
468                            s.start < sub_end && s_end > sub_start
469                        })
470                        .collect();
471
472                    let char_start = sub_start - offset;
473                    let char_end = char_start + sub_len;
474                    let sub_text: String = chars[char_start..char_end].iter().collect();
475
476                    let sub_format = if active.is_empty() {
477                        format.clone()
478                    } else {
479                        let merged_hl = merge_overlapping_highlights(&active);
480                        apply_highlight(format, &merged_hl)
481                    };
482
483                    let sub_word_starts = compute_word_starts_local(&sub_text);
484                    result.push(FragmentContent::Text {
485                        text: sub_text,
486                        format: sub_format,
487                        offset: sub_start,
488                        length: sub_len,
489                        // All sub-fragments split from one source
490                        // `FragmentContent::Text` reference the same
491                        // underlying format run — only the highlight
492                        // formatting differs. Sharing the id is
493                        // correct for accessibility (the underlying
494                        // text belongs to one stable run) at the cost
495                        // that synthetic NodeIds for highlighted
496                        // sub-runs collide unless the caller further
497                        // disambiguates.
498                        // The fern-widgets layer handles that by
499                        // mixing the `offset` into the synthetic-id
500                        // hash alongside `element_id`.
501                        element_id,
502                        word_starts: sub_word_starts,
503                    });
504                }
505            }
506            FragmentContent::Image {
507                ref name,
508                width,
509                height,
510                quality,
511                ref format,
512                offset,
513                element_id,
514            } => {
515                // Find overlapping highlights for this single-char position.
516                let active: Vec<&HighlightSpan> = spans
517                    .iter()
518                    .filter(|s| {
519                        let s_end = s.start + s.length;
520                        s.start < offset + 1 && s_end > offset
521                    })
522                    .collect();
523
524                let img_format = if active.is_empty() {
525                    format.clone()
526                } else {
527                    let merged_hl = merge_overlapping_highlights(&active);
528                    apply_highlight(format, &merged_hl)
529                };
530
531                result.push(FragmentContent::Image {
532                    name: name.clone(),
533                    width,
534                    height,
535                    quality,
536                    format: img_format,
537                    offset,
538                    element_id,
539                });
540            }
541        }
542    }
543
544    result
545}
546
547// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
548// Re-highlighting
549// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
550
551/// Get all block IDs sorted by document_position.
552fn ordered_block_ids(inner: &TextDocumentInner) -> Vec<(u64, String)> {
553    let mut blocks = block_commands::get_all_block(&inner.ctx).unwrap_or_default();
554    let store = inner.ctx.db_context.get_store();
555    crate::inner::refresh_block_positions(&mut blocks, store);
556    blocks.sort_by_key(|b| b.document_position);
557    blocks
558        .into_iter()
559        .map(|b| {
560            let entity: common::entities::Block = b.clone().into();
561            let text = common::database::rope_helpers::block_content_via_store(&entity, store);
562            (b.id, text)
563        })
564        .collect()
565}
566
567impl TextDocumentInner {
568    /// Re-highlight all blocks in the document.
569    pub(crate) fn rehighlight_all(&mut self) {
570        let hl = match self.highlight {
571            Some(ref mut hl) => hl,
572            None => return,
573        };
574
575        let highlighter = Arc::clone(&hl.highlighter);
576        hl.blocks.clear();
577
578        let blocks = ordered_block_ids(self);
579        let mut previous_state: i64 = -1;
580
581        for (block_id, text) in &blocks {
582            let bid = *block_id as usize;
583            let mut ctx = HighlightContext::new(bid, previous_state, None);
584            highlighter.highlight_block(text, &mut ctx);
585            let (spans, state, user_data) = ctx.into_parts();
586
587            previous_state = state;
588
589            // Only store if there's something to store.
590            let hl = self.highlight.as_mut().unwrap();
591            hl.blocks.insert(
592                bid,
593                BlockHighlightData {
594                    spans,
595                    state,
596                    user_data,
597                },
598            );
599        }
600
601        self.recompute_highlight_kind();
602    }
603
604    /// Recompute [`highlight_kind`](TextDocumentInner::highlight_kind) from the
605    /// currently stored spans. Paint-only iff a highlighter is attached and no
606    /// stored span touches a metric field.
607    pub(crate) fn recompute_highlight_kind(&mut self) {
608        self.highlight_kind = match &self.highlight {
609            None => HighlighterKind::None,
610            Some(hl) => {
611                if hl.blocks.values().any(|bd| spans_touch_metrics(&bd.spans)) {
612                    HighlighterKind::Metric
613                } else {
614                    HighlighterKind::PaintOnly
615                }
616            }
617        };
618    }
619
620    /// Re-highlight starting from a specific block, cascading until the
621    /// block state stabilizes or the end of the document is reached.
622    pub(crate) fn rehighlight_from_block(&mut self, start_block_id: usize) {
623        let hl = match self.highlight {
624            Some(ref hl) => hl,
625            None => return,
626        };
627
628        let highlighter = Arc::clone(&hl.highlighter);
629        let blocks = ordered_block_ids(self);
630
631        // Find the starting index.
632        let start_idx = match blocks
633            .iter()
634            .position(|(id, _)| *id as usize == start_block_id)
635        {
636            Some(idx) => idx,
637            None => return,
638        };
639
640        for i in start_idx..blocks.len() {
641            let (block_id, ref text) = blocks[i];
642            let bid = block_id as usize;
643
644            let hl = self.highlight.as_ref().unwrap();
645
646            // Get previous block's state.
647            let previous_state = if i == 0 {
648                -1
649            } else {
650                let prev_bid = blocks[i - 1].0 as usize;
651                hl.blocks.get(&prev_bid).map_or(-1, |d| d.state)
652            };
653
654            // Take existing user data if available.
655            let user_data = self
656                .highlight
657                .as_mut()
658                .unwrap()
659                .blocks
660                .get_mut(&bid)
661                .and_then(|d| d.user_data.take());
662
663            let old_state = self
664                .highlight
665                .as_ref()
666                .unwrap()
667                .blocks
668                .get(&bid)
669                .map_or(-1, |d| d.state);
670
671            let mut ctx = HighlightContext::new(bid, previous_state, user_data);
672            highlighter.highlight_block(text, &mut ctx);
673            let (spans, state, user_data) = ctx.into_parts();
674
675            let hl = self.highlight.as_mut().unwrap();
676            hl.blocks.insert(
677                bid,
678                BlockHighlightData {
679                    spans,
680                    state,
681                    user_data,
682                },
683            );
684
685            // If we are past the initial block and the state didn't change,
686            // stop cascading.
687            if i > start_idx && state == old_state {
688                break;
689            }
690        }
691
692        self.recompute_highlight_kind();
693    }
694
695    /// Re-highlight blocks affected by a content change at the given
696    /// document position.
697    pub(crate) fn rehighlight_affected(&mut self, position: usize) {
698        if self.highlight.is_none() {
699            return;
700        }
701
702        let blocks = ordered_block_ids(self);
703
704        let store = self.ctx.db_context.get_store();
705        // Find the block that contains `position`.
706        let target_bid = blocks
707            .iter()
708            .rev()
709            .find_map(|(id, _)| {
710                let mut dto = block_commands::get_block(&self.ctx, id).ok().flatten()?;
711                crate::inner::refresh_block_position(&mut dto, store);
712                let bp = dto.document_position as usize;
713                if position >= bp {
714                    Some(*id as usize)
715                } else {
716                    None
717                }
718            })
719            .unwrap_or_else(|| blocks.first().map_or(0, |(id, _)| *id as usize));
720
721        if blocks.is_empty() {
722            return;
723        }
724
725        self.rehighlight_from_block(target_bid);
726    }
727}