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