//! 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, snapshot: Option>, 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, pub path: &'a str, pub diagnostics: &'a [SourceDiagnostic], } impl<'a, Message, R: text::Renderer> Decorations<'a, Message, R> { pub fn new( child: impl Into>, content: &'a text_editor::Content, snapshot: Option>, padding: Padding, hover_enabled: bool, ) -> Self { Self { child: child.into(), content, snapshot, padding, hover_enabled, } } } struct Cached { publication: Id, path: String, source: Arc, index: Index, } #[derive(Default)] struct State { index: RefCell>, details: RefCell>, } // 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 Widget 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::() } fn state(&self) -> widget::tree::State { widget::tree::State::new(State::default()) } fn children(&self) -> Vec { 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 { self.child.as_widget().size() } fn size_hint(&self) -> Size { 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::(); // 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> { self.child.as_widget_mut().overlay( &mut tree.children[0], layout, renderer, viewport, translation, ) } } impl<'a, Message: 'a, R> From> 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>( renderer: &mut R, theme: &Theme, diagnostics: &[SourceDiagnostic], hits: &[usize], point: Point, clip: Rectangle, ) -> Option { 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;