Merge branch 'feat/diagram-view' into develop

# Conflicts:
#	crates/ui-egui/src/lib.rs
This commit is contained in:
Marcos Paulo
2026-10-14 00:39:19 -03:00
3 changed files with 543 additions and 8 deletions
+321
View File
@@ -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<u64>,
diagram: Option<Diagram>,
layout: Option<Layout>,
error: Option<String>,
/// 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<String>,
}
/// 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<usize>, 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<String>) {
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::<Vec<_>>(), ["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);
}
}
+219
View File
@@ -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),
)
}
+3 -8
View File
@@ -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 {