Merge branch 'feat/diagram-view' into develop
# Conflicts: # crates/ui-egui/src/lib.rs
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user