Allow pasing text directly
This commit is contained in:
@@ -38,6 +38,7 @@ reshuffle.
|
|||||||
| Drag a group | Moves the group and everything inside it |
|
| Drag a group | Moves the group and everything inside it |
|
||||||
| Drag on empty canvas | Rubber-band selection (hold <kbd>Shift</kbd> to add) |
|
| Drag on empty canvas | Rubber-band selection (hold <kbd>Shift</kbd> to add) |
|
||||||
| Middle- or right-drag | Pan |
|
| Middle- or right-drag | Pan |
|
||||||
|
| Drop files on the window | A file node each; a `.canvas` file opens instead |
|
||||||
| Wheel over a node | Scroll contents that are taller than the node |
|
| Wheel over a node | Scroll contents that are taller than the node |
|
||||||
| <kbd>Ctrl</kbd>+wheel | Zoom around the pointer |
|
| <kbd>Ctrl</kbd>+wheel | Zoom around the pointer |
|
||||||
|
|
||||||
@@ -57,6 +58,16 @@ stored as a `label` attribute — the name groups and edges already use — so a
|
|||||||
tool that does not know about it carries it through untouched. This is an
|
tool that does not know about it carries it through untouched. This is an
|
||||||
addition to the format; see [`spec/extensions.md`](spec/extensions.md).
|
addition to the format; see [`spec/extensions.md`](spec/extensions.md).
|
||||||
|
|
||||||
|
**Dropping and pasting things in**: drop files on the window and each becomes a
|
||||||
|
file node, with its path stored relative to the canvas when it lives beside it.
|
||||||
|
Paste text and it becomes a note, or a link node when the text is a URL. A copy
|
||||||
|
made in the editor goes onto the system clipboard as JSON Canvas, so it pastes
|
||||||
|
back into another window as nodes rather than as text.
|
||||||
|
|
||||||
|
Dropping *text* on the window does nothing, and no drop lands under the pointer:
|
||||||
|
the windowing layer this editor is built on accepts file drags only, and discards
|
||||||
|
the position that comes with them. Paste is the way in for text.
|
||||||
|
|
||||||
**The usual editor comforts**: undo/redo of every change, copy/paste/duplicate,
|
**The usual editor comforts**: undo/redo of every change, copy/paste/duplicate,
|
||||||
grouping, alignment, z-order, a properties inspector, snap-to-grid, light and
|
grouping, alignment, z-order, a properties inspector, snap-to-grid, light and
|
||||||
dark themes, and a warning before you lose unsaved work.
|
dark themes, and a warning before you lose unsaved work.
|
||||||
|
|||||||
+98
-13
@@ -285,6 +285,22 @@ impl App {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adds the node a piece of loose text should become: a link node when the
|
||||||
|
/// text is a URL, a note otherwise.
|
||||||
|
fn insert_node_for_text(&mut self, text: &str, at: Pos2, ctx: &Context) {
|
||||||
|
let kind = NodeKind::from_text(text);
|
||||||
|
let link = matches!(kind, NodeKind::Link { .. });
|
||||||
|
self.view.insert_node(&mut self.doc, kind, at);
|
||||||
|
self.set_status(
|
||||||
|
if link {
|
||||||
|
"Added a link node"
|
||||||
|
} else {
|
||||||
|
"Added a text node"
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn insert_group(&mut self, at: Pos2, ctx: &Context) {
|
fn insert_group(&mut self, at: Pos2, ctx: &Context) {
|
||||||
self.view.insert_node(
|
self.view.insert_node(
|
||||||
&mut self.doc,
|
&mut self.doc,
|
||||||
@@ -345,6 +361,9 @@ impl App {
|
|||||||
match self.view.copy_selection(&self.doc.canvas) {
|
match self.view.copy_selection(&self.doc.canvas) {
|
||||||
Some(clipboard) => {
|
Some(clipboard) => {
|
||||||
let count = clipboard.nodes.len();
|
let count = clipboard.nodes.len();
|
||||||
|
// Also on the system clipboard, so the copy can be pasted into
|
||||||
|
// another window; `paste` recognises it again by its JSON.
|
||||||
|
ctx.copy_text(clipboard.to_json());
|
||||||
self.clipboard = clipboard;
|
self.clipboard = clipboard;
|
||||||
self.set_status(format!("Copied {count} node(s)"), ctx);
|
self.set_status(format!("Copied {count} node(s)"), ctx);
|
||||||
}
|
}
|
||||||
@@ -352,14 +371,36 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn paste(&mut self, ctx: &Context) {
|
/// Pastes `text` from the system clipboard, falling back to the last copy
|
||||||
|
/// made in this window when there is none (the Edit menu has no way to read
|
||||||
|
/// the system clipboard).
|
||||||
|
///
|
||||||
|
/// Text that is a canvas pastes as nodes; any other text becomes a node of
|
||||||
|
/// its own — a link node for a URL, a note for anything else.
|
||||||
|
fn paste(&mut self, text: Option<&str>, ctx: &Context) {
|
||||||
|
if let Some(text) = text {
|
||||||
|
if let Some(clipboard) = Clipboard::from_json(text) {
|
||||||
|
self.paste_clipboard(&clipboard, ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !text.trim().is_empty() {
|
||||||
|
let at = self.view.centre_in_canvas();
|
||||||
|
self.insert_node_for_text(text, at, ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if self.clipboard.is_empty() {
|
if self.clipboard.is_empty() {
|
||||||
|
self.set_status("Nothing to paste", ctx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let clipboard = self.clipboard.clone();
|
let clipboard = self.clipboard.clone();
|
||||||
|
self.paste_clipboard(&clipboard, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paste_clipboard(&mut self, clipboard: &Clipboard, ctx: &Context) {
|
||||||
let message = self
|
let message = self
|
||||||
.view
|
.view
|
||||||
.paste(&mut self.doc, &clipboard, egui::vec2(24.0, 24.0));
|
.paste(&mut self.doc, clipboard, egui::vec2(24.0, 24.0));
|
||||||
self.set_status(message, ctx);
|
self.set_status(message, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,12 +452,6 @@ impl App {
|
|||||||
if consume(ctx, CTRL, K::A) {
|
if consume(ctx, CTRL, K::A) {
|
||||||
self.view.select_all(&self.doc.canvas);
|
self.view.select_all(&self.doc.canvas);
|
||||||
}
|
}
|
||||||
if consume(ctx, CTRL, K::C) {
|
|
||||||
self.copy_selection(ctx);
|
|
||||||
}
|
|
||||||
if consume(ctx, CTRL, K::V) {
|
|
||||||
self.paste(ctx);
|
|
||||||
}
|
|
||||||
if consume(ctx, CTRL, K::D) {
|
if consume(ctx, CTRL, K::D) {
|
||||||
self.duplicate_selection(ctx);
|
self.duplicate_selection(ctx);
|
||||||
}
|
}
|
||||||
@@ -446,6 +481,21 @@ impl App {
|
|||||||
if ctx.egui_wants_keyboard_input() {
|
if ctx.egui_wants_keyboard_input() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+C and Ctrl+V never arrive as key presses: the windowing layer
|
||||||
|
// turns them into clipboard events before egui sees the key, and a
|
||||||
|
// focused text field (excluded just above) needs those events itself.
|
||||||
|
// Taking them out of the queue keeps anything drawn later from acting
|
||||||
|
// on the same paste a second time.
|
||||||
|
if take_event(ctx, |e| matches!(e, egui::Event::Copy).then_some(())).is_some() {
|
||||||
|
self.copy_selection(ctx);
|
||||||
|
}
|
||||||
|
if let Some(text) = take_event(ctx, |e| match e {
|
||||||
|
egui::Event::Paste(text) => Some(text.clone()),
|
||||||
|
_ => None,
|
||||||
|
}) {
|
||||||
|
self.paste(Some(&text), ctx);
|
||||||
|
}
|
||||||
if ctx.input_mut(|i| i.key_pressed(Key::Delete) || i.key_pressed(Key::Backspace)) {
|
if ctx.input_mut(|i| i.key_pressed(Key::Delete) || i.key_pressed(Key::Backspace)) {
|
||||||
self.delete_selection(ctx);
|
self.delete_selection(ctx);
|
||||||
}
|
}
|
||||||
@@ -492,6 +542,10 @@ impl App {
|
|||||||
|
|
||||||
/// Files dropped on the window: canvases are opened, anything else becomes
|
/// Files dropped on the window: canvases are opened, anything else becomes
|
||||||
/// a file node.
|
/// a file node.
|
||||||
|
///
|
||||||
|
/// The new nodes land in the middle of the view because the drop carries no
|
||||||
|
/// position: X11 sends the pointer along with the drag, but winit drops
|
||||||
|
/// those coordinates and reports the file alone.
|
||||||
fn handle_dropped_files(&mut self, ctx: &Context) {
|
fn handle_dropped_files(&mut self, ctx: &Context) {
|
||||||
let dropped: Vec<PathBuf> = ctx.input(|i| {
|
let dropped: Vec<PathBuf> = ctx.input(|i| {
|
||||||
i.raw
|
i.raw
|
||||||
@@ -500,14 +554,25 @@ impl App {
|
|||||||
.map(|f| f.path().to_path_buf())
|
.map(|f| f.path().to_path_buf())
|
||||||
.collect()
|
.collect()
|
||||||
});
|
});
|
||||||
|
if dropped.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let centre = self.view.centre_in_canvas();
|
||||||
|
let mut added: Vec<String> = Vec::new();
|
||||||
for path in dropped {
|
for path in dropped {
|
||||||
if path.extension().is_some_and(|e| e == "canvas") {
|
if path.extension().is_some_and(|e| e == "canvas") {
|
||||||
if self.guard(Pending::OpenPath(path.clone())) {
|
if self.guard(Pending::OpenPath(path.clone())) {
|
||||||
self.open_path(&path);
|
self.open_path(&path);
|
||||||
}
|
}
|
||||||
} else {
|
continue;
|
||||||
|
}
|
||||||
|
// A file below the canvas is stored relative to it, so the pair
|
||||||
|
// survives being moved or shared; anything else keeps its full path.
|
||||||
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();
|
// Step each further file down and right instead of stacking them all
|
||||||
|
// on the same spot.
|
||||||
|
let at = centre + egui::vec2(24.0, 24.0) * added.len() as f32;
|
||||||
self.view.insert_node(
|
self.view.insert_node(
|
||||||
&mut self.doc,
|
&mut self.doc,
|
||||||
NodeKind::File {
|
NodeKind::File {
|
||||||
@@ -516,9 +581,20 @@ impl App {
|
|||||||
},
|
},
|
||||||
at,
|
at,
|
||||||
);
|
);
|
||||||
self.set_status(format!("Added {file}"), ctx);
|
added.push(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut message = match added.as_slice() {
|
||||||
|
[] => return,
|
||||||
|
[file] => format!("Added {file}"),
|
||||||
|
files => format!("Added {} file nodes", files.len()),
|
||||||
|
};
|
||||||
|
// An unsaved canvas has no directory for the paths to be relative to,
|
||||||
|
// so they went in absolute and will not travel with the file.
|
||||||
|
if self.doc.path().is_none() {
|
||||||
|
message.push_str(" — save the canvas to store the paths relative to it");
|
||||||
}
|
}
|
||||||
|
self.set_status(message, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- chrome -----------------------------------------------------------
|
// -- chrome -----------------------------------------------------------
|
||||||
@@ -587,7 +663,7 @@ impl App {
|
|||||||
self.copy_selection(ctx);
|
self.copy_selection(ctx);
|
||||||
}
|
}
|
||||||
if item(ui, "Paste", "Ctrl+V") {
|
if item(ui, "Paste", "Ctrl+V") {
|
||||||
self.paste(ctx);
|
self.paste(None, ctx);
|
||||||
}
|
}
|
||||||
if item(ui, "Duplicate", "Ctrl+D") {
|
if item(ui, "Duplicate", "Ctrl+D") {
|
||||||
self.duplicate_selection(ctx);
|
self.duplicate_selection(ctx);
|
||||||
@@ -939,7 +1015,8 @@ const SHORTCUTS: &[(&str, &str)] = &[
|
|||||||
("Ctrl+N / Ctrl+O", "New / open a canvas"),
|
("Ctrl+N / Ctrl+O", "New / open a canvas"),
|
||||||
("Ctrl+S / Ctrl+Shift+S", "Save / save as"),
|
("Ctrl+S / Ctrl+Shift+S", "Save / save as"),
|
||||||
("Ctrl+Z / Ctrl+Shift+Z", "Undo / redo"),
|
("Ctrl+Z / Ctrl+Shift+Z", "Undo / redo"),
|
||||||
("Ctrl+C / Ctrl+V / Ctrl+D", "Copy / paste / duplicate"),
|
("Ctrl+C / Ctrl+D", "Copy / duplicate the selection"),
|
||||||
|
("Ctrl+V", "Paste nodes, or clipboard text as a node"),
|
||||||
("Ctrl+A", "Select everything"),
|
("Ctrl+A", "Select everything"),
|
||||||
("Ctrl+G", "Put the selection in a group"),
|
("Ctrl+G", "Put the selection in a group"),
|
||||||
("Ctrl+I", "Show or hide the inspector"),
|
("Ctrl+I", "Show or hide the inspector"),
|
||||||
@@ -958,6 +1035,14 @@ fn consume(ctx: &Context, modifiers: Modifiers, key: Key) -> bool {
|
|||||||
ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(modifiers, key)))
|
ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(modifiers, key)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes the first input event `wanted` accepts and returns what it made of it.
|
||||||
|
fn take_event<T>(ctx: &Context, wanted: impl Fn(&egui::Event) -> Option<T>) -> Option<T> {
|
||||||
|
ctx.input_mut(|i| {
|
||||||
|
let index = i.events.iter().position(|e| wanted(e).is_some())?;
|
||||||
|
wanted(&i.events.remove(index))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// A menu entry with its shortcut, closing the menu when clicked.
|
/// A menu entry with its shortcut, closing the menu when clicked.
|
||||||
fn item(ui: &mut Ui, label: &str, shortcut: &str) -> bool {
|
fn item(ui: &mut Ui, label: &str, shortcut: &str) -> bool {
|
||||||
let clicked = ui.add(Button::new(label).shortcut_text(shortcut)).clicked();
|
let clicked = ui.add(Button::new(label).shortcut_text(shortcut)).clicked();
|
||||||
|
|||||||
@@ -192,6 +192,45 @@ impl NodeKind {
|
|||||||
Self::Unknown { type_name } => type_name,
|
Self::Unknown { type_name } => type_name,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The node a piece of loose text should become: a link when the text is a
|
||||||
|
/// URL and nothing else, a note otherwise.
|
||||||
|
pub fn from_text(text: &str) -> Self {
|
||||||
|
match url_in(text) {
|
||||||
|
Some(url) => Self::Link { url, label: None },
|
||||||
|
None => Self::Text {
|
||||||
|
text: text.to_owned(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads `text` as a URL, returning it in a form a browser will accept.
|
||||||
|
///
|
||||||
|
/// Only text that says it is a URL counts: an explicit scheme, or the `www.`
|
||||||
|
/// prefix browsers themselves treat as one. A bare `example.com` is left alone
|
||||||
|
/// because the same shape is far more often a file name — `notes.md` and
|
||||||
|
/// `archive.zip` would otherwise turn into links.
|
||||||
|
pub fn url_in(text: &str) -> Option<String> {
|
||||||
|
let text = text.trim();
|
||||||
|
// A URL is one word. Anything with a line break or a space in it is prose.
|
||||||
|
if text.is_empty() || text.chars().any(|c| c.is_whitespace() || c.is_control()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Some(rest) = text.strip_prefix("www.") {
|
||||||
|
// `www.` on its own is not a host.
|
||||||
|
return (!rest.is_empty()).then(|| format!("https://{text}"));
|
||||||
|
}
|
||||||
|
let scheme_end = text.find(':')?;
|
||||||
|
let scheme = &text[..scheme_end];
|
||||||
|
let named = !scheme.is_empty()
|
||||||
|
&& scheme.starts_with(|c: char| c.is_ascii_alphabetic())
|
||||||
|
&& scheme
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
|
||||||
|
// A scheme needs something after it, and `C:/tmp` is a path, not a URL.
|
||||||
|
let has_target = text.len() > scheme_end + 1;
|
||||||
|
(named && scheme.len() > 1 && has_target).then(|| text.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A node of the canvas.
|
/// A node of the canvas.
|
||||||
@@ -797,4 +836,62 @@ mod tests {
|
|||||||
let ids: std::collections::HashSet<String> = (0..1000).map(|_| random_id()).collect();
|
let ids: std::collections::HashSet<String> = (0..1000).map(|_| random_id()).collect();
|
||||||
assert_eq!(ids.len(), 1000);
|
assert_eq!(ids.len(), 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_that_is_a_url_becomes_a_link() {
|
||||||
|
for raw in [
|
||||||
|
"https://jsoncanvas.org",
|
||||||
|
"http://example.com/a?b=c#d",
|
||||||
|
" https://example.com \n",
|
||||||
|
"mailto:someone@example.com",
|
||||||
|
"ftp://files.example.com/pub",
|
||||||
|
"www.example.com",
|
||||||
|
] {
|
||||||
|
match NodeKind::from_text(raw) {
|
||||||
|
NodeKind::Link { url, label } => {
|
||||||
|
let scheme = ["http", "mailto:", "ftp:"];
|
||||||
|
assert!(scheme.iter().any(|s| url.starts_with(s)), "{url}");
|
||||||
|
assert_eq!(label, None);
|
||||||
|
}
|
||||||
|
other => panic!("{raw:?} became {}", other.type_name()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A bare `www.` host is spelled out for the browser.
|
||||||
|
assert_eq!(
|
||||||
|
url_in("www.example.com").as_deref(),
|
||||||
|
Some("https://www.example.com")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_that_is_not_a_url_becomes_a_note() {
|
||||||
|
for raw in [
|
||||||
|
"Just a thought",
|
||||||
|
"https://example.com and some words",
|
||||||
|
"Read https://example.com\nlater",
|
||||||
|
"example.com",
|
||||||
|
"notes.md",
|
||||||
|
"C:/Users/me/notes.md",
|
||||||
|
"",
|
||||||
|
" ",
|
||||||
|
"a:",
|
||||||
|
":8080",
|
||||||
|
] {
|
||||||
|
let kind = NodeKind::from_text(raw);
|
||||||
|
assert!(
|
||||||
|
matches!(kind, NodeKind::Text { .. }),
|
||||||
|
"{raw:?} became {}",
|
||||||
|
kind.type_name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_note_keeps_the_text_exactly_as_it_was_pasted() {
|
||||||
|
let raw = " # Heading\n\n body ";
|
||||||
|
match NodeKind::from_text(raw) {
|
||||||
|
NodeKind::Text { text } => assert_eq!(text, raw),
|
||||||
|
other => panic!("became {}", other.type_name()),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-1
@@ -17,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, Color, Edge, EndShape, Node, NodeKind, Side, MIN_NODE_SIZE};
|
use crate::model::{Canvas, Color, Edge, EndShape, Extra, 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};
|
||||||
|
|
||||||
@@ -1596,6 +1596,29 @@ impl Clipboard {
|
|||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.nodes.is_empty()
|
self.nodes.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The copy as a JSON Canvas document, so that it can travel through the
|
||||||
|
/// system clipboard and into another window or another editor.
|
||||||
|
pub fn to_json(&self) -> String {
|
||||||
|
Canvas {
|
||||||
|
nodes: self.nodes.clone(),
|
||||||
|
edges: self.edges.clone(),
|
||||||
|
extra: Extra::new(),
|
||||||
|
}
|
||||||
|
.to_json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads back what [`Clipboard::to_json`] wrote.
|
||||||
|
///
|
||||||
|
/// `None` for anything else, including a canvas without nodes: that text is
|
||||||
|
/// pasted as a node of its own instead.
|
||||||
|
pub fn from_json(text: &str) -> Option<Self> {
|
||||||
|
let canvas = Canvas::from_json(text).ok()?;
|
||||||
|
(!canvas.nodes.is_empty()).then_some(Self {
|
||||||
|
nodes: canvas.nodes,
|
||||||
|
edges: canvas.edges,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nodes are drawn in two passes so that edges end up between the groups and
|
/// Nodes are drawn in two passes so that edges end up between the groups and
|
||||||
@@ -1944,6 +1967,42 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::model::Canvas;
|
use crate::model::Canvas;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_copy_survives_the_system_clipboard() {
|
||||||
|
let mut canvas = Canvas::default();
|
||||||
|
let note = |id: &str, x| Node::new(id, NodeKind::Text { text: id.into() }, x, 0, 250, 60);
|
||||||
|
canvas.nodes.push(note("a", 0));
|
||||||
|
canvas.nodes.push(note("b", 400));
|
||||||
|
canvas
|
||||||
|
.edges
|
||||||
|
.push(Edge::new("e", "a", Side::Right, "b", Side::Left));
|
||||||
|
|
||||||
|
let mut view = CanvasView::new();
|
||||||
|
view.select_all(&canvas);
|
||||||
|
let copied = view.copy_selection(&canvas).expect("a selection to copy");
|
||||||
|
|
||||||
|
let back = Clipboard::from_json(&copied.to_json()).expect("the copy to be recognised");
|
||||||
|
assert_eq!(back.nodes, copied.nodes);
|
||||||
|
assert_eq!(back.edges, copied.edges);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_text_is_not_mistaken_for_a_copy() {
|
||||||
|
for text in [
|
||||||
|
"https://jsoncanvas.org",
|
||||||
|
"Just a thought",
|
||||||
|
"{}",
|
||||||
|
"{\"nodes\": []}",
|
||||||
|
"[1, 2, 3]",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
Clipboard::from_json(text).is_none(),
|
||||||
|
"{text:?} was read as a copy"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs one egui pass over the canvas with the given input events.
|
/// Runs one egui pass over the canvas with the given input events.
|
||||||
fn pass(
|
fn pass(
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
|
|||||||
Reference in New Issue
Block a user