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,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;