843 lines
27 KiB
Rust
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(""), "");
|
|
}
|
|
}
|