Files
ARCADIA-Studio/crates/oa-desktop/src/source_annotations.rs
T

310 lines
12 KiB
Rust
Raw Normal View History

//! 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<usize>,
}
/// 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<str>,
lines: Vec<Range<usize>>,
parts: BTreeMap<usize, Vec<Part>>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Band {
pub diagnostic: usize,
pub bounds: Rectangle,
pub underline: Rectangle,
}
impl Index {
pub fn new(source: Arc<str>, 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<usize, Vec<Part>> = 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<Band> {
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<f32> {
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<f32> {
// 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;