diff --git a/README.md b/README.md index 6c758a1..e542dcb 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/src/app.rs b/src/app.rs index c4254e6..bb816b9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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); diff --git a/src/view.rs b/src/view.rs index 03f55f2..41c5cf0 100644 --- a/src/view.rs +++ b/src/view.rs @@ -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, /// A short message for the status bar. pub status: Option, + /// 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, + /// 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, /// Screen rectangle the canvas was drawn in last frame, `None` until the /// canvas has been laid out once. viewport: Option, @@ -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) { + 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), + /// `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, + menu: Option, } 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, +) { + 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 { + 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");