//! The properties panel: everything about the current selection that is easier //! to change with a widget than by dragging it on the canvas. use std::path::{Path, PathBuf}; use eframe::egui::{self, Color32, ComboBox, DragValue, RichText, TextEdit, Ui}; use crate::document::Document; use crate::model::{BackgroundStyle, Color, EndShape, Node, NodeKind, Side}; use crate::theme::{self, Palette}; use crate::view::CanvasView; #[derive(Default)] pub struct InspectorOutput { pub status: Option, pub open_target: Option, } pub fn show( ui: &mut Ui, doc: &mut Document, view: &mut CanvasView, palette: &Palette, ) -> InspectorOutput { let mut out = InspectorOutput::default(); egui::ScrollArea::vertical().show(ui, |ui| { ui.add_space(4.0); if let Some(id) = view.selection.only_node().map(str::to_owned) { node_section(ui, doc, view, palette, &id, &mut out); } else if let Some(id) = view.selection.only_edge().map(str::to_owned) { edge_section(ui, doc, view, palette, &id, &mut out); } else if !view.selection.is_empty() { multi_section(ui, doc, view, palette, &mut out); } else { canvas_section(ui, doc, palette, &mut out); } }); out } // --------------------------------------------------------------------------- // A single node // --------------------------------------------------------------------------- fn node_section( ui: &mut Ui, doc: &mut Document, view: &mut CanvasView, palette: &Palette, id: &str, out: &mut InspectorOutput, ) { let Some(node) = doc.canvas.node(id).cloned() else { return; }; heading(ui, palette, &format!("{} node", capitalise(node.kind.type_name()))); match &node.kind { NodeKind::Text { text } => { let mut buffer = text.clone(); let response = ui.add( TextEdit::multiline(&mut buffer) .desired_width(f32::INFINITY) .desired_rows(6) .hint_text("Markdown"), ); if response.changed() { doc.begin_change("Edit text"); if let Some(NodeKind::Text { text }) = doc.canvas.node_mut(id).map(|n| &mut n.kind) { *text = buffer; } } if response.lost_focus() { doc.end_change(); } } NodeKind::File { file, subpath } => { let mut path = file.clone(); let mut sub = subpath.clone().unwrap_or_default(); ui.label(field_label(palette, "File")); let changed = ui .add(TextEdit::singleline(&mut path).desired_width(f32::INFINITY)) .changed(); ui.horizontal(|ui| { if ui.button("Browse…").clicked() && let Some(picked) = pick_any_file(doc) { path = picked; doc.change("Set file"); set_file(doc, id, Some(path.clone()), None); } if ui.button("Open").on_hover_text("Open with the system default application").clicked() { out.open_target = Some(file.clone()); } }); ui.add_space(6.0); ui.label(field_label(palette, "Subpath")); let sub_changed = ui .add( TextEdit::singleline(&mut sub) .hint_text("#heading") .desired_width(f32::INFINITY), ) .changed(); if changed || sub_changed { doc.begin_change("Edit file node"); set_file(doc, id, Some(path), Some(sub)); } } NodeKind::Link { url, label } => { let mut url_buffer = url.clone(); ui.label(field_label(palette, "URL")); if ui .add(TextEdit::singleline(&mut url_buffer).desired_width(f32::INFINITY)) .changed() { doc.begin_change("Edit URL"); if let Some(NodeKind::Link { url, .. }) = doc.canvas.node_mut(id).map(|n| &mut n.kind) { *url = url_buffer; } } ui.add_space(6.0); ui.label(field_label(palette, "Display text")); let mut label_buffer = label.clone().unwrap_or_default(); if ui .add( TextEdit::singleline(&mut label_buffer) .desired_width(f32::INFINITY) .hint_text("Shown instead of the URL"), ) .changed() { doc.begin_change("Edit display text"); if let Some(NodeKind::Link { label, .. }) = doc.canvas.node_mut(id).map(|n| &mut n.kind) { // Kept verbatim so a space typed mid-word is not trimmed // away under the cursor; blank labels simply go away. *label = (!label_buffer.is_empty()).then_some(label_buffer); } } ui.add_space(6.0); if ui.button("Open in browser").clicked() { out.open_target = Some(url.clone()); } } NodeKind::Group { label, background, background_style, } => { let mut label_buffer = label.clone().unwrap_or_default(); ui.label(field_label(palette, "Label")); if ui .add( TextEdit::singleline(&mut label_buffer) .desired_width(f32::INFINITY) .hint_text("Unnamed group"), ) .changed() { doc.begin_change("Edit label"); set_group(doc, id, Some(label_buffer), None, None); } ui.add_space(6.0); ui.label(field_label(palette, "Background image")); let mut background_buffer = background.clone().unwrap_or_default(); if ui .add(TextEdit::singleline(&mut background_buffer).desired_width(f32::INFINITY)) .changed() { doc.begin_change("Edit background"); set_group(doc, id, None, Some(background_buffer), None); } ui.horizontal(|ui| { if ui.button("Browse…").clicked() && let Some(picked) = pick_image_file(doc) { doc.change("Set background"); set_group(doc, id, None, Some(picked), None); } if background.is_some() && ui.button("Clear").clicked() { doc.change("Clear background"); set_group(doc, id, None, Some(String::new()), None); } }); ui.add_space(6.0); let mut style = *background_style; ComboBox::from_label("Fitting") .selected_text(style.map_or("default", BackgroundStyle::label)) .show_ui(ui, |ui| { ui.selectable_value(&mut style, None, "default"); for option in BackgroundStyle::ALL { ui.selectable_value(&mut style, Some(option), option.label()); } }); if style != *background_style { doc.change("Set background fitting"); set_group(doc, id, None, None, Some(style)); } let members = doc.canvas.nodes_in_group(id).len(); ui.add_space(4.0); ui.label( RichText::new(format!( "Contains {members} node{}", if members == 1 { "" } else { "s" } )) .size(12.0) .color(palette.text_muted), ); } NodeKind::Unknown { type_name } => { ui.label( RichText::new(format!( "This node has the unsupported type “{type_name}”. Its attributes are kept as they are when saving." )) .size(12.0) .color(palette.text_muted), ); } } ui.add_space(10.0); geometry_section(ui, doc, palette, id, &node); ui.add_space(10.0); ui.label(field_label(palette, "Colour")); if let Some(color) = color_picker(ui, node.color.as_ref(), palette, id) { doc.change("Set colour"); if let Some(node) = doc.canvas.node_mut(id) { node.color = color; } } ui.add_space(10.0); ui.horizontal(|ui| { if ui.button("Bring to front").clicked() { view.raise_selection(doc, true); } if ui.button("Send to back").clicked() { view.raise_selection(doc, false); } }); ui.add_space(4.0); if ui .button(RichText::new("Delete node").color(palette.danger)) .clicked() { out.status = view.delete_selection(doc); } ui.add_space(10.0); ui.label( RichText::new(format!("id: {}", node.id)) .size(11.0) .monospace() .color(palette.text_muted), ); } fn geometry_section(ui: &mut Ui, doc: &mut Document, palette: &Palette, id: &str, node: &Node) { let (mut x, mut y, mut w, mut h) = (node.x, node.y, node.width, node.height); ui.label(field_label(palette, "Position and size")); let mut changed = false; ui.horizontal(|ui| { changed |= ui.add(DragValue::new(&mut x).speed(1).prefix("x ")).changed(); changed |= ui.add(DragValue::new(&mut y).speed(1).prefix("y ")).changed(); }); ui.horizontal(|ui| { changed |= ui .add(DragValue::new(&mut w).speed(1).range(1..=100_000).prefix("w ")) .changed(); changed |= ui .add(DragValue::new(&mut h).speed(1).range(1..=100_000).prefix("h ")) .changed(); }); if changed { doc.begin_change("Edit geometry"); if let Some(node) = doc.canvas.node_mut(id) { node.x = x; node.y = y; node.width = w; node.height = h; } } } // --------------------------------------------------------------------------- // A single edge // --------------------------------------------------------------------------- fn edge_section( ui: &mut Ui, doc: &mut Document, view: &mut CanvasView, palette: &Palette, id: &str, out: &mut InspectorOutput, ) { let Some(edge) = doc.canvas.edge(id).cloned() else { return; }; heading(ui, palette, "Edge"); let name_of = |node_id: &str| { doc.canvas .node(node_id) .map(|n| n.title()) .unwrap_or_else(|| format!("missing node {node_id}")) }; ui.label( RichText::new(format!("{} → {}", name_of(&edge.from_node), name_of(&edge.to_node))) .size(12.0) .color(palette.text_muted), ); ui.add_space(8.0); let mut from_side = edge.from_side; let mut to_side = edge.to_side; let mut from_end = edge.from_end; let mut to_end = edge.to_end; ui.horizontal(|ui| { side_combo(ui, "From", &mut from_side); end_combo(ui, "Start", &mut from_end, EndShape::None); }); ui.horizontal(|ui| { side_combo(ui, "To", &mut to_side); end_combo(ui, "End", &mut to_end, EndShape::Arrow); }); if from_side != edge.from_side || to_side != edge.to_side || from_end != edge.from_end || to_end != edge.to_end { doc.change("Edit edge"); if let Some(edge) = doc.canvas.edge_mut(id) { edge.from_side = from_side; edge.to_side = to_side; edge.from_end = from_end; edge.to_end = to_end; } } ui.add_space(8.0); ui.label(field_label(palette, "Label")); let mut label = edge.label.clone().unwrap_or_default(); if ui .add(TextEdit::singleline(&mut label).desired_width(f32::INFINITY)) .changed() { doc.begin_change("Edit edge label"); if let Some(edge) = doc.canvas.edge_mut(id) { edge.label = (!label.is_empty()).then_some(label); } } ui.add_space(8.0); ui.label(field_label(palette, "Colour")); if let Some(color) = color_picker(ui, edge.color.as_ref(), palette, id) { doc.change("Set colour"); if let Some(edge) = doc.canvas.edge_mut(id) { edge.color = color; } } ui.add_space(10.0); ui.horizontal(|ui| { if ui.button("Reverse direction").clicked() { doc.change("Reverse edge"); if let Some(edge) = doc.canvas.edge_mut(id) { std::mem::swap(&mut edge.from_node, &mut edge.to_node); std::mem::swap(&mut edge.from_side, &mut edge.to_side); std::mem::swap(&mut edge.from_end, &mut edge.to_end); } out.status = Some("Reversed the edge".to_owned()); } if ui .button(RichText::new("Delete edge").color(palette.danger)) .clicked() { out.status = view.delete_selection(doc); } }); } fn side_combo(ui: &mut Ui, label: &str, side: &mut Option) { ComboBox::from_id_salt(label) .selected_text(format!( "{label}: {}", side.map_or("auto", Side::label) )) .width(110.0) .show_ui(ui, |ui| { ui.selectable_value(side, None, "auto"); for option in Side::ALL { ui.selectable_value(side, Some(option), option.label()); } }); } fn end_combo(ui: &mut Ui, label: &str, end: &mut Option, default: EndShape) { let text = match end { None => format!("default ({})", end_name(default)), Some(value) => end_name(*value).to_owned(), }; ComboBox::from_id_salt(label) .selected_text(format!("{label}: {text}")) .width(130.0) .show_ui(ui, |ui| { ui.selectable_value(end, None, format!("default ({})", end_name(default))); ui.selectable_value(end, Some(EndShape::None), "none"); ui.selectable_value(end, Some(EndShape::Arrow), "arrow"); }); } fn end_name(end: EndShape) -> &'static str { match end { EndShape::None => "none", EndShape::Arrow => "arrow", } } // --------------------------------------------------------------------------- // Many things selected // --------------------------------------------------------------------------- fn multi_section( ui: &mut Ui, doc: &mut Document, view: &mut CanvasView, palette: &Palette, out: &mut InspectorOutput, ) { let nodes = view.selection.nodes.len(); let edges = view.selection.edges.len(); heading(ui, palette, "Multiple items"); ui.label( RichText::new(format!( "{nodes} node{} and {edges} edge{} selected", plural(nodes), plural(edges) )) .size(12.0) .color(palette.text_muted), ); ui.add_space(10.0); ui.label(field_label(palette, "Colour")); if let Some(color) = color_picker(ui, None, palette, "multi") { doc.change("Set colour"); for id in view.selection.nodes.clone() { if let Some(node) = doc.canvas.node_mut(&id) { node.color = color.clone(); } } for id in view.selection.edges.clone() { if let Some(edge) = doc.canvas.edge_mut(&id) { edge.color = color.clone(); } } } if nodes >= 2 { ui.add_space(10.0); ui.label(field_label(palette, "Align")); ui.horizontal(|ui| { if ui.button("Left").clicked() { align(doc, view, Align::Left); } if ui.button("Centre").clicked() { align(doc, view, Align::CentreX); } if ui.button("Right").clicked() { align(doc, view, Align::Right); } }); ui.horizontal(|ui| { if ui.button("Top").clicked() { align(doc, view, Align::Top); } if ui.button("Middle").clicked() { align(doc, view, Align::CentreY); } if ui.button("Bottom").clicked() { align(doc, view, Align::Bottom); } }); } ui.add_space(10.0); if ui .button(RichText::new("Delete selection").color(palette.danger)) .clicked() { out.status = view.delete_selection(doc); } } #[derive(Clone, Copy)] enum Align { Left, CentreX, Right, Top, CentreY, Bottom, } fn align(doc: &mut Document, view: &CanvasView, how: Align) { let ids = view.selection.nodes.clone(); let boxes: Vec<(i32, i32, i32, i32)> = ids .iter() .filter_map(|id| doc.canvas.node(id)) .map(|n| (n.x, n.y, n.width, n.height)) .collect(); if boxes.len() < 2 { return; } let left = boxes.iter().map(|b| b.0).min().unwrap_or(0); let right = boxes.iter().map(|b| b.0 + b.2).max().unwrap_or(0); let top = boxes.iter().map(|b| b.1).min().unwrap_or(0); let bottom = boxes.iter().map(|b| b.1 + b.3).max().unwrap_or(0); doc.change("Align"); for id in ids { let Some(node) = doc.canvas.node_mut(&id) else { continue; }; match how { Align::Left => node.x = left, Align::Right => node.x = right - node.width, Align::CentreX => node.x = (left + right) / 2 - node.width / 2, Align::Top => node.y = top, Align::Bottom => node.y = bottom - node.height, Align::CentreY => node.y = (top + bottom) / 2 - node.height / 2, } } } // --------------------------------------------------------------------------- // Nothing selected // --------------------------------------------------------------------------- fn canvas_section(ui: &mut Ui, doc: &mut Document, palette: &Palette, out: &mut InspectorOutput) { heading(ui, palette, "Canvas"); let nodes = doc.canvas.nodes.len(); let edges = doc.canvas.edges.len(); let groups = doc.canvas.nodes.iter().filter(|n| n.is_group()).count(); for (label, value) in [ ("Nodes", nodes.to_string()), ("Groups", groups.to_string()), ("Edges", edges.to_string()), ( "File", doc.path() .map(|p| p.display().to_string()) .unwrap_or_else(|| "not saved yet".to_owned()), ), ] { ui.horizontal(|ui| { ui.label(field_label(palette, label)); ui.label(RichText::new(value).size(12.0)); }); } let dangling = doc .canvas .edges .iter() .filter(|e| { doc.canvas.node(&e.from_node).is_none() || doc.canvas.node(&e.to_node).is_none() }) .count(); if dangling > 0 { ui.add_space(8.0); ui.label( RichText::new(format!( "{dangling} edge{} point at nodes that do not exist.", plural(dangling) )) .size(12.0) .color(palette.danger), ); if ui.button("Remove them").clicked() { doc.change("Remove dangling edges"); let removed = doc.canvas.drop_dangling_edges(); out.status = Some(format!("Removed {removed} dangling edge{}", plural(removed))); } } ui.add_space(12.0); ui.label(field_label(palette, "Tips")); for tip in [ "Double-click the canvas to add a note", "Drag a dot on a node's edge to connect it", "Middle- or right-drag to pan, Ctrl+wheel to zoom", "Hold Shift to add to the selection", ] { ui.label(RichText::new(format!("• {tip}")).size(12.0).color(palette.text_muted)); } } // --------------------------------------------------------------------------- // Shared widgets // --------------------------------------------------------------------------- /// A row of the six preset colours plus a hex field. Returns the new value when /// the user picked one. fn color_picker( ui: &mut Ui, current: Option<&Color>, palette: &Palette, salt: &str, ) -> Option> { let mut picked = None; ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 4.0; for (index, color) in theme::PRESETS.iter().enumerate() { let preset = Color::Preset(index as u8 + 1); let selected = current == Some(&preset); if swatch(ui, *color, selected, palette).clicked() { picked = Some(Some(preset)); } } if swatch(ui, palette.surface, current.is_none(), palette) .on_hover_text("No colour") .clicked() { picked = Some(None); } }); ui.add_space(4.0); ui.horizontal(|ui| { let mut hex = match current { Some(Color::Hex(hex)) => hex.clone(), _ => String::new(), }; let response = ui.add( TextEdit::singleline(&mut hex) .id_salt(format!("hex-{salt}")) .hint_text("#rrggbb") .desired_width(90.0), ); if response.changed() && let Some(color) = Color::parse(&hex) { picked = Some(Some(color)); } if let Some(color) = current { ui.label( RichText::new(color.display_name()) .size(11.0) .color(palette.text_muted), ); } }); picked } fn swatch(ui: &mut Ui, color: Color32, selected: bool, palette: &Palette) -> egui::Response { let size = egui::vec2(20.0, 20.0); let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); let stroke = if selected { egui::Stroke::new(2.0, palette.accent) } else if response.hovered() { egui::Stroke::new(1.5, palette.text_muted) } else { egui::Stroke::new(1.0, palette.border) }; ui.painter().rect( rect, egui::CornerRadius::same(4), color, stroke, egui::StrokeKind::Inside, ); response } fn heading(ui: &mut Ui, palette: &Palette, text: &str) { ui.label(RichText::new(text).size(15.0).strong().color(palette.text)); ui.add_space(6.0); } fn field_label(palette: &Palette, text: &str) -> RichText { RichText::new(text).size(11.0).color(palette.text_muted) } fn plural(count: usize) -> &'static str { if count == 1 { "" } else { "s" } } fn capitalise(word: &str) -> String { let mut chars = word.chars(); match chars.next() { Some(first) => first.to_uppercase().collect::() + chars.as_str(), None => String::new(), } } fn set_file(doc: &mut Document, id: &str, file: Option, subpath: Option) { if let Some(NodeKind::File { file: current, subpath: current_sub, }) = doc.canvas.node_mut(id).map(|n| &mut n.kind) { if let Some(file) = file { *current = file; } if let Some(sub) = subpath { *current_sub = (!sub.is_empty()).then_some(sub); } } } fn set_group( doc: &mut Document, id: &str, label: Option, background: Option, style: Option>, ) { if let Some(NodeKind::Group { label: current_label, background: current_background, background_style, }) = doc.canvas.node_mut(id).map(|n| &mut n.kind) { if let Some(label) = label { *current_label = (!label.is_empty()).then_some(label); } if let Some(background) = background { *current_background = (!background.is_empty()).then_some(background); } if let Some(style) = style { *background_style = style; } } } fn pick_any_file(doc: &Document) -> Option { let mut dialog = rfd::FileDialog::new(); if let Some(dir) = doc.base_dir() { dialog = dialog.set_directory(dir); } dialog .pick_file() .map(|path| relative_to(doc.base_dir().as_deref(), &path)) } fn pick_image_file(doc: &Document) -> Option { let mut dialog = rfd::FileDialog::new().add_filter( "Images", &["png", "jpg", "jpeg", "gif", "bmp", "webp"], ); if let Some(dir) = doc.base_dir() { dialog = dialog.set_directory(dir); } dialog .pick_file() .map(|path| relative_to(doc.base_dir().as_deref(), &path)) } /// Paths are stored relative to the canvas when possible, which is what makes a /// canvas plus its files portable. pub fn relative_to(base: Option<&Path>, path: &Path) -> String { let relative = base .and_then(|base| path.strip_prefix(base).ok()) .map(PathBuf::from); let path = relative.as_deref().unwrap_or(path); path.to_string_lossy().replace('\\', "/") } #[cfg(test)] mod tests { use super::*; use crate::model::NodeKind; use crate::view::CanvasView; fn node(id: &str, x: i32, y: i32, w: i32, h: i32) -> Node { Node::new(id, NodeKind::Text { text: String::new() }, x, y, w, h) } #[test] fn relative_paths_are_used_when_the_file_is_below_the_canvas() { let base = Path::new("/vault"); assert_eq!( relative_to(Some(base), Path::new("/vault/img/a.png")), "img/a.png" ); assert_eq!( relative_to(Some(base), Path::new("/elsewhere/a.png")), "/elsewhere/a.png" ); assert_eq!(relative_to(None, Path::new("/a/b.png")), "/a/b.png"); } #[test] fn aligning_moves_nodes_to_a_shared_edge() { let mut doc = Document::new(); doc.canvas.nodes.push(node("a", 0, 0, 100, 100)); doc.canvas.nodes.push(node("b", 50, 200, 200, 50)); let mut view = CanvasView::new(); view.select_all(&doc.canvas); align(&mut doc, &view, Align::Left); assert_eq!(doc.canvas.node("b").unwrap().x, 0); align(&mut doc, &view, Align::Right); assert_eq!(doc.canvas.node("a").unwrap().x, 100); assert_eq!(doc.canvas.node("b").unwrap().x, 0); align(&mut doc, &view, Align::Top); assert_eq!(doc.canvas.node("b").unwrap().y, 0); } #[test] fn aligning_needs_at_least_two_nodes() { let mut doc = Document::new(); doc.canvas.nodes.push(node("a", 7, 7, 100, 100)); let mut view = CanvasView::new(); view.select_node("a"); align(&mut doc, &view, Align::Left); assert_eq!(doc.canvas.node("a").unwrap().x, 7); assert!(!doc.can_undo(), "a no-op should not create an undo step"); } #[test] fn capitalises_type_names_for_the_heading() { assert_eq!(capitalise("text"), "Text"); assert_eq!(capitalise(""), ""); } }