Integrate diagnostic decorations and current-source Text input boundaries
This commit is contained in:
@@ -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(
|
||||
¶graph,
|
||||
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
Reference in New Issue
Block a user