Preserve raw include diagnostic provenance and derive bands from the existing shaped editor. Retain deferred tooltip paragraphs through rendering. Include source, regression, failure and native-trial evidence; corrected native acceptance remains pending.
310 lines
12 KiB
Rust
310 lines
12 KiB
Rust
//! 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;
|