diff --git a/crates/ui-egui/src/diagram_view.rs b/crates/ui-egui/src/diagram_view.rs new file mode 100644 index 0000000..188ac28 --- /dev/null +++ b/crates/ui-egui/src/diagram_view.rs @@ -0,0 +1,321 @@ +//! Native Mermaid diagram rendering for notes. + +use std::hash::{Hash, Hasher}; +use std::ops::Range; + +use egui::Vec2; +use notescraft_diagrams::{Diagram, Layout, Palette}; + +use crate::theme::Tokens; + +mod paint; + +const MIN_VIEW_HEIGHT: f32 = 120.0; +const MAX_VIEW_HEIGHT: f32 = 640.0; +const CONTENT_PAD: f32 = 16.0; + +/// Interactive diagram state, including the parse cache and viewport controls. +#[derive(Debug, Default)] +pub struct DiagramView { + source_hash: Option, + diagram: Option, + layout: Option, + error: Option, + /// Whether Mermaid source is shown below the rendered diagram. + pub source_visible: bool, + /// User zoom multiplier, initially fitted to available width. + pub zoom: f32, + /// User pan offset in points. + pub pan: Vec2, + #[cfg(test)] + parse_count: usize, +} + +/// Actions produced by [`show_diagram`]. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct DiagramResponse { + /// SVG requested by clicking the copy button. + pub copy_svg: Option, +} + +/// Renders one Mermaid source block and reports toolbar actions. +pub fn show_diagram(ui: &mut egui::Ui, tokens: &Tokens, view: &mut DiagramView, source: &str) -> DiagramResponse { + update_cache(view, source); + let mut response = DiagramResponse::default(); + let kind = view.diagram.as_ref().map_or(strings::ERROR, Diagram::kind); + ui.horizontal(|ui| { + ui.strong(kind); + if let Some(layout) = view.layout.as_ref() + && ui.small_button(strings::SVG).clicked() + { + response.copy_svg = Some(notescraft_diagrams::to_svg_with(layout, &diagram_palette(tokens))); + } + if ui.small_button(if view.source_visible { strings::HIDE_SOURCE } else { strings::SHOW_SOURCE }).clicked() { + view.source_visible = !view.source_visible; + } + }); + + if let Some(error) = view.error.as_deref() { + paint::show_error(ui, tokens, error, source); + } else if let Some(layout) = view.layout.as_ref() { + paint::show_layout(ui, tokens, &mut view.pan, &mut view.zoom, layout); + if let Some(diagram) = view.diagram.as_ref() { + let diagnostics = diagram.diagnostics(); + if !diagnostics.is_empty() { + egui::CollapsingHeader::new(strings::DIAGNOSTICS).show(ui, |ui| { + for diagnostic in diagnostics { + ui.label(format!("{}: {}", diagnostic.line, diagnostic.message)); + } + }); + } + } + } + if view.source_visible { + ui.code(source); + } + response +} + +/// Estimates inline note space for a Mermaid source block. +#[must_use] +pub fn inline_diagram_height(source: &str) -> f32 { + let lines = source.lines().count().min(24); + let line_count = u32::try_from(lines).unwrap_or(24); + (MIN_VIEW_HEIGHT + crate::style::narrow_f32(f64::from(line_count)) * 16.0).min(MAX_VIEW_HEIGHT) +} + +fn update_cache(view: &mut DiagramView, source: &str) { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + source.hash(&mut hasher); + let hash = hasher.finish(); + if view.source_hash == Some(hash) { + return; + } + view.source_hash = Some(hash); + view.diagram = None; + view.layout = None; + view.error = None; + view.zoom = 0.0; + view.pan = Vec2::ZERO; + #[cfg(test)] + { + view.parse_count += 1; + } + match notescraft_diagrams::parse(source) { + Ok(diagram) => match notescraft_diagrams::layout(&diagram) { + Ok(layout) => { + view.layout = Some(layout); + view.diagram = Some(diagram); + } + Err(error) => { + view.diagram = Some(diagram); + view.error = Some(error.to_string()); + } + }, + Err(error) => view.error = Some(error.to_string()), + } +} + +fn diagram_palette(tokens: &Tokens) -> Palette { + let colors = &tokens.theme.colors; + Palette { + background: hex_color(colors.canvas), + node_fill: hex_color(colors.surface), + node_stroke: hex_color(colors.accent), + text: hex_color(colors.text), + edge: hex_color(colors.text_muted), + group_fill: hex_color(colors.surface_2), + group_stroke: hex_color(colors.border), + } +} + +fn hex_color(value: notescraft_themes::Rgba) -> String { + format!("#{:02x}{:02x}{:02x}", value.r, value.g, value.b) +} + +mod strings { + pub(super) const SVG: &str = "SVG"; + pub(super) const ERROR: &str = "Diagram source"; + pub(super) const DIAGNOSTICS: &str = "Diagnostics"; + pub(super) const SHOW_SOURCE: &str = "Show source"; + pub(super) const HIDE_SOURCE: &str = "Hide source"; + pub(super) const SOURCE_ERROR: &str = "Could not render this diagram"; +} + +/// Finds Mermaid fence bodies and their language labels in Markdown source. +#[must_use] +pub fn fence_ranges(markdown: &str) -> Vec<(Range, String)> { + let mut found = Vec::new(); + let mut opening: Option<(char, usize, usize)> = None; + let mut offset = 0; + for line_with_newline in markdown.split_inclusive('\n') { + let line = line_with_newline + .strip_suffix('\n') + .unwrap_or(line_with_newline) + .strip_suffix('\r') + .unwrap_or(line_with_newline.strip_suffix('\n').unwrap_or(line_with_newline)); + let trimmed = line.trim_start(); + let mut chars = trimmed.chars(); + let marker = chars.next(); + let count = trimmed.chars().take_while(|ch| Some(*ch) == marker).count(); + if let Some((open_marker, open_count, body_start)) = opening { + let tail = trimmed.get(count..).unwrap_or("").trim(); + if marker == Some(open_marker) && count >= open_count && tail.is_empty() { + found.push((body_start..offset, String::from("mermaid"))); + opening = None; + } + } else if matches!(marker, Some('`' | '~')) && count >= 3 { + let info = trimmed.get(count..).unwrap_or("").trim(); + if info.split_whitespace().next().is_some_and(|language| language.eq_ignore_ascii_case("mermaid")) { + opening = Some((marker.unwrap_or('`'), count, offset + line_with_newline.len())); + } + } + offset += line_with_newline.len(); + } + found +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::fmt::Write as _; + + use egui::{Context, RawInput, Rect}; + use notescraft_themes::{Colors, Fonts, GraphColors, Radii, Rgba, Theme, Variant}; + + use super::{DiagramView, fence_ranges, inline_diagram_height, show_diagram}; + use crate::theme::Tokens; + + fn tokens() -> Tokens { + let shade = Rgba::new(50, 60, 70, 255); + Tokens::new(Theme { + id: String::from("test"), + name: String::from("Test"), + variant: Variant::Dark, + colors: Colors { + background: shade, + surface: Rgba::new(40, 45, 52, 255), + surface_2: Rgba::new(60, 65, 72, 255), + canvas: Rgba::new(20, 25, 32, 255), + border: shade, + text: Rgba::WHITE, + text_muted: shade, + accent: Rgba::new(120, 150, 220, 255), + on_accent: Rgba::BLACK, + link: shade, + link_unresolved: shade, + selection: shade, + code_background: shade, + highlight: shade, + success: shade, + warning: shade, + danger: shade, + }, + callouts: BTreeMap::new(), + graph: GraphColors { node: shade, node_active: shade, edge: shade, unresolved: shade }, + radii: Radii { small: 4.0, medium: 8.0 }, + fonts: Fonts { ui: String::new(), editor: String::new(), mono: String::new() }, + }) + } + + fn context() -> Context { + let ctx = Context::default(); + crate::fonts::install(&ctx); + let mut warmup = ctx.run_ui(RawInput::default(), |_| {}); + warmup.textures_delta.clear(); + ctx + } + + fn draw(ctx: &Context, tokens: &Tokens, view: &mut DiagramView, source: &str) -> (usize, Vec) { + let input = RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 700.0))), ..RawInput::default() }; + let mut output = ctx.run_ui(input, |ui| { + let _ = show_diagram(ui, tokens, view, source); + }); + let count = output.shapes.len(); + let labels = output + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::epaint::Shape::Text(text) => Some(text.galley.text().to_owned()), + _ => None, + }) + .collect(); + output.textures_delta.clear(); + (count, labels) + } + + #[test] + fn finds_mermaid_bodies_for_backticks_and_tildes() { + let source = "é before\n````mermaid\nflowchart TD\nA-->B\n````\n~~~mermaid title\npie\n~~~\n```rust\nignored\n```\n"; + let found = fence_ranges(source); + let bodies: Vec<&str> = found.iter().filter_map(|(range, _)| source.get(range.clone())).collect(); + assert_eq!(bodies, ["flowchart TD\nA-->B\n", "pie\n"]); + assert_eq!(found.iter().map(|(_, language)| language.as_str()).collect::>(), ["mermaid", "mermaid"]); + } + + #[test] + fn fence_ranges_returns_no_unclosed_or_non_mermaid_fences() { + for source in ["```mermaid\nflowchart TD\n", "~~~rust\npie\n~~~", "ordinary text"] { + assert_eq!(fence_ranges(source), []); + } + let source = "```MERMAID\npie\n```"; + let range = fence_ranges(source).first().map(|(range, _)| range.clone()); + assert_eq!(range.and_then(|range| source.get(range)), Some("pie\n")); + } + + #[test] + fn all_supported_kinds_paint_shapes_and_reuse_parse_cache() { + let examples = [ + "flowchart TD\nA-->B", + "sequenceDiagram\nAlice->>Bob: Hello", + "classDiagram\nclass Shape", + "stateDiagram-v2\n[*] --> Idle", + "erDiagram\nCUSTOMER ||--o{ ORDER : places", + "gantt\ntitle Release\nsection Design\nSpec :done, 2026-10-01, 3d", + "pie\ntitle Fruits\n\"Apples\" : 42\nBananas : 18", + ]; + let ctx = context(); + let theme = tokens(); + for source in examples { + let mut view = DiagramView::default(); + assert!(draw(&ctx, &theme, &mut view, source).0 > 1, "no shapes for {source}"); + assert_eq!(view.parse_count, 1); + let _ = draw(&ctx, &theme, &mut view, source); + assert_eq!(view.parse_count, 1, "unchanged source reparsed"); + } + } + + #[test] + fn invalid_input_keeps_source_and_reports_error() { + let ctx = context(); + let mut view = DiagramView::default(); + let source = "this is not a diagram"; + let (shapes, labels) = draw(&ctx, &tokens(), &mut view, source); + assert!(shapes > 1); + assert!(labels.iter().any(|label| label == source), "source must remain visible"); + assert!(view.error.is_some()); + } + + #[test] + fn hostile_sources_hit_parser_caps_without_panicking() { + let ctx = context(); + let theme = tokens(); + let mut view = DiagramView::default(); + let large = format!("flowchart TD\n{}", "A-->B\n".repeat(200_000)); + let _ = draw(&ctx, &theme, &mut view, &large); + assert!(view.error.is_some()); + let mut edges = String::new(); + for number in 0..4_999 { + let _ = writeln!(edges, "N{number}-->N{}", number + 1); + } + let source = format!("flowchart TD\n{edges}"); + let _ = draw(&ctx, &theme, &mut view, &source); + assert!(view.error.is_none(), "5,000 nodes are within diagrams cap"); + } + + #[test] + fn inline_height_is_bounded() { + assert_eq!(inline_diagram_height("pie"), 136.0); + assert_eq!(inline_diagram_height(&"line\n".repeat(100)), 504.0); + } +} diff --git a/crates/ui-egui/src/diagram_view/paint.rs b/crates/ui-egui/src/diagram_view/paint.rs new file mode 100644 index 0000000..9aa8652 --- /dev/null +++ b/crates/ui-egui/src/diagram_view/paint.rs @@ -0,0 +1,219 @@ +//! egui painter for the diagram geometry model. + +use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, StrokeKind, Vec2}; +use notescraft_diagrams::{Align, EdgeStyle, Frame, Layout, MarkerKind, Palette, Point, Shape, Text}; + +use super::{CONTENT_PAD, MAX_VIEW_HEIGHT, diagram_palette}; +use crate::theme::{Tokens, color}; + +pub(super) fn show_error(ui: &mut egui::Ui, tokens: &Tokens, error: &str, source: &str) { + let radius = crate::style::radius_u8(tokens.radius_medium()); + egui::Frame::new().fill(color(tokens.theme.colors.surface_2)).corner_radius(CornerRadius::same(radius)).inner_margin(12.0).show(ui, |ui| { + ui.colored_label(color(tokens.theme.colors.danger), super::strings::SOURCE_ERROR); + ui.label(error); + ui.code(source); + }); +} + +pub(super) fn show_layout(ui: &mut egui::Ui, tokens: &Tokens, pan: &mut Vec2, zoom: &mut f32, layout: &Layout) { + let available_width = ui.available_width().max(1.0); + let fit_scale = (available_width - CONTENT_PAD * 2.0).max(1.0) / crate::style::narrow_f32(layout.width); + let aspect_height = crate::style::narrow_f32(layout.height) * fit_scale; + let base_scale = if aspect_height > MAX_VIEW_HEIGHT { fit_scale * MAX_VIEW_HEIGHT / aspect_height } else { fit_scale }; + let current_zoom = if *zoom <= 0.0 { 1.0 } else { *zoom }; + let scale = base_scale * current_zoom; + let height = (crate::style::narrow_f32(layout.height) * scale + CONTENT_PAD * 2.0).clamp(96.0, MAX_VIEW_HEIGHT + CONTENT_PAD * 2.0); + let (rect, response) = ui.allocate_exact_size(Vec2::new(available_width, height), Sense::drag()); + if response.dragged() { + *pan += response.drag_delta(); + } + if response.hovered() { + let scroll = ui.input(|input| input.smooth_scroll_delta.y); + if scroll != 0.0 { + *zoom = (current_zoom * (1.0 + scroll * 0.001)).clamp(0.2, 5.0); + } + } + let alpha = crate::motion::appear(ui.ctx(), ("diagram-appear", response.id), crate::motion::FAST, crate::motion::ease_out_cubic); + let palette = diagram_palette(tokens); + let transform = Transform { + origin: rect.min + *pan + Vec2::new(CONTENT_PAD, CONTENT_PAD + (1.0 - alpha) * CONTENT_PAD), + scale, + radius: crate::style::radius_u8(tokens.radius_medium()), + }; + let painter = ui.painter().with_clip_rect(rect); + for shape in &layout.shapes { + paint_shape(&painter, shape, &transform, &palette, alpha); + } +} + +struct Transform { + origin: Pos2, + scale: f32, + radius: u8, +} + +fn pos(point: Point, transform: &Transform) -> Pos2 { + transform.origin + Vec2::new(crate::style::narrow_f32(point.x) * transform.scale, crate::style::narrow_f32(point.y) * transform.scale) +} + +fn rect(frame: Frame, transform: &Transform) -> Rect { + Rect::from_min_size( + pos(Point::new(frame.x, frame.y), transform), + Vec2::new(crate::style::narrow_f32(frame.width) * transform.scale, crate::style::narrow_f32(frame.height) * transform.scale), + ) +} + +fn paint_shape(painter: &egui::Painter, shape: &Shape, transform: &Transform, palette: &Palette, alpha: f32) { + let stroke = Stroke::new(1.5, tinted(&palette.node_stroke, alpha)); + let fill = tinted(&palette.node_fill, alpha); + let text_color = tinted(&palette.text, alpha); + match shape { + Shape::Box { frame, radius, text } => { + let box_rect = rect(*frame, transform); + painter.rect_filled(box_rect, CornerRadius::same(crate::style::radius_u8(crate::style::narrow_f32(*radius) * transform.scale)), fill); + painter.rect_stroke( + box_rect, + CornerRadius::same(crate::style::radius_u8(crate::style::narrow_f32(*radius) * transform.scale)), + stroke, + StrokeKind::Inside, + ); + paint_text(painter, text, transform, text_color); + } + Shape::Diamond { frame, text } => { + let box_rect = rect(*frame, transform); + let points = vec![box_rect.center_top(), box_rect.right_center(), box_rect.center_bottom(), box_rect.left_center()]; + painter.add(egui::Shape::convex_polygon(points, fill, stroke)); + paint_text(painter, text, transform, text_color); + } + Shape::Circle { frame, text } => { + let box_rect = rect(*frame, transform); + let mut points = Vec::with_capacity(32); + for step in 0..32_u16 { + let angle = f32::from(step) * std::f32::consts::TAU / 32.0; + points.push(box_rect.center() + Vec2::new(angle.cos() * box_rect.width() * 0.5, angle.sin() * box_rect.height() * 0.5)); + } + painter.add(egui::Shape::convex_polygon(points, fill, stroke)); + paint_text(painter, text, transform, text_color); + } + Shape::Edge { points, label, style } => { + paint_edge(painter, points, *style, transform, tinted(&palette.edge, alpha), tinted(&palette.background, alpha)); + if let Some(label) = label { + paint_text(painter, label, transform, text_color); + } + } + Shape::Text { text } => paint_text(painter, text, transform, text_color), + Shape::Group { frame, title } => { + let group_rect = rect(*frame, transform); + painter.rect_filled(group_rect, CornerRadius::same(transform.radius), tinted(&palette.group_fill, alpha)); + painter.rect_stroke(group_rect, CornerRadius::same(transform.radius), Stroke::new(1.0, tinted(&palette.group_stroke, alpha)), StrokeKind::Inside); + paint_text(painter, title, transform, text_color); + } + Shape::Marker { at, from, kind } => { + paint_marker(painter, *at, *from, *kind, transform, tinted(&palette.edge, alpha), tinted(&palette.background, alpha)); + } + Shape::Sector { center, radius, start, sweep, label } => { + let screen_center = pos(*center, transform); + let segments = 24_u32; + let mut points = Vec::with_capacity(26); + points.push(screen_center); + for step in 0..=segments { + let amount = f64::from(step) / f64::from(segments); + let angle = *start + *sweep * amount; + points.push(pos(Point::new(center.x + *radius * angle.cos(), center.y + *radius * angle.sin()), transform)); + } + painter.add(egui::Shape::convex_polygon(points, fill, stroke)); + paint_text(painter, label, transform, text_color); + } + } +} + +fn paint_text(painter: &egui::Painter, text: &Text, transform: &Transform, text_color: Color32) { + let align = match text.align { + Align::Middle => egui::Align2::CENTER_CENTER, + Align::Start => egui::Align2::LEFT_CENTER, + }; + painter.text(pos(text.at, transform), align, &text.content, egui::FontId::proportional(14.0 * transform.scale.min(1.5)), text_color); +} + +fn paint_edge(painter: &egui::Painter, points: &[Point], style: EdgeStyle, transform: &Transform, color: Color32, background: Color32) { + let width = if style == EdgeStyle::Thick { 3.0 } else { 1.5 }; + let stroke = Stroke::new(width, color); + for pair in points.windows(2) { + if let (Some(start), Some(end)) = (pair.first(), pair.get(1)) { + let start = pos(*start, transform); + let end = pos(*end, transform); + if style == EdgeStyle::Dotted { + draw_dotted(painter, start, end, stroke); + } else { + painter.line_segment([start, end], stroke); + } + } + } + if matches!(style, EdgeStyle::Solid | EdgeStyle::Dotted | EdgeStyle::Thick) + && let (Some(end), Some(from)) = (points.last(), points.iter().rev().nth(1)) + { + paint_marker(painter, *end, *from, MarkerKind::Arrow, transform, color, background); + } +} + +fn draw_dotted(painter: &egui::Painter, start: Pos2, end: Pos2, stroke: Stroke) { + let delta = end - start; + let length = delta.length(); + if length <= f32::EPSILON { + return; + } + let direction = delta / length; + let mut distance = 0.0; + while distance < length { + let dash_end = (distance + 4.0).min(length); + painter.line_segment([start + direction * distance, start + direction * dash_end], stroke); + distance += 7.0; + } +} + +fn paint_marker(painter: &egui::Painter, at: Point, from: Point, kind: MarkerKind, transform: &Transform, stroke_color: Color32, fill: Color32) { + let tip = pos(at, transform); + let origin = pos(from, transform); + let direction = (tip - origin).normalized(); + let normal = Vec2::new(-direction.y, direction.x); + let length = 10.0_f32.max(8.0 * transform.scale.min(1.0)); + let base = tip - direction * length; + let left = base + normal * 4.0; + let right = base - normal * 4.0; + let stroke = Stroke::new(1.5, stroke_color); + match kind { + MarkerKind::Arrow | MarkerKind::CrowFoot => { + painter.line_segment([left, tip], stroke); + painter.line_segment([tip, right], stroke); + if kind == MarkerKind::CrowFoot { + painter.line_segment([base, base - direction * 4.0 + normal * 4.0], stroke); + painter.line_segment([base, base - direction * 4.0 - normal * 4.0], stroke); + } + } + MarkerKind::Bar => { + painter.line_segment([left, right], stroke); + } + MarkerKind::Circle => { + painter.circle(tip, 4.0, fill, stroke); + } + MarkerKind::Triangle => { + painter.add(egui::Shape::convex_polygon(vec![tip, left, right], fill, stroke)); + } + MarkerKind::DiamondFilled | MarkerKind::DiamondHollow => { + let back = base - direction * length; + let points = vec![tip, base + normal * 4.0, back, base - normal * 4.0]; + let marker_fill = if kind == MarkerKind::DiamondFilled { stroke_color } else { fill }; + painter.add(egui::Shape::convex_polygon(points, marker_fill, stroke)); + } + } +} + +fn tinted(hex: &str, alpha: f32) -> Color32 { + let parsed = u32::from_str_radix(hex.trim_start_matches('#'), 16).unwrap_or(0); + Color32::from_rgba_unmultiplied( + u8::try_from((parsed >> 16) & 0xff).unwrap_or(0), + u8::try_from((parsed >> 8) & 0xff).unwrap_or(0), + u8::try_from(parsed & 0xff).unwrap_or(0), + crate::style::unit_u8(alpha * 255.0), + ) +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9f324a8..b36bea4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,16 +6,13 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod app; -#[cfg(test)] -mod app_tests; pub mod autocomplete; pub mod backlinks_panel; pub mod canvas_view; pub mod center; pub mod commands_ui; +pub mod diagram_view; pub mod editing; -#[cfg(test)] -mod editing_tests; pub mod fonts; pub mod graph_view; pub mod icons; @@ -33,14 +30,12 @@ pub mod state; pub mod statusbar; pub mod style; pub mod tabs; -#[cfg(test)] -pub(crate) mod testsupport; pub mod theme; pub mod tree; #[cfg(test)] -mod tree_tests; +pub(crate) mod testsupport; -// Remaining phase-3 scope: settings panel, graph and canvas renderers (other tasks). +// TODO(phase 3): whiteboard renderer, assistant panel. #[cfg(test)] mod tests {