Integrate diagnostic decorations and current-source Text input boundaries

This commit is contained in:
2026-09-13 04:47:30 -07:00
parent 16904398db
commit 92ff99eb54
98 changed files with 35959 additions and 208 deletions
@@ -0,0 +1,260 @@
//! Manual actual-font witnesses; missing supported glyphs fail fixture admission.
use super::*;
use iced::advanced::{
graphics::text::Editor,
text::{
Editor as _, Highlighter as _, LineHeight, Wrapping,
highlighter::{Format, PlainText},
},
};
use iced::{Font, Pixels};
fn shaped(source: &str, width: f32) -> Editor {
let mut editor = Editor::with_text(source);
let mut highlighter = PlainText::new(&());
editor.update(
Size::new(width, 1200.),
Font::MONOSPACE,
Pixels(16.),
LineHeight::Absolute(Pixels(24.)),
Wrapping::WordOrGlyph,
&mut highlighter,
);
editor.highlight(Font::MONOSPACE, &mut highlighter, |_| Format::default());
editor
}
fn trailing(cluster: &[&cosmic_text::LayoutGlyph]) -> f32 {
let rtl = cluster[0].level.is_rtl();
assert!(cluster.iter().all(|g| g.level.is_rtl() == rtl));
if rtl {
cluster
.iter()
.map(|g| g.x.min(g.x + g.w))
.fold(f32::INFINITY, f32::min)
} else {
cluster
.iter()
.map(|g| g.x.max(g.x + g.w))
.fold(f32::NEG_INFINITY, f32::max)
}
}
#[test]
#[ignore = "manual supported-font cluster and bidi witnesses"]
fn final_multi_glyph_cluster_anchor_uses_logical_trailing_advance_edge() {
// Marks without precomposed forms exercise real multi-glyph shaping. Test
// both paragraph directions and both final-cluster directions. No font is
// assumed to support a given specimen; glyph_id=0 cannot admit a witness.
let sources = [
"abc q\u{301}\u{323}",
"אבג q\u{301}\u{323}",
"abc א\u{5b0}\u{5bc}",
"אבג א\u{5b0}\u{5bc}",
"abc ب\u{64e}\u{651}",
"אבג ب\u{64e}\u{651}",
"abc x\u{20dd}",
"אבג x\u{20dd}",
];
let mut admitted = 0;
let mut opposite_direction = 0;
for source in sources {
let editor = shaped(source, 700.);
let runs: Vec<_> = editor.buffer().layout_runs().collect();
assert_eq!(runs.len(), 1, "wide specimen unexpectedly wrapped");
let run = &runs[0];
let Some(start) = run
.glyphs
.iter()
.filter(|g| g.end == source.len())
.map(|g| g.start)
.max()
else {
continue;
};
let cluster: Vec<_> = run
.glyphs
.iter()
.filter(|g| g.start == start && g.end == source.len())
.collect();
if cluster.len() < 2 || cluster.iter().any(|g| g.glyph_id == 0) {
continue;
}
admitted += 1;
opposite_direction += usize::from(cluster[0].level.is_rtl() != run.rtl);
let expected = trailing(&cluster);
let actual = anchor(editor.buffer(), run, source.len());
eprintln!(
"EOF witness {source:?}: paragraph_rtl={} cluster={cluster:?} expected={expected} actual={actual:?}",
run.rtl
);
assert!(
actual.is_some_and(|x| (x - expected).abs() < 0.01),
"logical EOF must follow the whole cluster, including shared-range glyphs; source={source:?}"
);
}
assert!(
admitted > 0,
"fixture admission: current fonts produced no supported multi-glyph final cluster"
);
assert!(
opposite_direction > 0,
"fixture admission: no supported final cluster opposed its paragraph direction"
);
}
#[test]
#[ignore = "manual supported-font cluster and bidi witnesses"]
fn mixed_direction_omitted_separator_has_one_logical_predecessor_anchor() {
let sources = [
"one אבג דהו two זחט three יכל four מנס five עפצ six קרש seven",
"אבג one two דהו three four זחט five six יכל seven eight מנס nine",
];
let mut admitted = 0;
let mut rtl_predecessors = 0;
let mut failures = Vec::new();
let mut old_mismatches = 0;
let mut old_inside_mismatches = 0;
for source in sources {
for width in [72., 96., 120., 144., 180.] {
let editor = shaped(source, width);
let runs: Vec<_> = editor.buffer().layout_runs().collect();
assert!(runs.len() > 1);
assert_eq!(
runs.len(),
editor.buffer().lines[0].layout_opt().unwrap().len(),
"negative control requires the complete unscrolled paragraph"
);
assert!(
runs.iter()
.any(|r| r.glyphs.iter().any(|g| g.level.is_rtl()))
);
assert!(
runs.iter()
.any(|r| r.glyphs.iter().any(|g| !g.level.is_rtl()))
);
for (byte, character) in source.char_indices().filter(|(_, c)| *c == ' ') {
// Require a genuinely omitted separator, not a rendered space.
if runs
.iter()
.any(|r| r.glyphs.iter().any(|g| g.start <= byte && byte < g.end))
{
continue;
}
let Some(end) = runs
.iter()
.flat_map(|r| r.glyphs)
.filter(|g| g.end <= byte)
.map(|g| g.end)
.max()
else {
continue;
};
let Some(next) = runs
.iter()
.flat_map(|r| r.glyphs)
.filter(|g| g.start >= byte + character.len_utf8())
.map(|g| g.start)
.min()
else {
continue;
};
if !source[end..next].chars().all(char::is_whitespace) {
continue;
}
let previous: Vec<_> = runs
.iter()
.enumerate()
.filter(|(_, r)| r.glyphs.iter().any(|g| g.end == end))
.collect();
// A cluster broken across runs is a separate boundary problem;
// it is not admitted as this omitted-separator witness.
if previous.len() != 1 {
continue;
}
let (owner, run) = previous[0];
let start = run
.glyphs
.iter()
.filter(|g| g.end == end)
.map(|g| g.start)
.max()
.unwrap();
let cluster: Vec<_> = run
.glyphs
.iter()
.filter(|g| g.start == start && g.end == end)
.collect();
if cluster.iter().any(|g| g.glyph_id == 0) {
continue;
}
admitted += 1;
rtl_predecessors += usize::from(cluster[0].level.is_rtl());
let expected = trailing(&cluster);
let actual: Vec<_> = runs
.iter()
.enumerate()
.filter_map(|(i, r)| anchor(editor.buffer(), r, byte).map(|x| (i, x)))
.collect();
let matches_expected = |positions: &[(usize, f32)]| {
positions.len() == 1
&& positions[0].0 == owner
&& (positions[0].1 - expected).abs() < 0.01
};
// Exact rejected fallback, for already-proven omitted bytes:
// per-run maximum end and the next visual run's minimum start.
let old: Vec<_> = runs
.iter()
.enumerate()
.filter_map(|(i, r)| {
let last = r.glyphs.iter().max_by_key(|g| g.end)?;
let next_start = runs
.get(i + 1)
.and_then(|next| next.glyphs.iter().map(|g| g.start).min());
(byte >= last.end
&& (next_start.is_some_and(|start| byte < start)
|| (next_start.is_none() && byte == source.len())))
.then_some((
i,
if last.level.is_rtl() {
last.x
} else {
last.x + last.w
},
))
})
.collect();
let inside_clip = expected >= 0. && expected <= width;
if !matches_expected(&old) {
old_mismatches += 1;
old_inside_mismatches += usize::from(inside_clip);
}
eprintln!(
"gap witness width={width} source={source:?} byte={byte} owner={owner} expected={expected} inside_horizontal_clip={inside_clip} actual={actual:?} rejected_old={old:?} cluster={cluster:?}"
);
if !matches_expected(&actual) {
failures.push(format!("width={width} source={source:?} byte={byte} expected=({owner},{expected}) inside={inside_clip} actual={actual:?}"));
}
}
}
}
assert!(
admitted > 0,
"fixture admission: no omitted mixed-direction separator found"
);
assert!(
rtl_predecessors > 0,
"fixture admission: no omitted separator after a supported RTL cluster"
);
eprintln!(
"gap summary admitted={admitted} rejected_old_mismatches={old_mismatches} rejected_old_inside_horizontal_clip={old_inside_mismatches}"
);
assert!(
old_mismatches > 0,
"actual-layout negative control must discriminate the old fallback"
);
assert!(
failures.is_empty(),
"mixed-bidi logical anchor failures: {failures:#?}"
);
}
@@ -0,0 +1,321 @@
//! Real shaped-layout projection checks. No window/GPU or hover authority claim.
use super::*;
use iced::advanced::{
graphics::text::Editor,
text::{
Editor as _, Highlighter as _, LineHeight, Wrapping,
highlighter::{Format, PlainText},
},
};
use iced::{Font, Pixels};
use oa_core::{
language::{Diagnostic, Span},
validation::Severity,
};
const ORIGIN: Point = Point::new(37., 53.);
fn diagnostic(start: usize, end: usize) -> SourceDiagnostic {
SourceDiagnostic {
path: "a".into(),
diagnostic: Diagnostic {
severity: Severity::Error,
code: "layout".into(),
message: "fixture".into(),
object_id: None,
field: None,
line: 999,
column: 999,
span: Span { start, end },
},
related: Vec::new(),
}
}
fn editor(source: &str, width: f32, height: f32) -> Editor {
let mut editor = Editor::with_text(source);
let mut highlighter = PlainText::new(&());
editor.update(
Size::new(width, height),
Font::MONOSPACE,
Pixels(16.),
LineHeight::Absolute(Pixels(24.)),
Wrapping::WordOrGlyph,
&mut highlighter,
);
editor.highlight(Font::MONOSPACE, &mut highlighter, |_| Format::default());
editor
}
fn index(source: &str, ranges: &[Range<usize>]) -> Index {
Index::new(
Arc::from(source),
"a",
&ranges
.iter()
.map(|r| diagnostic(r.start, r.end))
.collect::<Vec<_>>(),
)
}
fn clip(width: f32, height: f32) -> Rectangle {
Rectangle::new(ORIGIN, Size::new(width, height))
}
fn covered(bands: &[Band], point: Point, ordinal: usize) -> bool {
bands
.iter()
.any(|b| b.diagnostic == ordinal && b.bounds.contains(point))
}
fn center(run: &cosmic_text::LayoutRun<'_>, glyph: &cosmic_text::LayoutGlyph) -> Point {
Point::new(
ORIGIN.x + glyph.x + glyph.w / 2.,
ORIGIN.y + run.line_top + run.line_height / 2.,
)
}
#[test]
fn repeated_alias_marks_only_second_actual_glyph_sequence_and_blank_space_misses() {
let source = "alias alias";
let editor = editor(source, 500., 120.);
let bands = index(source, std::slice::from_ref(&(6..11))).bands(
editor.buffer(),
ORIGIN,
clip(500., 120.),
);
let run = editor.buffer().layout_runs().next().unwrap();
let mut selected = 0;
let mut rejected = 0;
for glyph in run.glyphs {
let at = center(&run, glyph);
if glyph.start >= 6 {
assert!(covered(&bands, at, 0));
selected += 1;
}
if glyph.end <= 5 {
assert!(!covered(&bands, at, 0));
rejected += 1;
}
}
assert!(selected > 0 && rejected > 0);
assert!(!covered(
&bands,
Point::new(ORIGIN.x + 450., ORIGIN.y + 12.),
0
));
assert!(!covered(
&bands,
Point::new(ORIGIN.x + 12., ORIGIN.y + 100.),
0
));
// Wrong-origin negative control: expected source glyphs are not at raw local positions.
assert!(!covered(&bands, Point::new(0., 0.), 0));
}
#[test]
fn wrapped_scrolled_multiline_range_uses_actual_visible_runs_and_effective_clip() {
let source = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu\nsecond source line has more words\nthird source line\n";
let mut editor = editor(source, 150., 110.);
assert!(
editor
.buffer()
.layout_runs()
.filter(|r| r.line_i == 0)
.count()
>= 2
);
editor.perform(iced::widget::text_editor::Action::Scroll { lines: 2 });
let scroll = editor.buffer().scroll();
assert!(scroll.line > 0 || scroll.vertical > 0.);
let effective = Rectangle::new(
Point::new(ORIGIN.x + 9., ORIGIN.y + 5.),
Size::new(126., 91.),
);
let bands = index(source, std::slice::from_ref(&(0..source.len()))).bands(
editor.buffer(),
ORIGIN,
effective,
);
assert!(!bands.is_empty());
for b in &bands {
assert_eq!(b.bounds.intersection(&effective), Some(b.bounds));
assert_eq!(b.underline.intersection(&effective), Some(b.underline));
}
let mut checked = 0;
for run in editor.buffer().layout_runs() {
// Only rows whose actual underline is inside the clip are hoverable by policy.
let underline_y = ORIGIN.y + run.line_top + run.line_height - 1.;
if underline_y < effective.y || underline_y >= effective.y + effective.height {
continue;
}
for glyph in run.glyphs {
let at = center(&run, glyph);
if effective.contains(at) {
assert!(covered(&bands, at, 0), "visible glyph {glyph:?}");
checked += 1;
}
}
}
assert!(checked > 0);
assert_eq!(editor.buffer().scroll(), scroll);
assert!(
index(source, std::slice::from_ref(&(0..source.len())))
.bands(
editor.buffer(),
ORIGIN,
Rectangle::new(Point::new(-500., -500.), Size::new(10., 10.))
)
.is_empty()
);
}
#[test]
fn combining_cjk_and_bidi_ranges_cover_actual_clusters_without_neighbor_aliases() {
let source = "left e\u{301} 救援 abc אבג xyz right";
let editor = editor(source, 700., 120.);
let combining = source.find("e\u{301}").unwrap();
let cjk = source.find('救').unwrap();
let rtl = source.find('א').unwrap();
let ranges = [
combining + 1..combining + 3,
cjk..cjk + "救援".len(),
rtl..rtl + "אבג".len(),
];
let bands = index(source, &ranges).bands(editor.buffer(), ORIGIN, clip(700., 120.));
let run = editor.buffer().layout_runs().next().unwrap();
assert!(
run.glyphs.iter().any(|g| g.level.is_rtl()),
"fixture must shape RTL"
);
assert!(
run.glyphs
.iter()
.any(|g| g.start <= combining && g.end >= combining + 3),
"fixture must shape combining cluster"
);
for (ordinal, range) in ranges.iter().enumerate() {
let targets: Vec<_> = run
.glyphs
.iter()
.filter(|g| g.start < range.end && range.start < g.end)
.collect();
assert!(!targets.is_empty());
for g in targets {
assert!(covered(&bands, center(&run, g), ordinal));
}
}
for g in run.glyphs.iter().filter(|g| {
g.end <= 4
|| g.start >= source.find("right").unwrap()
|| (g.start >= source.find("abc").unwrap() && g.end <= source.find("abc").unwrap() + 3)
|| (g.start >= source.find("xyz").unwrap() && g.end <= source.find("xyz").unwrap() + 3)
}) {
assert!(!(0..ranges.len()).any(|n| covered(&bands, center(&run, g), n)));
}
}
#[test]
fn omitted_wrap_space_has_one_preceding_run_anchor_and_none_when_that_run_is_clipped_out() {
let source = "one two three four five six seven eight nine ten";
let mut found = None;
for width in [60., 72., 84., 96., 108., 120., 132., 144.] {
let e = editor(source, width, 600.);
let gap = {
let runs: Vec<_> = e.buffer().layout_runs().collect();
runs.windows(2).find_map(|pair| {
if pair[0].line_i != pair[1].line_i {
return None;
}
let before = pair[0].glyphs.iter().max_by_key(|g| g.end)?;
let after = pair[1].glyphs.iter().map(|g| g.start).min()?;
(before.end < after && source[before.end..after].chars().all(char::is_whitespace))
.then_some((
before.end,
before.x + before.w,
pair[0].line_top,
pair[0].line_height,
pair[1].line_top,
))
})
};
if let Some(gap) = gap {
found = Some((e, width, gap));
break;
}
}
let (editor, width, (byte, edge, top, height, next_top)) =
found.expect("actual wrapping must omit a separator; do not silently skip fixture");
let idx = index(source, std::slice::from_ref(&(byte..byte)));
let bands = idx.bands(editor.buffer(), ORIGIN, clip(width, 600.));
assert_eq!(
bands.len(),
1,
"exactly the preceding actual run owns the gap"
);
assert!((bands[0].bounds.y - (ORIGIN.y + top)).abs() < 0.01);
assert!(
bands[0].bounds.x <= ORIGIN.x + edge
&& bands[0].bounds.x + bands[0].bounds.width >= ORIGIN.x + edge
);
assert!((bands[0].bounds.height - height).abs() < 0.01);
let later_only = Rectangle::new(
Point::new(ORIGIN.x, ORIGIN.y + next_top),
Size::new(width, 100.),
);
assert!(
idx.bands(editor.buffer(), ORIGIN, later_only).is_empty(),
"must not relocate hidden prior-run anchor into next visible run"
);
}
#[test]
fn empty_eof_and_separator_ranges_follow_real_paragraph_ownership() {
for source in ["", "a", "a\n"] {
let editor = editor(source, 200., 200.);
let byte = source.len();
let bands = index(source, std::slice::from_ref(&(byte..byte))).bands(
editor.buffer(),
ORIGIN,
clip(200., 200.),
);
assert_eq!(bands.len(), 1, "EOF {source:?}");
let run = editor.buffer().layout_runs().last().unwrap();
assert!((bands[0].bounds.y - (ORIGIN.y + run.line_top)).abs() < 0.01);
let edge = run.glyphs.last().map_or(0., |g| g.x + g.w);
assert!(
bands[0].bounds.x <= ORIGIN.x + edge
&& bands[0].bounds.x + bands[0].bounds.width >= ORIGIN.x + edge
);
}
let source = "a\r\n\r\n雨\nz";
let editor = editor(source, 200., 200.);
let bands =
index(source, &[0..source.len(), 1..3]).bands(editor.buffer(), ORIGIN, clip(200., 200.));
let runs: Vec<_> = editor.buffer().layout_runs().collect();
assert_eq!(runs.len(), 4);
for run in &runs {
let x = run.glyphs.first().map_or(2., |g| g.x + g.w / 2.);
assert!(covered(
&bands,
Point::new(ORIGIN.x + x, ORIGIN.y + run.line_top + run.line_height / 2.),
0
));
}
let separator: Vec<_> = bands.iter().filter(|b| b.diagnostic == 1).collect();
assert_eq!(separator.len(), 1);
assert_eq!(separator[0].bounds.y, ORIGIN.y + runs[0].line_top);
}
#[test]
fn mismatched_affected_source_and_bottom_line_without_visible_underline_have_no_bands() {
let actual = editor("other", 300., 100.);
assert!(
index("alias", std::slice::from_ref(&(0..5)))
.bands(actual.buffer(), ORIGIN, clip(300., 100.))
.is_empty()
);
let actual = editor("alias", 300., 100.);
let run = actual.buffer().layout_runs().next().unwrap();
let top_only = Rectangle::new(ORIGIN, Size::new(300., run.line_height / 2.));
assert!(
index("alias", std::slice::from_ref(&(0..5)))
.bands(actual.buffer(), ORIGIN, top_only)
.is_empty(),
"partially visible text with clipped underline is not hoverable by declared policy"
);
}
@@ -0,0 +1,216 @@
//! Manual actual-layout timing distributions; no fixed machine threshold.
use super::*;
use iced::advanced::{
graphics::text::Editor,
text::{
Editor as _, Highlighter as _, LineHeight, Wrapping,
highlighter::{Format, PlainText},
},
};
use iced::{Font, Pixels};
use oa_core::{
language::{Diagnostic, Span},
validation::Severity,
};
use std::{hint::black_box, time::Instant};
fn diagnostic(start: usize, end: usize) -> SourceDiagnostic {
SourceDiagnostic {
path: "a".into(),
diagnostic: Diagnostic {
severity: Severity::Warning,
code: "perf".into(),
message: "prepared geometry specimen".into(),
object_id: None,
field: None,
line: 0,
column: 0,
span: Span { start, end },
},
related: Vec::new(),
}
}
fn shaped(source: &str) -> Editor {
let mut e = Editor::with_text(source);
settle_layout(&mut e);
e
}
// Stock TextEditor calls Editor::update during the next layout after input.
// Scroll initially changes pixel offset only; update normalizes logical scroll
// and shapes newly visible paragraphs before draw/highlight consumes them.
fn settle_layout(e: &mut Editor) {
let mut h = PlainText::new(&());
e.update(
Size::new(640., 240.),
Font::MONOSPACE,
Pixels(16.),
LineHeight::Absolute(Pixels(24.)),
Wrapping::WordOrGlyph,
&mut h,
);
e.highlight(Font::MONOSPACE, &mut h, |_| Format::default());
}
fn distribution<T>(label: &str, batch: usize, mut operation: impl FnMut() -> T) {
drop(black_box(operation())); // one excluded warmup
let mut samples = Vec::new();
for _ in 0..5 {
let mut elapsed = 0;
for _ in 0..batch {
let start = Instant::now();
let value = operation();
black_box(&value);
elapsed += start.elapsed().as_nanos();
drop(value); // allocation/destruction separated: operation includes allocation, excludes drop
}
samples.push(elapsed / batch as u128);
}
let mut sorted = samples.clone();
sorted.sort_unstable();
println!(
"TIMING {label} batch={batch} warmup=1 measured=5 ns_per_call={samples:?} min={} median={} max={} drop_excluded=true",
sorted[0], sorted[2], sorted[4]
);
}
fn run_view(label: &str, source: Arc<str>, editor: &Editor, dense: &[SourceDiagnostic]) {
let buffer = editor.buffer();
let runs: Vec<_> = buffer.layout_runs().collect();
assert!(!runs.is_empty());
let visible_glyphs: usize = runs.iter().map(|r| r.glyphs.len()).sum();
let first = runs
.iter()
.find_map(|r| {
r.glyphs
.iter()
.find(|g| g.w > 0.)
.map(|g| (r.line_i, g.start, g.end))
})
.unwrap();
let line_starts: Vec<_> = cosmic_text::LineIter::new(&source)
.map(|(r, _)| r.start)
.collect();
let sparse = [diagnostic(
line_starts[first.0] + first.1,
line_starts[first.0] + first.2,
)];
let sparse_index = Index::new(source.clone(), "a", &sparse);
let dense_index = Index::new(source.clone(), "a", dense);
let empty_index = Index::new(source.clone(), "a", &[]);
let origin = Point::new(17., 29.);
let clip = Rectangle::new(origin, Size::new(640., 240.));
let sparse_bands = sparse_index.bands(buffer, origin, clip);
assert!(!sparse_bands.is_empty(), "positive visible glyph fixture");
assert!(empty_index.bands(buffer, origin, clip).is_empty());
let rejected = Rectangle::new(Point::new(-1000., -1000.), Size::new(1., 1.));
assert!(
dense_index.bands(buffer, origin, rejected).is_empty(),
"offscreen-output negative control"
);
let dense_count = dense_index.bands(buffer, origin, clip).len();
println!(
"CASE {label} bytes={} paragraphs={} diagnostics={} visible_runs={} visible_glyphs={visible_glyphs} dense_bands={dense_count} scroll={:?}",
source.len(),
buffer.lines.len(),
dense.len(),
runs.len(),
buffer.scroll()
);
distribution(&format!("{label}/index-build-dense"), 1, || {
Index::new(black_box(source.clone()), "a", black_box(dense))
});
distribution(&format!("{label}/layout-runs-only"), 8, || {
black_box(buffer)
.layout_runs()
.map(|r| r.glyphs.len())
.sum::<usize>()
});
distribution(&format!("{label}/bands-no-diagnostics"), 8, || {
empty_index.bands(black_box(buffer), origin, clip)
});
distribution(&format!("{label}/bands-one-visible"), 8, || {
sparse_index.bands(black_box(buffer), origin, clip)
});
distribution(&format!("{label}/bands-dense"), 8, || {
dense_index.bands(black_box(buffer), origin, clip)
});
distribution(&format!("{label}/bands-dense-rejected-clip"), 8, || {
dense_index.bands(black_box(buffer), origin, rejected)
});
}
#[test]
#[ignore = "manual diagnostic projection distributions; no fixed machine threshold"]
fn annotation_projection_cost_distributions() {
// Ordinary file: many logical paragraphs; deep scroll can skip by paragraph.
let line = "alpha beta gamma delta epsilon\n";
let ordinary: Arc<str> = Arc::from(line.repeat(4000));
let ordinary_diagnostics: Vec<_> = (0..4000)
.map(|i| diagnostic(i * line.len(), i * line.len() + 5))
.collect();
let mut ordinary_editor = shaped(&ordinary);
run_view(
"ordinary/top",
ordinary.clone(),
&ordinary_editor,
&ordinary_diagnostics,
);
ordinary_editor.perform(iced::widget::text_editor::Action::Scroll { lines: 2000 });
settle_layout(&mut ordinary_editor);
assert!(
ordinary_editor.buffer().scroll().line > 1000,
"must really reach a deep logical paragraph"
);
assert!(
ordinary_editor
.buffer()
.layout_runs()
.next()
.unwrap()
.line_i
> 1000,
"newly visible deep paragraph must actually be shaped"
);
run_view(
"ordinary/deep",
ordinary,
&ordinary_editor,
&ordinary_diagnostics,
);
// Single paragraph: diagnostics near beginning remain indexed on the same
// logical line even when the visible runs are far below all of them.
let token = "alpha beta gamma delta ";
let single: Arc<str> = Arc::from(token.repeat(6000));
let dense: Vec<_> = (0..1500)
.map(|i| diagnostic(i * token.len(), i * token.len() + 5))
.collect();
let mut single_editor = shaped(&single);
let wrap_count = single_editor.buffer().lines[0].layout_opt().unwrap().len();
assert!(wrap_count > 1000, "fixture must shape thousands of wraps");
run_view("single/top", single.clone(), &single_editor, &dense);
single_editor.perform(iced::widget::text_editor::Action::Scroll {
lines: (wrap_count / 2) as i32,
});
settle_layout(&mut single_editor);
let buffer = single_editor.buffer();
assert_eq!(buffer.scroll().line, 0);
assert!(
buffer.scroll().vertical > 1000.,
"must really scroll within same paragraph"
);
let first_visible = buffer.layout_runs().next().unwrap();
assert!(
first_visible.glyphs.iter().map(|g| g.start).min().unwrap()
> dense.last().unwrap().diagnostic.span.end,
"dense diagnostics must all be above the deep viewport"
);
run_view(
"single/deep-offscreen-diagnostics",
single,
&single_editor,
&dense,
);
println!(
"DONE manual cost distributions; compare controls and top/deep ratios, not a platform-independent latency PASS"
);
}
@@ -0,0 +1,83 @@
use super::*;
use oa_core::{
language::{Diagnostic, Span},
validation::Severity,
};
fn diagnostic(path: &str, start: usize, end: usize) -> SourceDiagnostic {
SourceDiagnostic {
path: path.into(),
diagnostic: Diagnostic {
severity: Severity::Error,
code: "test".into(),
message: "synthetic admission fixture".into(),
object_id: None,
field: None,
line: 999,
column: 999,
span: Span { start, end },
},
related: Vec::new(),
}
}
#[test]
fn exact_path_and_utf8_range_admission_keep_valid_neighbor() {
let index = Index::new(
Arc::from("雨 x"),
"a.arcadia",
&[
diagnostic("b.arcadia", 0, 3),
diagnostic("a.arcadia", 1, 3),
diagnostic("a.arcadia", 4, 2),
diagnostic("a.arcadia", 0, 99),
diagnostic("a.arcadia", 4, 5),
],
);
assert_eq!(index.parts.len(), 1);
let parts = &index.parts[&0];
assert_eq!(parts.len(), 1);
assert_eq!((parts[0].diagnostic, parts[0].bytes.clone()), (4, 4..5));
}
#[test]
fn mixed_endings_use_actual_paragraphs_and_preserve_eof() {
let index = Index::new(
Arc::from("a\r\n雨\rb\n\rc\n"),
"a",
&[
diagnostic("a", 3, 6),
diagnostic("a", 7, 8),
diagnostic("a", 12, 12),
],
);
assert_eq!(index.lines, vec![0..1, 3..6, 7..8, 10..11, 12..12]);
assert_eq!(index.parts[&1][0].bytes, 0..3);
assert_eq!(index.parts[&2][0].bytes, 0..1);
assert_eq!(index.parts[&4][0].bytes, 0..0);
}
#[test]
fn half_open_next_line_excluded_separator_only_and_empty_line_anchored() {
let index = Index::new(
Arc::from("ab\r\n\r\ncd"),
"a",
&[
diagnostic("a", 0, 4),
diagnostic("a", 2, 4),
diagnostic("a", 4, 6),
],
);
assert_eq!(index.parts.len(), 2);
assert_eq!(index.parts[&0][0].bytes, 0..2);
assert_eq!(index.parts[&0][1].bytes, 2..2);
assert_eq!(index.parts[&1][0].bytes, 0..0);
assert!(!index.parts.contains_key(&2));
}
#[test]
fn empty_source_has_real_zero_width_marker() {
let index = Index::new(Arc::from(""), "a", &[diagnostic("a", 0, 0)]);
assert_eq!(index.lines, vec![0..0]);
assert_eq!(index.parts[&0][0].bytes, 0..0);
}
@@ -0,0 +1,425 @@
//! Permanent passive decoration around the existing editor. Draw uses current
//! geometry; only the source index is cached, never pointer hitboxes or messages.
//! Current-frame tooltip paragraphs stay owned until deferred renderer preparation.
use super::Index;
use iced::advanced::text::Paragraph as _;
use iced::{
Element, Event, Font, Length, Padding, Point, Rectangle, Size, Theme, Vector,
advanced::{
Clipboard, Layout, Shell, Widget, graphics, layout, overlay, renderer, text, widget,
},
alignment, mouse,
widget::text_editor,
};
use oa_core::{Id, source_project::SourceDiagnostic, validation::Severity};
use std::{cell::RefCell, sync::Arc};
pub(crate) struct Decorations<'a, Message, R: text::Renderer> {
child: Element<'a, Message, Theme, R>,
content: &'a text_editor::Content<R>,
snapshot: Option<Snapshot<'a>>,
padding: Padding,
hover_enabled: bool,
}
/// Supplied only after the App admits a current diagnostic publication for the
/// displayed draft. Absence keeps the editor mounted with no annotations.
pub(crate) struct Snapshot<'a> {
pub publication: Id,
pub source: Arc<str>,
pub path: &'a str,
pub diagnostics: &'a [SourceDiagnostic],
}
impl<'a, Message, R: text::Renderer> Decorations<'a, Message, R> {
pub fn new(
child: impl Into<Element<'a, Message, Theme, R>>,
content: &'a text_editor::Content<R>,
snapshot: Option<Snapshot<'a>>,
padding: Padding,
hover_enabled: bool,
) -> Self {
Self {
child: child.into(),
content,
snapshot,
padding,
hover_enabled,
}
}
}
struct Cached {
publication: Id,
path: String,
source: Arc<str>,
index: Index,
}
#[derive(Default)]
struct State {
index: RefCell<Option<Cached>>,
details: RefCell<Option<DetailsParagraphs>>,
}
// WGPU queues weak paragraph references during draw and upgrades them later.
// This is render-resource ownership, not a cache authorizing future hover hits.
struct DetailsParagraphs {
_body: graphics::text::Paragraph,
_footer: graphics::text::Paragraph,
}
impl<Message, R> Widget<Message, Theme, R> for Decorations<'_, Message, R>
where
R: text::Renderer<
Font = Font,
Editor = graphics::text::Editor,
Paragraph = graphics::text::Paragraph,
>,
{
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<State>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(State::default())
}
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(&self.child)]
}
fn diff(&self, tree: &mut widget::Tree) {
// A publication change only replaces the read-only index. The editor's
// existing Tree and hidden selection/IME/scroll state remain mounted.
tree.diff_children(std::slice::from_ref(&self.child));
}
fn size(&self) -> Size<Length> {
self.child.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.child.as_widget().size_hint()
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &R,
limits: &layout::Limits,
) -> layout::Node {
self.child
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn widget::Operation,
) {
self.child
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
if matches!(
event,
Event::Mouse(mouse::Event::CursorMoved { .. } | mouse::Event::CursorLeft)
) {
// Movement within a focused editor need not change stock status.
// Request painting, but do not capture or publish any input action.
shell.request_redraw();
}
self.child.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.child.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
let state = tree.state.downcast_ref::<State>();
// Revoke the preceding frame's text even on every early/hidden path.
// Current source admission and current geometry alone decide new text.
let mut details = state.details.borrow_mut();
*details = None;
let mut cache = state.index.borrow_mut();
let Some(snapshot) = &self.snapshot else {
*cache = None;
return;
};
let inner = layout.bounds().shrink(self.padding);
let Some(clip) = inner
.intersection(viewport)
.filter(|clip| clip.width > 0. && clip.height > 0.)
else {
return;
};
if cache.as_ref().is_none_or(|cached| {
cached.publication != snapshot.publication
|| cached.path != snapshot.path
|| !Arc::ptr_eq(&cached.source, &snapshot.source)
}) {
*cache = Some(Cached {
publication: snapshot.publication,
path: snapshot.path.into(),
source: snapshot.source.clone(),
index: Index::new(snapshot.source.clone(), snapshot.path, snapshot.diagnostics),
});
}
// Child draw has now applied the actual syntax highlighter. Release the
// Content borrow before further renderer work; no callback reentrancy.
let mut bands = self.content.with_editor(|editor| {
cache
.as_ref()
.unwrap()
.index
.bands(editor.buffer(), inner.position(), clip)
});
bands.sort_by_key(
|band| match snapshot.diagnostics[band.diagnostic].diagnostic.severity {
Severity::Warning => 0,
Severity::Error => 1,
},
);
let palette = theme.palette();
renderer.start_layer(clip);
for band in &bands {
let color = match snapshot.diagnostics[band.diagnostic].diagnostic.severity {
Severity::Warning => palette.warning,
Severity::Error => palette.danger,
};
renderer.fill_quad(
renderer::Quad {
bounds: band.underline,
..Default::default()
},
color,
);
}
if self.hover_enabled
&& let Some(point) = cursor.position_over(clip)
{
let mut hits: Vec<_> = bands
.iter()
.filter(|band| band.bounds.contains(point))
.map(|band| band.diagnostic)
.collect();
hits.sort_unstable();
hits.dedup();
hits.sort_by_key(|ordinal| {
(
match snapshot.diagnostics[*ordinal].diagnostic.severity {
Severity::Error => 0,
Severity::Warning => 1,
},
*ordinal,
)
});
if !hits.is_empty() {
*details = draw_details(renderer, theme, snapshot.diagnostics, &hits, point, clip);
}
}
renderer.end_layer();
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.child.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut widget::Tree,
layout: Layout<'b>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
self.child.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
impl<'a, Message: 'a, R> From<Decorations<'a, Message, R>> for Element<'a, Message, Theme, R>
where
R: text::Renderer<
Font = Font,
Editor = graphics::text::Editor,
Paragraph = graphics::text::Paragraph,
> + 'a,
{
fn from(value: Decorations<'a, Message, R>) -> Self {
Element::new(value)
}
}
fn draw_details<R: text::Renderer<Font = Font, Paragraph = graphics::text::Paragraph>>(
renderer: &mut R,
theme: &Theme,
diagnostics: &[SourceDiagnostic],
hits: &[usize],
point: Point,
clip: Rectangle,
) -> Option<DetailsParagraphs> {
const PAD: f32 = 8.;
if clip.width < 100. || clip.height < 80. {
return None;
}
let width = 520_f32.min(clip.width);
let mut details = Vec::new();
for ordinal in hits.iter().take(4) {
let issue = &diagnostics[*ordinal];
let d = &issue.diagnostic;
let severity = match d.severity {
Severity::Error => "Error",
Severity::Warning => "Warning",
};
let raw = format!(
"{severity} [{}] · {}:{}\n{}",
d.code, d.line, d.column, d.message
);
let mut value: String = raw.chars().take(1000).collect();
if value.len() < raw.len() {
value.push('…');
}
if !issue.related.is_empty() {
value.push_str(&format!(
"\n{} related location(s) in Problems",
issue.related.len()
));
}
details.push(value);
}
let content = details.join("\n\n");
let paragraph = graphics::text::Paragraph::with_text(text::Text {
content: content.as_str(),
bounds: Size::new(width - PAD * 2., f32::INFINITY),
size: 12.into(),
line_height: text::LineHeight::Relative(1.3),
font: renderer.default_font(),
align_x: text::Alignment::Left,
align_y: alignment::Vertical::Top,
shaping: text::Shaping::Advanced,
wrapping: text::Wrapping::WordOrGlyph,
});
let remaining = hits.len().saturating_sub(4);
let footer = if remaining > 0 {
format!(
"4 of {} shown · {remaining} more\nFull text: Actions → Source problems",
hits.len()
)
} else {
"Full text and locations: Actions → Source problems".into()
};
let footer_paragraph = graphics::text::Paragraph::with_text(text::Text {
content: footer.as_str(),
bounds: Size::new(width - PAD * 2., f32::INFINITY),
size: 11.into(),
line_height: text::LineHeight::Relative(1.2),
font: renderer.default_font(),
align_x: text::Alignment::Left,
align_y: alignment::Vertical::Top,
shaping: text::Shaping::Advanced,
wrapping: text::Wrapping::WordOrGlyph,
});
let footer_height = footer_paragraph.min_bounds().height;
let available_body = clip.height - PAD * 2. - footer_height - 4.;
if available_body < 16. {
return None;
}
let max_body_height =
(clip.height * 0.6 - PAD * 2. - footer_height - 4.).clamp(16., available_body);
let body_height = paragraph.min_bounds().height.min(max_body_height);
let height = PAD * 2. + body_height + 4. + footer_height;
let x = point.x.min(clip.x + clip.width - width).max(clip.x);
let y = if point.y + 18. + height <= clip.y + clip.height {
point.y + 18.
} else {
(point.y - 18. - height).max(clip.y)
};
let bounds = Rectangle::new(Point::new(x, y), Size::new(width, height));
let palette = theme.extended_palette();
renderer.fill_quad(
renderer::Quad {
bounds,
border: iced::Border {
color: palette.background.strong.color,
width: 1.,
radius: 4.into(),
},
..Default::default()
},
palette.background.weak.color,
);
let body = Rectangle::new(
Point::new(x + PAD, y + PAD),
Size::new(width - PAD * 2., body_height),
);
renderer.fill_paragraph(
&paragraph,
body.position(),
palette.background.weak.text,
body,
);
let footer_bounds = Rectangle::new(
Point::new(x + PAD, body.y + body.height + 4.),
Size::new(width - PAD * 2., footer_height),
);
renderer.fill_paragraph(
&footer_paragraph,
footer_bounds.position(),
palette.background.weak.text,
footer_bounds,
);
Some(DetailsParagraphs {
_body: paragraph,
_footer: footer_paragraph,
})
}
#[cfg(all(test, debug_assertions))]
pub(crate) mod tests;
File diff suppressed because it is too large Load Diff