Integrate diagnostic decorations and current-source Text input boundaries
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
//! 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;
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
pub(crate) use widget::tests::AppMounted;
|
||||
Reference in New Issue
Block a user