Files
ARCADIA-Studio/crates/oa-desktop/src/source_annotations/widget.rs
T
sorokin.oleg.edu 9fd76aef3a Add current-draft diagnostic underlines and passive hover
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.
2026-09-13 01:43:22 -07:00

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(
&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))]
mod tests;