Files
jsoncanvas-desktop/src/inspector.rs
T
2026-09-09 21:47:34 +02:00

843 lines
27 KiB
Rust

//! 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<String>,
pub open_target: Option<String>,
}
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<Side>) {
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<EndShape>, 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<Option<Color>> {
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::<String>() + chars.as_str(),
None => String::new(),
}
}
fn set_file(doc: &mut Document, id: &str, file: Option<String>, subpath: Option<String>) {
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<String>,
background: Option<String>,
style: Option<Option<BackgroundStyle>>,
) {
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<String> {
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<String> {
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(""), "");
}
}