Allow pasing text directly
This commit is contained in:
+107
-22
@@ -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) {
|
||||
self.view.insert_node(
|
||||
&mut self.doc,
|
||||
@@ -345,6 +361,9 @@ impl App {
|
||||
match self.view.copy_selection(&self.doc.canvas) {
|
||||
Some(clipboard) => {
|
||||
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.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() {
|
||||
self.set_status("Nothing to paste", ctx);
|
||||
return;
|
||||
}
|
||||
let clipboard = self.clipboard.clone();
|
||||
self.paste_clipboard(&clipboard, ctx);
|
||||
}
|
||||
|
||||
fn paste_clipboard(&mut self, clipboard: &Clipboard, ctx: &Context) {
|
||||
let message = self
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -411,12 +452,6 @@ impl App {
|
||||
if consume(ctx, CTRL, K::A) {
|
||||
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) {
|
||||
self.duplicate_selection(ctx);
|
||||
}
|
||||
@@ -446,6 +481,21 @@ impl App {
|
||||
if ctx.egui_wants_keyboard_input() {
|
||||
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)) {
|
||||
self.delete_selection(ctx);
|
||||
}
|
||||
@@ -492,6 +542,10 @@ impl App {
|
||||
|
||||
/// Files dropped on the window: canvases are opened, anything else becomes
|
||||
/// 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) {
|
||||
let dropped: Vec<PathBuf> = ctx.input(|i| {
|
||||
i.raw
|
||||
@@ -500,25 +554,47 @@ impl App {
|
||||
.map(|f| f.path().to_path_buf())
|
||||
.collect()
|
||||
});
|
||||
if dropped.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let centre = self.view.centre_in_canvas();
|
||||
let mut added: Vec<String> = Vec::new();
|
||||
for path in dropped {
|
||||
if path.extension().is_some_and(|e| e == "canvas") {
|
||||
if self.guard(Pending::OpenPath(path.clone())) {
|
||||
self.open_path(&path);
|
||||
}
|
||||
} else {
|
||||
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 {
|
||||
file: file.clone(),
|
||||
subpath: None,
|
||||
},
|
||||
at,
|
||||
);
|
||||
self.set_status(format!("Added {file}"), ctx);
|
||||
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);
|
||||
// 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(
|
||||
&mut self.doc,
|
||||
NodeKind::File {
|
||||
file: file.clone(),
|
||||
subpath: None,
|
||||
},
|
||||
at,
|
||||
);
|
||||
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 -----------------------------------------------------------
|
||||
@@ -587,7 +663,7 @@ impl App {
|
||||
self.copy_selection(ctx);
|
||||
}
|
||||
if item(ui, "Paste", "Ctrl+V") {
|
||||
self.paste(ctx);
|
||||
self.paste(None, ctx);
|
||||
}
|
||||
if item(ui, "Duplicate", "Ctrl+D") {
|
||||
self.duplicate_selection(ctx);
|
||||
@@ -939,7 +1015,8 @@ const SHORTCUTS: &[(&str, &str)] = &[
|
||||
("Ctrl+N / Ctrl+O", "New / open a canvas"),
|
||||
("Ctrl+S / Ctrl+Shift+S", "Save / save as"),
|
||||
("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+G", "Put the selection in a group"),
|
||||
("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)))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn item(ui: &mut Ui, label: &str, shortcut: &str) -> bool {
|
||||
let clicked = ui.add(Button::new(label).shortcut_text(shortcut)).clicked();
|
||||
|
||||
Reference in New Issue
Block a user