//! Read-only source-range admission and projection onto the editor's real layout. //! Byte ranges retain their diagnostic meaning; visual bands expand to clusters. mod widget; use iced::{Point, Rectangle, Size, Vector, advanced::graphics::text::cosmic_text}; use oa_core::source_project::SourceDiagnostic; use std::{collections::BTreeMap, ops::Range, sync::Arc}; use unicode_segmentation::UnicodeSegmentation; pub(crate) use widget::{Decorations, Snapshot}; #[derive(Debug)] struct Part { diagnostic: usize, bytes: Range, } /// Source-only index, reusable across pointer movement, scroll and reflow. /// A caller must separately establish publication/path/snapshot authority. #[derive(Debug)] pub(crate) struct Index { source: Arc, lines: Vec>, parts: BTreeMap>, } #[derive(Debug, Clone, PartialEq)] pub(crate) struct Band { pub diagnostic: usize, pub bounds: Rectangle, pub underline: Rectangle, } impl Index { pub fn new(source: Arc, path: &str, diagnostics: &[SourceDiagnostic]) -> Self { // This is the same paragraph splitter used by the actual editor engine, // including standalone CR and LFCR, not the diagnostic's LF-only label. let mut lines: Vec<_> = cosmic_text::LineIter::new(&source) .map(|(bytes, _)| bytes) .collect(); if lines.last().is_none_or(|line| line.end < source.len()) { lines.push(source.len()..source.len()); } let mut parts: BTreeMap> = BTreeMap::new(); for (ordinal, diagnostic) in diagnostics.iter().enumerate() { let span = diagnostic.diagnostic.span; if diagnostic.path != path || span.start > span.end || span.end > source.len() || !source.is_char_boundary(span.start) || !source.is_char_boundary(span.end) { continue; } let first = lines.partition_point(|line| line.start <= span.start) - 1; let last_byte = if span.start == span.end { span.end } else { span.end - 1 }; let last = lines.partition_point(|line| line.start <= last_byte) - 1; for (line_index, line) in lines.iter().enumerate().take(last + 1).skip(first) { let start = span.start.max(line.start).min(line.end) - line.start; let end = span.end.min(line.end) - line.start; parts.entry(line_index).or_default().push(Part { diagnostic: ordinal, bytes: start..end, }); } } Self { source, lines, parts, } } /// Only actual visible runs are projected. The engine's iterator already /// applies scroll; the caller supplies the actual padded origin and clip. pub fn bands(&self, buffer: &cosmic_text::Buffer, origin: Point, clip: Rectangle) -> Vec { let mut bands = Vec::new(); if self.parts.is_empty() || clip.width <= 0. || clip.height <= 0. { return bands; } let translation = Vector::new(origin.x, origin.y); let mut checked_line = None; let mut source_matches = false; for run in buffer.layout_runs() { let Some(parts) = self.parts.get(&run.line_i) else { continue; }; let underline_y = origin.y + run.line_top + run.line_height - 2.; if underline_y >= clip.y + clip.height || underline_y + 1.5 <= clip.y { continue; } // Several visible wraps can share a very large paragraph. Validate // its exact bytes once in this projection, never in a stale cache. if checked_line != Some(run.line_i) { checked_line = Some(run.line_i); source_matches = self .lines .get(run.line_i) .and_then(|line| self.source.get(line.clone())) == Some(run.text); } if !source_matches { // A mismatched editor must never remap a diagnostic by text search. continue; } let (first_byte, last_byte) = run .glyphs .iter() .fold((usize::MAX, 0), |(first, last), glyph| { (first.min(glyph.start), last.max(glyph.end)) }); for part in parts { // Bidirectional glyphs need not be ordered by source byte. Their // envelope safely rejects disjoint ranges before a glyph scan. // Empty ranges retain the separate logical gap/EOF rules. if !part.bytes.is_empty() && (part.bytes.end <= first_byte || part.bytes.start >= last_byte) { continue; } let mut intervals: Vec<(f32, f32)> = if part.bytes.is_empty() { anchor(buffer, &run, part.bytes.start) .into_iter() .map(|x| (x, x + 4.)) .collect() } else { run.glyphs .iter() .filter(|glyph| { glyph.start < part.bytes.end && part.bytes.start < glyph.end }) .map(|glyph| { let left = glyph.x.min(glyph.x + glyph.w); let right = glyph.x.max(glyph.x + glyph.w); (left, right.max(left + 1.)) }) .collect() }; intervals.sort_by(|a, b| a.0.total_cmp(&b.0)); let mut merged: Vec<(f32, f32)> = Vec::new(); for (left, right) in intervals { if let Some(previous) = merged.last_mut() && left <= previous.1 + 0.01 { previous.1 = previous.1.max(right); } else { merged.push((left, right)); } } for (mut left, mut right) in merged { if part.bytes.is_empty() { // An insertion at the right edge remains visible while // still containing its true boundary, without borrowing a token. let max_x = clip.x + clip.width - origin.x; if left <= max_x && right > max_x { right = max_x; left = (right - 4.).max(0.); } } let bounds = Rectangle::new( Point::new(left, run.line_top), Size::new(right - left, run.line_height), ) + translation; let underline = Rectangle { y: bounds.y + bounds.height - 2., height: 1.5, ..bounds }; if let (Some(bounds), Some(underline)) = (bounds.intersection(&clip), underline.intersection(&clip)) && bounds.width > 0. && bounds.height > 0. && underline.width > 0. && underline.height > 0. { bands.push(Band { diagnostic: part.diagnostic, bounds, underline, }); } } } } bands } } /// Prefer the following cluster at a wrap boundary. EOF uses the preceding /// cluster's trailing edge; an empty paragraph has a real origin insertion. fn anchor( buffer: &cosmic_text::Buffer, run: &cosmic_text::LayoutRun<'_>, byte: usize, ) -> Option { if run.glyphs.is_empty() { return (byte == 0 && run.text.is_empty()).then_some(0.); } if let Some(glyph) = run .glyphs .iter() .find(|glyph| glyph.start <= byte && byte < glyph.end) { // Match cosmic-text's cursor heuristic inside a shaped cluster: a // proportional grapheme advance, including ligatures. This describes // a caret affordance, not an exact font-provided ligature caret or ink. let cluster = run.text.get(glyph.start..glyph.end)?; let mut before = 0; let mut total = 0; for (offset, _) in cluster.grapheme_indices(true) { before += usize::from(glyph.start + offset < byte); total += 1; } let advance = glyph.w * before as f32 / total.max(1) as f32; return Some(if glyph.level.is_rtl() { glyph.x + glyph.w - advance } else { glyph.x + advance }); } // Visual order need not be logical order, even between wrapped runs. First // find this run's best logical predecessor, not its largest end overall. let previous = run .glyphs .iter() .filter(|glyph| glyph.end <= byte) .max_by_key(|glyph| (glyph.end, glyph.start))?; // Most unrelated runs fail here without scanning the paragraph's layout. // Only a genuinely omitted separator or EOF may use a predecessor edge. if !run .text .get(previous.end..byte)? .chars() .all(char::is_whitespace) || (byte != run.text.len() && !run.text.get(byte..)?.chars().next()?.is_whitespace()) { return None; } let layout = buffer.lines.get(run.line_i)?.layout_opt()?; let key = (previous.end, previous.start); let mut current_found = false; for line in layout { let current = std::ptr::eq(line.glyphs.as_slice(), run.glyphs); current_found |= current; for glyph in &line.glyphs { // A painted source position, including an exact next-run start, // belongs to that containing run, never to a gap fallback here. if glyph.start <= byte && byte < glyph.end { return None; } let candidate = (glyph.end, glyph.start); if glyph.end <= byte && candidate > key { return None; } // A cluster split across visual runs has no single run-local edge. // Suppress unavailable geometry instead of moving it to a neighbor. if !current && candidate == key { return None; } } } if !current_found { return None; } cluster_trailing_edge(run.glyphs, previous) } fn cluster_trailing_edge( glyphs: &[cosmic_text::LayoutGlyph], cluster: &cosmic_text::LayoutGlyph, ) -> Option { // Multiple glyphs can share a source cluster. Their iterator order follows // paragraph layout, so its last entry need not be the logical trailing edge. let rtl = cluster.level.is_rtl(); let mut edge = None; for glyph in glyphs .iter() .filter(|glyph| glyph.start == cluster.start && glyph.end == cluster.end) { if glyph.level.is_rtl() != rtl { return None; } let next = if rtl { glyph.x.min(glyph.x + glyph.w) } else { glyph.x.max(glyph.x + glyph.w) }; edge = Some(edge.map_or(next, |value: f32| { if rtl { value.min(next) } else { value.max(next) } })); } edge } #[cfg(test)] mod layout_tests; #[cfg(test)] mod tests; #[cfg(test)] mod anchor_tests; #[cfg(test)] mod performance_tests;