Add Context menu.
This commit is contained in:
@@ -31,6 +31,8 @@ reshuffle.
|
|||||||
| Double-click empty canvas | New text note, ready to type in |
|
| Double-click empty canvas | New text note, ready to type in |
|
||||||
| Double-click a note | Edit its Markdown in place |
|
| Double-click a note | Edit its Markdown in place |
|
||||||
| Double-click a file/link node | Open it with the system default application |
|
| 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 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 corner or side handle | Resize |
|
||||||
| Drag a group | Moves the group and everything inside it |
|
| Drag a group | Moves the group and everything inside it |
|
||||||
|
|||||||
+29
-18
@@ -4,7 +4,7 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use eframe::egui::{
|
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;
|
use crate::document::Document;
|
||||||
@@ -12,7 +12,7 @@ use crate::geometry::{bounds_of, node_rect};
|
|||||||
use crate::inspector;
|
use crate::inspector;
|
||||||
use crate::model::{Canvas, Node, NodeKind};
|
use crate::model::{Canvas, Node, NodeKind};
|
||||||
use crate::theme::Palette;
|
use crate::theme::Palette;
|
||||||
use crate::view::{CanvasView, Clipboard};
|
use crate::view::{CanvasView, Clipboard, InsertRequest};
|
||||||
|
|
||||||
/// How long a status message stays on screen.
|
/// How long a status message stays on screen.
|
||||||
const STATUS_SECONDS: f64 = 5.0;
|
const STATUS_SECONDS: f64 = 5.0;
|
||||||
@@ -225,8 +225,17 @@ impl App {
|
|||||||
|
|
||||||
// -- editing actions --------------------------------------------------
|
// -- editing actions --------------------------------------------------
|
||||||
|
|
||||||
fn insert_text_node(&mut self, ctx: &Context) {
|
/// Adds the node the canvas context menu asked for, where it asked for it.
|
||||||
let at = self.view.centre_in_canvas();
|
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
|
let id = self
|
||||||
.view
|
.view
|
||||||
.insert_node(&mut self.doc, NodeKind::Text { text: String::new() }, at);
|
.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);
|
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();
|
let mut dialog = rfd::FileDialog::new();
|
||||||
if let Some(dir) = self.doc.base_dir() {
|
if let Some(dir) = self.doc.base_dir() {
|
||||||
dialog = dialog.set_directory(dir);
|
dialog = dialog.set_directory(dir);
|
||||||
@@ -243,7 +252,6 @@ impl App {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let file = inspector::relative_to(self.doc.base_dir().as_deref(), &path);
|
let file = inspector::relative_to(self.doc.base_dir().as_deref(), &path);
|
||||||
let at = self.view.centre_in_canvas();
|
|
||||||
self.view.insert_node(
|
self.view.insert_node(
|
||||||
&mut self.doc,
|
&mut self.doc,
|
||||||
NodeKind::File {
|
NodeKind::File {
|
||||||
@@ -261,8 +269,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn insert_link_node(&mut self, ctx: &Context) {
|
fn insert_link_node(&mut self, at: Pos2, ctx: &Context) {
|
||||||
let at = self.view.centre_in_canvas();
|
|
||||||
self.view.insert_node(
|
self.view.insert_node(
|
||||||
&mut self.doc,
|
&mut self.doc,
|
||||||
NodeKind::Link {
|
NodeKind::Link {
|
||||||
@@ -278,8 +285,7 @@ impl App {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn insert_group(&mut self, ctx: &Context) {
|
fn insert_group(&mut self, at: Pos2, ctx: &Context) {
|
||||||
let at = self.view.centre_in_canvas();
|
|
||||||
self.view.insert_node(
|
self.view.insert_node(
|
||||||
&mut self.doc,
|
&mut self.doc,
|
||||||
NodeKind::Group {
|
NodeKind::Group {
|
||||||
@@ -596,20 +602,21 @@ impl App {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ui.menu_button("Insert", |ui| {
|
ui.menu_button("Insert", |ui| {
|
||||||
|
let at = self.view.centre_in_canvas();
|
||||||
if ui.button("Text node").clicked() {
|
if ui.button("Text node").clicked() {
|
||||||
self.insert_text_node(ctx);
|
self.insert_text_node(at, ctx);
|
||||||
ui.close();
|
ui.close();
|
||||||
}
|
}
|
||||||
if ui.button("File node…").clicked() {
|
if ui.button("File node…").clicked() {
|
||||||
self.insert_file_node(ctx);
|
self.insert_file_node(at, ctx);
|
||||||
ui.close();
|
ui.close();
|
||||||
}
|
}
|
||||||
if ui.button("Link node").clicked() {
|
if ui.button("Link node").clicked() {
|
||||||
self.insert_link_node(ctx);
|
self.insert_link_node(at, ctx);
|
||||||
ui.close();
|
ui.close();
|
||||||
}
|
}
|
||||||
if ui.button("Group").clicked() {
|
if ui.button("Group").clicked() {
|
||||||
self.insert_group(ctx);
|
self.insert_group(at, ctx);
|
||||||
ui.close();
|
ui.close();
|
||||||
}
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
@@ -684,17 +691,18 @@ impl App {
|
|||||||
self.redo(ctx);
|
self.redo(ctx);
|
||||||
}
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
let at = self.view.centre_in_canvas();
|
||||||
if ui.button("Note").on_hover_text("Add a text node").clicked() {
|
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() {
|
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() {
|
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() {
|
if ui.button("Group").on_hover_text("Add a group").clicked() {
|
||||||
self.insert_group(ctx);
|
self.insert_group(at, ctx);
|
||||||
}
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
if ui.button("−").on_hover_text("Zoom out (Ctrl+-)").clicked() {
|
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 {
|
if let Some(target) = out.open_target {
|
||||||
self.open_target(&target, &ctx);
|
self.open_target(&target, &ctx);
|
||||||
}
|
}
|
||||||
|
if let Some((kind, at)) = out.insert {
|
||||||
|
self.insert_requested(kind, at, &ctx);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
self.dialogs(&ctx);
|
self.dialogs(&ctx);
|
||||||
|
|||||||
+297
-3
@@ -4,8 +4,9 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use eframe::egui::{
|
use eframe::egui::{
|
||||||
self, Align, Color32, CornerRadius, CursorIcon, LayerId, Layout, PointerButton, Pos2, Rect,
|
self, Align, Atom, Button, Color32, CornerRadius, CursorIcon, Id, LayerId, Layout,
|
||||||
RichText, Sense, Shape, Stroke, StrokeKind, TextEdit, UiBuilder, Vec2,
|
PointerButton, Pos2, Rect, RichText, Sense, Shape, Stroke, StrokeKind, TextEdit, UiBuilder,
|
||||||
|
Vec2,
|
||||||
containers::scroll_area::ScrollSource,
|
containers::scroll_area::ScrollSource,
|
||||||
epaint, pos2, vec2,
|
epaint, pos2, vec2,
|
||||||
};
|
};
|
||||||
@@ -16,7 +17,7 @@ use crate::geometry::{
|
|||||||
anchor, auto_sides, bezier_point, bezier_tangent, bounds_of, curve_between, distance_to_curve,
|
anchor, auto_sides, bezier_point, bezier_tangent, bounds_of, curve_between, distance_to_curve,
|
||||||
edge_curve, node_rect,
|
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::nodes::{self, BodyContext, PreviewCache};
|
||||||
use crate::theme::{self, Palette};
|
use crate::theme::{self, Palette};
|
||||||
|
|
||||||
@@ -219,6 +220,17 @@ enum Gesture {
|
|||||||
Connect { from: String, side: Side },
|
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.
|
/// What the view wants the application to do after a frame.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct ViewOutput {
|
pub struct ViewOutput {
|
||||||
@@ -226,6 +238,8 @@ pub struct ViewOutput {
|
|||||||
pub open_target: Option<String>,
|
pub open_target: Option<String>,
|
||||||
/// A short message for the status bar.
|
/// A short message for the status bar.
|
||||||
pub status: Option<String>,
|
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,
|
pub previews: PreviewCache,
|
||||||
/// How far each node's contents are scrolled, and how far they can go.
|
/// How far each node's contents are scrolled, and how far they can go.
|
||||||
body_scroll: HashMap<String, BodyScroll>,
|
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
|
/// Screen rectangle the canvas was drawn in last frame, `None` until the
|
||||||
/// canvas has been laid out once.
|
/// canvas has been laid out once.
|
||||||
viewport: Option<Rect>,
|
viewport: Option<Rect>,
|
||||||
@@ -295,6 +313,7 @@ impl CanvasView {
|
|||||||
snap_to_grid: false,
|
snap_to_grid: false,
|
||||||
previews: PreviewCache::default(),
|
previews: PreviewCache::default(),
|
||||||
body_scroll: HashMap::new(),
|
body_scroll: HashMap::new(),
|
||||||
|
menu_at: None,
|
||||||
viewport: None,
|
viewport: None,
|
||||||
pending_fit: 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.
|
/// 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) {
|
pub fn raise_selection(&mut self, doc: &mut Document, to_front: bool) {
|
||||||
if self.selection.nodes.is_empty() {
|
if self.selection.nodes.is_empty() {
|
||||||
@@ -645,6 +677,12 @@ impl CanvasView {
|
|||||||
handle_used,
|
handle_used,
|
||||||
&mut output,
|
&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
|
// Last: which node the wheel belongs to is only known once they have
|
||||||
// all been drawn.
|
// all been drawn.
|
||||||
self.scroll_hovered_body(&scene);
|
self.scroll_hovered_body(&scene);
|
||||||
@@ -886,6 +924,21 @@ impl CanvasView {
|
|||||||
if response.contains_pointer() {
|
if response.contains_pointer() {
|
||||||
interactions.hovered = Some(id.clone());
|
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
|
interactions
|
||||||
}
|
}
|
||||||
@@ -1402,6 +1455,19 @@ impl CanvasView {
|
|||||||
self.edit_node(&id);
|
self.edit_node(&id);
|
||||||
output.status = Some("Added a text node".to_owned());
|
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.
|
/// Opens a file/link node, or starts editing a text node.
|
||||||
@@ -1540,6 +1606,16 @@ enum Pass {
|
|||||||
Nodes,
|
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.
|
/// What happened to nodes during one frame.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct NodeInteractions {
|
struct NodeInteractions {
|
||||||
@@ -1549,6 +1625,7 @@ struct NodeInteractions {
|
|||||||
dragging: bool,
|
dragging: bool,
|
||||||
drag_stopped: bool,
|
drag_stopped: bool,
|
||||||
hovered: Option<String>,
|
hovered: Option<String>,
|
||||||
|
menu: Option<NodeAction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NodeInteractions {
|
impl NodeInteractions {
|
||||||
@@ -1560,9 +1637,104 @@ impl NodeInteractions {
|
|||||||
self.dragging |= other.dragging;
|
self.dragging |= other.dragging;
|
||||||
self.drag_stopped |= other.drag_stopped;
|
self.drag_stopped |= other.drag_stopped;
|
||||||
self.hovered = other.hovered.or(self.hovered.take());
|
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
|
// 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]
|
#[test]
|
||||||
fn framing_waits_for_the_real_viewport() {
|
fn framing_waits_for_the_real_viewport() {
|
||||||
let ctx = egui::Context::default();
|
let ctx = egui::Context::default();
|
||||||
@@ -2149,6 +2341,108 @@ mod tests {
|
|||||||
assert!(doc.canvas.nodes[0].is_group());
|
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]
|
#[test]
|
||||||
fn counts_read_naturally() {
|
fn counts_read_naturally() {
|
||||||
assert_eq!(count_phrase(&[(1, "node"), (0, "edge")]), "1 node");
|
assert_eq!(count_phrase(&[(1, "node"), (0, "edge")]), "1 node");
|
||||||
|
|||||||
Reference in New Issue
Block a user