Add Context menu.

This commit is contained in:
2026-09-10 11:23:48 +02:00
parent 2422e65b2b
commit 2d7e8aa20f
3 changed files with 328 additions and 21 deletions
+2
View File
@@ -31,6 +31,8 @@ reshuffle.
| Double-click empty canvas | New text note, ready to type in |
| Double-click a note | Edit its Markdown in place |
| Double-click a file/link node | Open it with the system default application |
| Right-click empty canvas | Menu of every node type, added where you clicked |
| Right-click a node | Colour, bring to front / send to back, delete |
| Drag a dot on a node's edge | Draw a connection; drop it on empty canvas to create the node too |
| Drag a corner or side handle | Resize |
| Drag a group | Moves the group and everything inside it |
+29 -18
View File
@@ -4,7 +4,7 @@
use std::path::{Path, PathBuf};
use eframe::egui::{
self, Button, Context, Key, KeyboardShortcut, Modifiers, RichText, Ui, ViewportCommand,
self, Button, Context, Key, KeyboardShortcut, Modifiers, Pos2, RichText, Ui, ViewportCommand,
};
use crate::document::Document;
@@ -12,7 +12,7 @@ use crate::geometry::{bounds_of, node_rect};
use crate::inspector;
use crate::model::{Canvas, Node, NodeKind};
use crate::theme::Palette;
use crate::view::{CanvasView, Clipboard};
use crate::view::{CanvasView, Clipboard, InsertRequest};
/// How long a status message stays on screen.
const STATUS_SECONDS: f64 = 5.0;
@@ -225,8 +225,17 @@ impl App {
// -- editing actions --------------------------------------------------
fn insert_text_node(&mut self, ctx: &Context) {
let at = self.view.centre_in_canvas();
/// Adds the node the canvas context menu asked for, where it asked for it.
fn insert_requested(&mut self, kind: InsertRequest, at: Pos2, ctx: &Context) {
match kind {
InsertRequest::Text => self.insert_text_node(at, ctx),
InsertRequest::File => self.insert_file_node(at, ctx),
InsertRequest::Link => self.insert_link_node(at, ctx),
InsertRequest::Group => self.insert_group(at, ctx),
}
}
fn insert_text_node(&mut self, at: Pos2, ctx: &Context) {
let id = self
.view
.insert_node(&mut self.doc, NodeKind::Text { text: String::new() }, at);
@@ -234,7 +243,7 @@ impl App {
self.set_status("Added a text node", ctx);
}
fn insert_file_node(&mut self, ctx: &Context) {
fn insert_file_node(&mut self, at: Pos2, ctx: &Context) {
let mut dialog = rfd::FileDialog::new();
if let Some(dir) = self.doc.base_dir() {
dialog = dialog.set_directory(dir);
@@ -243,7 +252,6 @@ impl App {
return;
};
let file = inspector::relative_to(self.doc.base_dir().as_deref(), &path);
let at = self.view.centre_in_canvas();
self.view.insert_node(
&mut self.doc,
NodeKind::File {
@@ -261,8 +269,7 @@ impl App {
}
}
fn insert_link_node(&mut self, ctx: &Context) {
let at = self.view.centre_in_canvas();
fn insert_link_node(&mut self, at: Pos2, ctx: &Context) {
self.view.insert_node(
&mut self.doc,
NodeKind::Link {
@@ -278,8 +285,7 @@ impl App {
);
}
fn insert_group(&mut self, ctx: &Context) {
let at = self.view.centre_in_canvas();
fn insert_group(&mut self, at: Pos2, ctx: &Context) {
self.view.insert_node(
&mut self.doc,
NodeKind::Group {
@@ -596,20 +602,21 @@ impl App {
});
ui.menu_button("Insert", |ui| {
let at = self.view.centre_in_canvas();
if ui.button("Text node").clicked() {
self.insert_text_node(ctx);
self.insert_text_node(at, ctx);
ui.close();
}
if ui.button("File node…").clicked() {
self.insert_file_node(ctx);
self.insert_file_node(at, ctx);
ui.close();
}
if ui.button("Link node").clicked() {
self.insert_link_node(ctx);
self.insert_link_node(at, ctx);
ui.close();
}
if ui.button("Group").clicked() {
self.insert_group(ctx);
self.insert_group(at, ctx);
ui.close();
}
ui.separator();
@@ -684,17 +691,18 @@ impl App {
self.redo(ctx);
}
ui.separator();
let at = self.view.centre_in_canvas();
if ui.button("Note").on_hover_text("Add a text node").clicked() {
self.insert_text_node(ctx);
self.insert_text_node(at, ctx);
}
if ui.button("File").on_hover_text("Add a file node").clicked() {
self.insert_file_node(ctx);
self.insert_file_node(at, ctx);
}
if ui.button("Link").on_hover_text("Add a link node").clicked() {
self.insert_link_node(ctx);
self.insert_link_node(at, ctx);
}
if ui.button("Group").on_hover_text("Add a group").clicked() {
self.insert_group(ctx);
self.insert_group(at, ctx);
}
ui.separator();
if ui.button("−").on_hover_text("Zoom out (Ctrl+-)").clicked() {
@@ -918,6 +926,9 @@ impl eframe::App for App {
if let Some(target) = out.open_target {
self.open_target(&target, &ctx);
}
if let Some((kind, at)) = out.insert {
self.insert_requested(kind, at, &ctx);
}
});
self.dialogs(&ctx);
+297 -3
View File
@@ -4,8 +4,9 @@
use std::collections::HashMap;
use eframe::egui::{
self, Align, Color32, CornerRadius, CursorIcon, LayerId, Layout, PointerButton, Pos2, Rect,
RichText, Sense, Shape, Stroke, StrokeKind, TextEdit, UiBuilder, Vec2,
self, Align, Atom, Button, Color32, CornerRadius, CursorIcon, Id, LayerId, Layout,
PointerButton, Pos2, Rect, RichText, Sense, Shape, Stroke, StrokeKind, TextEdit, UiBuilder,
Vec2,
containers::scroll_area::ScrollSource,
epaint, pos2, vec2,
};
@@ -16,7 +17,7 @@ use crate::geometry::{
anchor, auto_sides, bezier_point, bezier_tangent, bounds_of, curve_between, distance_to_curve,
edge_curve, node_rect,
};
use crate::model::{Canvas, Edge, EndShape, Node, NodeKind, Side, MIN_NODE_SIZE};
use crate::model::{Canvas, Color, Edge, EndShape, Node, NodeKind, Side, MIN_NODE_SIZE};
use crate::nodes::{self, BodyContext, PreviewCache};
use crate::theme::{self, Palette};
@@ -219,6 +220,17 @@ enum Gesture {
Connect { from: String, side: Side },
}
/// A node the user asked for from the canvas context menu. Insertion goes
/// through the application because it is the shell that owns the file dialog a
/// file node needs and the inspector a fresh link node sends the user to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InsertRequest {
Text,
File,
Link,
Group,
}
/// What the view wants the application to do after a frame.
#[derive(Default)]
pub struct ViewOutput {
@@ -226,6 +238,8 @@ pub struct ViewOutput {
pub open_target: Option<String>,
/// A short message for the status bar.
pub status: Option<String>,
/// A node to add, and where in the canvas it goes.
pub insert: Option<(InsertRequest, Pos2)>,
}
// ---------------------------------------------------------------------------
@@ -249,6 +263,10 @@ pub struct CanvasView {
pub previews: PreviewCache,
/// How far each node's contents are scrolled, and how far they can go.
body_scroll: HashMap<String, BodyScroll>,
/// Where the open context menu was summoned, in canvas coordinates. The
/// pointer wanders off while the menu is up, so a node added from it has to
/// land where the menu was opened and not where the click on the entry was.
menu_at: Option<Pos2>,
/// Screen rectangle the canvas was drawn in last frame, `None` until the
/// canvas has been laid out once.
viewport: Option<Rect>,
@@ -295,6 +313,7 @@ impl CanvasView {
snap_to_grid: false,
previews: PreviewCache::default(),
body_scroll: HashMap::new(),
menu_at: None,
viewport: None,
pending_fit: None,
}
@@ -515,6 +534,19 @@ impl CanvasView {
}
}
/// Gives every selected node the same `canvasColor`, or clears it.
pub fn set_selection_color(&mut self, doc: &mut Document, color: Option<Color>) {
if self.selection.nodes.is_empty() {
return;
}
doc.change("Set colour");
for id in &self.selection.nodes {
if let Some(node) = doc.canvas.node_mut(id) {
node.color = color.clone();
}
}
}
/// Reorders the selected nodes within the file, which is the canvas z-order.
pub fn raise_selection(&mut self, doc: &mut Document, to_front: bool) {
if self.selection.nodes.is_empty() {
@@ -645,6 +677,12 @@ impl CanvasView {
handle_used,
&mut output,
);
// Nodes are drawn over the background and so take the right click
// themselves; what reaches the background is bare canvas.
let at = self.menu_at.unwrap_or_else(|| self.centre_in_canvas());
background.context_menu(|ui| background_menu(ui, at, &mut output));
// Last: which node the wheel belongs to is only known once they have
// all been drawn.
self.scroll_hovered_body(&scene);
@@ -886,6 +924,21 @@ impl CanvasView {
if response.contains_pointer() {
interactions.hovered = Some(id.clone());
}
// A right click on a node outside the selection takes it over; one
// on a node already in it leaves the selection alone, so the menu
// acts on all of them at once.
if response.secondary_clicked() && !self.selection.has_node(&id) {
self.selection.set_node(&id);
}
let color = doc.canvas.nodes[index].color.clone();
let selected = self.selection.nodes.len();
// The menu only records what was asked for. Deleting or reordering
// nodes here would pull the canvas out from under this very loop,
// so `handle_interactions` carries the action out afterwards.
response.context_menu(|ui| {
node_menu(ui, palette, color.as_ref(), selected, &mut interactions.menu);
});
}
interactions
}
@@ -1402,6 +1455,19 @@ impl CanvasView {
self.edit_node(&id);
output.status = Some("Added a text node".to_owned());
}
// -- the context menu
if background.secondary_clicked()
&& let Some(at) = pointer
{
self.menu_at = Some(at);
}
match interactions.menu {
Some(NodeAction::SetColor(color)) => self.set_selection_color(doc, color),
Some(NodeAction::Raise(to_front)) => self.raise_selection(doc, to_front),
Some(NodeAction::Delete) => output.status = self.delete_selection(doc),
None => {}
}
}
/// Opens a file/link node, or starts editing a text node.
@@ -1540,6 +1606,16 @@ enum Pass {
Nodes,
}
/// What a node's context menu asked for. It is applied once every node has
/// been drawn, because all three commands rearrange the node list.
#[derive(Debug, Clone, PartialEq)]
enum NodeAction {
SetColor(Option<Color>),
/// `true` brings the selection to the front, `false` sends it to the back.
Raise(bool),
Delete,
}
/// What happened to nodes during one frame.
#[derive(Default)]
struct NodeInteractions {
@@ -1549,6 +1625,7 @@ struct NodeInteractions {
dragging: bool,
drag_stopped: bool,
hovered: Option<String>,
menu: Option<NodeAction>,
}
impl NodeInteractions {
@@ -1560,9 +1637,104 @@ impl NodeInteractions {
self.dragging |= other.dragging;
self.drag_stopped |= other.drag_stopped;
self.hovered = other.hovered.or(self.hovered.take());
self.menu = other.menu.or(self.menu.take());
}
}
// ---------------------------------------------------------------------------
// Context menus
// ---------------------------------------------------------------------------
/// The menu of a right click on bare canvas: everything that can be added,
/// landing at `at` rather than in the middle of the view.
fn background_menu(ui: &mut egui::Ui, at: Pos2, output: &mut ViewOutput) {
let mut insert = |ui: &mut egui::Ui, label: &str, kind: InsertRequest| {
if ui.button(label).clicked() {
output.insert = Some((kind, at));
ui.close();
}
};
insert(ui, "Text", InsertRequest::Text);
insert(ui, "File…", InsertRequest::File);
insert(ui, "Link", InsertRequest::Link);
ui.separator();
insert(ui, "Group", InsertRequest::Group);
}
/// The menu of a right click on a node. `current` is the colour of the node
/// that was clicked and `selected` how many nodes the commands will act on.
fn node_menu(
ui: &mut egui::Ui,
palette: &Palette,
current: Option<&Color>,
selected: usize,
action: &mut Option<NodeAction>,
) {
ui.menu_button("Colour", |ui| {
for (index, name) in Color::PRESET_NAMES.iter().enumerate() {
let preset = Color::Preset(index as u8 + 1);
let chosen = current == Some(&preset);
if color_item(ui, palette, theme::PRESETS[index], name, chosen) {
*action = Some(NodeAction::SetColor(Some(preset)));
ui.close();
}
}
ui.separator();
if color_item(ui, palette, palette.surface, "none", current.is_none()) {
*action = Some(NodeAction::SetColor(None));
ui.close();
}
});
ui.separator();
if ui.button("Bring to front").clicked() {
*action = Some(NodeAction::Raise(true));
ui.close();
}
if ui.button("Send to back").clicked() {
*action = Some(NodeAction::Raise(false));
ui.close();
}
ui.separator();
let label = match selected {
0 | 1 => "Delete".to_owned(),
n => format!("Delete {n} nodes"),
};
if ui
.button(RichText::new(label).color(palette.danger))
.clicked()
{
*action = Some(NodeAction::Delete);
ui.close();
}
}
/// One row of the colour submenu: a swatch of the colour, its name, and a ring
/// around the swatch the node already carries.
fn color_item(
ui: &mut egui::Ui,
palette: &Palette,
color: Color32,
name: &str,
chosen: bool,
) -> bool {
let slot = Id::new("swatch");
let shown = Button::new((Atom::custom(slot, Vec2::splat(14.0)), name)).atom_ui(ui);
if let Some(rect) = shown.rect(slot) {
let painter = ui.painter();
let radius = CornerRadius::same(3);
painter.rect_filled(rect.shrink(if chosen { 3.0 } else { 0.0 }), radius, color);
let stroke = if chosen {
Stroke::new(1.5, palette.accent)
} else {
Stroke::new(1.0, palette.border)
};
painter.rect_stroke(rect, radius, stroke, StrokeKind::Inside);
}
shown.response.clicked()
}
// ---------------------------------------------------------------------------
// Free functions
// ---------------------------------------------------------------------------
@@ -1848,6 +2020,26 @@ mod tests {
}
}
/// The three passes a right click takes: egui hit-tests against the
/// rectangles of the previous pass, and a click is a press and a release.
fn right_click(
ctx: &egui::Context,
view: &mut CanvasView,
doc: &mut Document,
screen: Rect,
at: Pos2,
) {
let button = |pressed| egui::Event::PointerButton {
pos: at,
button: PointerButton::Secondary,
pressed,
modifiers: egui::Modifiers::default(),
};
pass(ctx, view, doc, screen, vec![egui::Event::PointerMoved(at)]);
pass(ctx, view, doc, screen, vec![button(true)]);
pass(ctx, view, doc, screen, vec![button(false)]);
}
#[test]
fn framing_waits_for_the_real_viewport() {
let ctx = egui::Context::default();
@@ -2149,6 +2341,108 @@ mod tests {
assert!(doc.canvas.nodes[0].is_group());
}
#[test]
fn a_right_click_on_a_node_selects_it() {
let ctx = egui::Context::default();
let mut doc = Document::new();
doc.canvas = canvas_with_two_nodes();
let mut view = CanvasView::new();
let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0));
// Node "a" covers the canvas square (0,0)–(100,100).
right_click(&ctx, &mut view, &mut doc, screen, pos2(50.0, 50.0));
assert_eq!(view.selection.nodes, vec!["a".to_owned()]);
pass(&ctx, &mut view, &mut doc, screen, vec![]);
assert!(open_menu_rect(&ctx).is_some(), "the node menu never opened");
// A right click on a node that is already selected leaves the rest of
// the selection alone, so the menu can act on all of it.
view.selection.nodes = vec!["a".to_owned(), "b".to_owned()];
right_click(&ctx, &mut view, &mut doc, screen, pos2(50.0, 50.0));
assert_eq!(view.selection.nodes.len(), 2);
}
/// The rectangle of the open menu, in screen coordinates. A menu is an
/// `Area` of its own, so it turns up as a foreground layer.
fn open_menu_rect(ctx: &egui::Context) -> Option<Rect> {
ctx.memory(|m| {
m.areas()
.visible_layer_ids()
.into_iter()
.filter(|layer| layer.order == egui::Order::Foreground)
.find_map(|layer| m.area_rect(layer.id))
})
}
/// The canvas is drawn in a layer carrying the pan/zoom transform. A menu
/// opened from it must not be dragged along by that transform: it belongs
/// at the pointer, at its usual size, whatever the view is zoomed to.
#[test]
fn the_menu_opens_at_the_pointer_and_not_in_canvas_space() {
let ctx = egui::Context::default();
let mut doc = Document::new();
doc.canvas = canvas_with_two_nodes();
let mut view = CanvasView::new();
view.to_global = TSTransform::new(vec2(120.0, 60.0), 2.0);
let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0));
let at = pos2(700.0, 500.0);
pass(&ctx, &mut view, &mut doc, screen, vec![]);
assert_eq!(open_menu_rect(&ctx), None, "a menu was up before the click");
right_click(&ctx, &mut view, &mut doc, screen, at);
// One more pass: the menu is laid out the frame after it opens.
pass(&ctx, &mut view, &mut doc, screen, vec![]);
let rect = open_menu_rect(&ctx).expect("the context menu never opened");
assert!(
(rect.min - at).length() < 40.0,
"the menu opened at {:?} instead of near {at:?}",
rect.min
);
// Zoomed to 2x, a menu caught by the transform would be twice as wide.
assert!(
rect.width() < 300.0 && rect.height() < 300.0,
"the menu was scaled with the canvas: {rect:?}"
);
}
#[test]
fn a_right_click_on_the_background_is_remembered_in_canvas_units() {
let ctx = egui::Context::default();
let mut doc = Document::new();
doc.canvas = canvas_with_two_nodes();
let mut view = CanvasView::new();
view.to_global = TSTransform::from_translation(vec2(100.0, 50.0));
let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0));
right_click(&ctx, &mut view, &mut doc, screen, pos2(600.0, 450.0));
// A node added from the menu has to land under the click, not under
// the pointer wherever it ended up in the menu.
assert_eq!(view.menu_at, Some(pos2(500.0, 400.0)));
}
#[test]
fn the_colour_command_covers_the_whole_selection() {
let mut doc = Document::new();
doc.canvas = canvas_with_two_nodes();
let mut view = CanvasView::new();
view.select_all(&doc.canvas);
view.set_selection_color(&mut doc, Some(Color::Preset(4)));
assert!(
doc.canvas.nodes.iter().all(|n| n.color == Some(Color::Preset(4))),
"some nodes kept their old colour"
);
view.set_selection_color(&mut doc, None);
assert!(doc.canvas.nodes.iter().all(|n| n.color.is_none()));
// Both steps are undoable on their own.
doc.undo();
assert_eq!(doc.canvas.nodes[0].color, Some(Color::Preset(4)));
}
#[test]
fn counts_read_naturally() {
assert_eq!(count_phrase(&[(1, "node"), (0, "edge")]), "1 node");