Version 1
This commit is contained in:
@@ -0,0 +1,817 @@
|
||||
//! 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 } => {
|
||||
let mut buffer = url.clone();
|
||||
ui.label(field_label(palette, "URL"));
|
||||
if ui
|
||||
.add(TextEdit::singleline(&mut 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 = buffer;
|
||||
}
|
||||
}
|
||||
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(""), "");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user