M1: Cmd/Ctrl+C and Cmd/Ctrl+V copy and paste the highlighted fill, stroke or effect (#1494)

With a fill, stroke or effect row highlighted, Cmd/Ctrl+C (or Edit ▸
Copy) copies that paint or effect instead of the layers, through the
same `fill.copy`, `stroke.copy` and `effect.copy` the row's menu runs.
Cmd/Ctrl+V then pastes it onto every selected layer with `fill.paste`,
`stroke.paste` or `effect.paste`, one undo step: on top of each layer's
list, or in place of the highlighted row when it is of the same kind.

- A copy keeps its kind. A stroke goes to the strokes and an effect to
  the effects whatever row is highlighted, and the last row copied is
  the one pasted.
- A solid colour goes to the system clipboard as its hex code, anything
  else as a marker. A paste takes the property only while the system
  clipboard still holds that copy and no layers were copied since, so
  text or an image from another app, or a later layer copy, pastes as
  before. The row menus' Copy writes the same, so Cmd/Ctrl+V pastes it.
- A programmatic copy never writes the system clipboard, and forgets a
  property mirror it replaced. A mirror whose engine copy is gone pastes
  nothing rather than the layers copied before it.
- The copy is shared by every window, as the layer copy is.

Tests: property_rows_tests (15 more, 22 in all); the replace index and
the Escape key were each removed once to check the tests fail.
This commit is contained in:
ljkx
2026-11-09 20:34:32 +00:00
parent 375a7b8abb
commit 33ac1d14fc
9 changed files with 420 additions and 15 deletions
+4 -1
View File
@@ -592,8 +592,10 @@ pub struct UiState {
/// copy changes the engine clipboard without writing the OS clipboard; the generation then
/// no longer matches, so the stale mirror is not mistaken for the user's own copy.
pub last_copy_generation: u64,
/// The fill, stroke or effect row highlighted in the Design panel.
/// The fill, stroke or effect row highlighted in the Design panel: Cmd/Ctrl+C copies it.
pub property_row: Option<property_rows::Highlight>,
/// A paint or effect copied from its row: Cmd/Ctrl+V pastes it while it's the last copy.
pub property_copy: Option<property_rows::Copied>,
/// A save shortcut pressed while a panel field had the keyboard: the field lets go (which
/// commits what was typed) and the command runs next frame for that document/window.
pub after_field: Option<shortcuts::AfterFieldSave>,
@@ -738,6 +740,7 @@ impl Default for UiState {
last_copy: None,
last_copy_generation: 0,
property_row: None,
property_copy: None,
after_field: None,
held: egui::Modifiers::NONE,
next_autosave: 0.0,
+4
View File
@@ -193,6 +193,10 @@ pub fn invoke(app: &mut FigApp, id: &str) {
app.toast(tr("Properties copied"));
}
}
// A fill, stroke or effect row highlighted in the Design panel: that paint or effect.
"edit.copy" if crate::property_rows::current(app).is_some() => {
crate::property_rows::copy_highlighted(app);
}
"edit.copy" | "edit.cut" => {
let ids = app.session.selection();
// For other apps: text layers' text, other layers as SVG; taken before a cut removes them.
+6 -1
View File
@@ -16,7 +16,7 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>)
if app.control_synthetic {
// Synthetic input is user-like: Dev Mode refuses the paste here too, and only the engine's
// own copied layers are pasted (the system clipboard is never read).
if !dev_mode_refuses(app, "edit.paste") {
if !(at.is_none() && crate::property_rows::paste(app)) && !dev_mode_refuses(app, "edit.paste") {
app.run("edit.paste", at.map_or(json!({}), |(x, y)| json!({"x": x, "y": y})));
}
return;
@@ -81,6 +81,11 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>)
return paste_text(app, &text, (cx, cy), at.is_none());
}
}
// Nothing newer from another app: a fill, stroke or effect copied from its row, when that was
// the last copy, else our copied layers.
if at.is_none() && crate::property_rows::paste(app) {
return;
}
if !dev_mode_refuses(app, "edit.paste") {
app.run("edit.paste", at.map_or(json!({}), |(x, y)| json!({"x": x, "y": y})));
}
+2 -2
View File
@@ -33,7 +33,7 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node
let selected_row = crate::theme::Tokens::get(ui.ctx()).selected_row;
for (i, e) in list.iter().enumerate().rev() {
let mut open = effect_open(ui.ctx(), i);
// A click on the row's background highlights it (`property_rows`).
// A click on the row's background highlights it for Cmd/Ctrl+C (`property_rows`).
let highlight = crate::property_rows::fill(app, PropertyKind::Effect, i, selected_row);
let header = row_target(ui, 32.0, highlight, |ui| {
if icons::button(ui, Icon::Effects, 24.0, open, tr("Effect settings")).clicked() {
@@ -72,7 +72,7 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node
let can_paste = app.session.clipboard.effect.is_some();
row_context_menu(ui, header, Id::unique(("effect_menu", i))).show(|ui| {
if menu_item(ui, tr("Copy effect"), "") {
app.run("effect.copy", json!({"ids": ids, "index": i}));
crate::property_rows::copy(app, PropertyKind::Effect, i, &ids);
ui.close();
}
if can_paste && menu_item(ui, tr("Paste effect"), "") {
+2 -2
View File
@@ -456,7 +456,7 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI
/// Paint `i`'s row (swatch, colour or kind, opacity, visibility, remove); returns its area.
/// One paint's row; `region`: a row of the regions selected in vector edit mode (#1875), which
/// leaves out what belongs to the layer (its variable binding, the image crop). A click on its
/// background highlights it ([`crate::property_rows`]).
/// background highlights it for Cmd/Ctrl+C ([`crate::property_rows`]).
fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, fills: bool, region: bool) -> Rect {
let t = Tokens::get(ui.ctx());
let ids = ids.to_vec();
@@ -565,7 +565,7 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
let can_paste = app.session.clipboard.paint.as_ref().is_some_and(|(f, _)| *f == fills);
row_context_menu(ui, r, Id::unique(("paint_menu", fills, i))).show(|ui| {
if menu_item(ui, copy, "") {
app.run(if fills { "fill.copy" } else { "stroke.copy" }, json!({"ids": ids, "index": i}));
crate::property_rows::copy(app, kind, i, &ids);
ui.close();
}
if can_paste && menu_item(ui, paste, "") {
+139 -5
View File
@@ -1,13 +1,19 @@
//! Fill, stroke and effect rows of the Design panel that highlight, the first step of the help
//! center's "Copy and paste properties between layers": click to highlight the property in the
//! right sidebar.
//! Fill, stroke and effect rows of the Design panel as things to copy, as the help center's "Copy
//! and paste properties between layers" describes: click to highlight the property in the right
//! sidebar, press Cmd/Ctrl+C, select another layer, press Cmd/Ctrl+V.
//!
//! A click on a row's background (beside and between its controls, or on its reorder grip)
//! highlights it, one row at a time. The highlight belongs to the selection it was made on, and
//! goes with Escape or a press on the canvas or elsewhere in the Design panel.
//! goes with Escape or a press on the canvas or elsewhere in the Design panel. Cmd/Ctrl+C then
//! copies that one paint or effect (`fill.copy`, `stroke.copy`, `effect.copy`, as the row's menu
//! does) instead of the layers, and Cmd/Ctrl+V pastes it onto every selected layer (`fill.paste`,
//! `stroke.paste`, `effect.paste`) while it is still the last thing copied: on top of their list,
//! or in place of their highlighted row when it is of the same kind. A copy keeps its kind: a
//! stroke never lands in the fills, whatever row is highlighted.
use egui::{Color32, Context, Rect, Response};
use figcraft_doc::NodeId;
use serde_json::json;
use crate::{FigApp, RightTab};
@@ -19,6 +25,46 @@ pub enum PropertyKind {
Effect,
}
impl PropertyKind {
/// The command that copies row `index` of this list.
fn copy_command(self) -> &'static str {
match self {
PropertyKind::Fill => "fill.copy",
PropertyKind::Stroke => "stroke.copy",
PropertyKind::Effect => "effect.copy",
}
}
/// The command that pastes the copied paint or effect.
fn paste_command(self) -> &'static str {
match self {
PropertyKind::Fill => "fill.paste",
PropertyKind::Stroke => "stroke.paste",
PropertyKind::Effect => "effect.paste",
}
}
/// What the system clipboard gets when the copy has no text of its own: the marker that also
/// stands for copied layers, so a paste never makes a text layer of it.
fn marker(self) -> &'static str {
match self {
PropertyKind::Fill => "figcraft://clipboard/fill",
PropertyKind::Stroke => "figcraft://clipboard/stroke",
PropertyKind::Effect => "figcraft://clipboard/effect",
}
}
/// Whether the engine clipboard still holds a copy of this kind.
fn on_clipboard(self, app: &FigApp) -> bool {
let clip = &app.session.clipboard;
match self {
PropertyKind::Fill => clip.paint.as_ref().is_some_and(|(fills, _)| *fills),
PropertyKind::Stroke => clip.paint.as_ref().is_some_and(|(fills, _)| !*fills),
PropertyKind::Effect => clip.effect.is_some(),
}
}
}
/// The row highlighted in the Design panel. It belongs to the layers selected when it was clicked
/// and goes when they are no longer the selection.
#[derive(Clone, Debug, PartialEq)]
@@ -35,6 +81,18 @@ pub struct Highlight {
pub rect: Option<Rect>,
}
/// A paint or effect copied from its row: Cmd/Ctrl+V pastes it rather than the copied layers while
/// it is the last thing copied.
#[derive(Clone, Debug, PartialEq)]
pub struct Copied {
pub kind: PropertyKind,
/// The engine clipboard's layers generation at the copy: a layer copy since moves it on.
pub layers_generation: u64,
/// What the copy put on the system clipboard (`None`: a programmatic copy, which never writes
/// it). A user's paste takes the property only while the system clipboard is still that.
pub mirror: Option<String>,
}
/// The highlighted row, when it still belongs to the selection in the Design panel.
pub(crate) fn current(app: &FigApp) -> Option<&Highlight> {
let h = app.ui.property_row.as_ref()?;
@@ -45,7 +103,7 @@ pub(crate) fn current(app: &FigApp) -> Option<&Highlight> {
/// Let the highlight go once it no longer belongs to what the Design panel shows (another file or
/// tab, another selection, the panel not `shown`), or when the pointer is pressed on the canvas or
/// elsewhere in the Design panel. A press on its own row keeps it; so does one on a menu, a popup
/// or the toolbar, so a menu command can still act on it. Called each frame before
/// or the toolbar, so Edit ▸ Copy still copies the highlighted property. Called each frame before
/// the right panel draws its rows.
pub(crate) fn reconcile(app: &mut FigApp, ctx: &Context, shown: bool) {
if app.ui.property_row.is_none() {
@@ -115,3 +173,79 @@ pub(crate) fn follow(app: &mut FigApp, kind: PropertyKind, from: usize, to: Opti
None => app.ui.property_row = None,
}
}
/// Cmd/Ctrl+C with a highlighted row: copy its paint or effect. `false` when no row is
/// highlighted (the layers are copied as usual).
pub(crate) fn copy_highlighted(app: &mut FigApp) -> bool {
let Some(h) = current(app).cloned() else { return false };
copy(app, h.kind, h.index, &h.ids);
true
}
/// Copy paint or effect `index` of the `kind` list of `ids` (a highlighted row, or the row's menu)
/// and remember it as the last thing copied. A solid colour goes to the system clipboard as its
/// hex code, as other apps read colours; anything else as a marker of the copy.
pub(crate) fn copy(app: &mut FigApp, kind: PropertyKind, index: usize, ids: &[NodeId]) {
let Some(copied) = app.run(kind.copy_command(), json!({"ids": ids, "index": index})) else { return };
let layers_generation = app.session.clipboard.layers_generation;
let mirror = if app.control_synthetic {
// The engine's copy is no longer what the system clipboard mirrors: a user's paste must
// not take that text for this copy (`menus::paste` would paste stale layers).
if app.ui.property_copy.as_ref().is_some_and(|c| c.mirror.is_some() && c.mirror == app.ui.last_copy) {
app.ui.last_copy = None;
}
None
} else {
let text = match kind {
PropertyKind::Effect => None,
PropertyKind::Fill | PropertyKind::Stroke => {
serde_json::from_value::<figcraft_color::Paint>(copied).ok().and_then(|p| p.solid_color()).map(figcraft_color::Color::to_css_hex)
}
}
.unwrap_or_else(|| kind.marker().to_string());
app.clipboard_set_text(&text);
// The system clipboard holds our copy now: a paste reads it as ours (`menus::paste`).
app.ui.last_copy = Some(text.clone());
app.ui.last_copy_generation = layers_generation;
Some(text)
};
app.ui.property_copy = Some(Copied { kind, layers_generation, mirror });
}
/// Cmd/Ctrl+V when nothing newer than our own copy is on the system clipboard: when a paint or
/// effect copied from its row is the last thing copied, paste it onto the selected layers (in
/// place of their highlighted row of that kind, else on top) and return `true`; `false` leaves
/// the paste to the copied layers.
pub(crate) fn paste(app: &mut FigApp) -> bool {
let Some(c) = app.ui.property_copy.clone() else { return false };
let still_last = c.layers_generation == app.session.clipboard.layers_generation
&& c.kind.on_clipboard(app)
// A user's paste: the system clipboard must still be what this copy put there. A
// programmatic paste reads only the engine clipboard.
&& (app.control_synthetic || (c.mirror.is_some() && app.ui.last_copy == c.mirror));
if !still_last {
// The system clipboard still holds this copy's mirror, but the engine's copy of it is gone
// (a command copied a paint or effect of another kind since): nothing to paste, rather
// than the layers copied before it.
return !app.control_synthetic
&& c.mirror.is_some()
&& app.ui.last_copy == c.mirror
&& app.ui.last_copy_generation == app.session.clipboard.layers_generation
&& c.layers_generation == app.session.clipboard.layers_generation;
}
let ids = app.session.selection();
if ids.is_empty() {
// Nothing to paste onto: a paste of the layers copied before would be surprising.
return true;
}
let cmd = c.kind.paste_command();
if crate::menus::dev_mode_refuses(app, cmd) {
return true;
}
let mut params = json!({"ids": ids});
if let Some(h) = current(app).filter(|h| h.kind == c.kind) {
params["index"] = json!(h.index);
}
app.run(cmd, params);
true
}
+258 -3
View File
@@ -1,6 +1,6 @@
//! A fill, stroke or effect row of the Design panel highlights on a click on its background or its
//! grip ("Copy and paste properties between layers": click to highlight the property in the right
//! sidebar).
//! A fill, stroke or effect row of the Design panel highlights on a click on its background, and
//! the keyboard copies and pastes it ("Copy and paste properties between layers": click to
//! highlight the property in the right sidebar, Cmd/Ctrl+C, select another layer, Cmd/Ctrl+V).
use std::cell::RefCell;
use std::rc::Rc;
@@ -70,12 +70,144 @@ fn click_row(u: &mut Ui, label: &str) {
click(u, pos2(panel.min.x + 6.0, y));
}
/// Cmd/Ctrl+C, as the desktop shell delivers it.
fn copy(u: &mut Ui) {
u.frame(vec![Event::Copy]);
}
/// Cmd/Ctrl+V with the system clipboard's text, as the desktop shell delivers it.
fn paste(u: &mut Ui, clip: &Rc<RefCell<Option<String>>>) {
let text = clip.borrow().clone().unwrap_or_default();
u.frame(vec![Event::Paste(text)]);
}
/// Select `id` only, and draw the panel for it.
fn select(u: &mut Ui, id: NodeId) {
u.app.run("select.set", json!({"ids": [id]}));
u.frame(vec![]);
}
/// The hex codes of `id`'s fills, bottom to top.
fn fills(u: &Ui, id: NodeId) -> Vec<String> {
let doc = u.app.session.doc().unwrap();
doc.node(id).unwrap().fills.iter().map(|p| p.solid_color().unwrap().to_hex()).collect()
}
#[test]
fn a_click_on_a_fill_rows_background_highlights_it_and_the_keyboard_copies_and_pastes_it() {
let (mut u, a, b, clip) = setup();
click_row(&mut u, "FF0000");
assert_eq!(
u.app.ui.property_row.as_ref().map(|h| (h.kind, h.index, h.ids.clone())),
Some((PropertyKind::Fill, 0, vec![a])),
"the row is highlighted for A"
);
let layers_before = u.app.session.clipboard.layers_generation;
copy(&mut u);
let (fills_flag, paint) = u.app.session.clipboard.paint.clone().expect("the fill is on the clipboard");
assert!(fills_flag);
assert_eq!(paint, u.app.session.doc().unwrap().node(a).unwrap().fills[0]);
assert_eq!(u.app.session.clipboard.layers_generation, layers_before, "the layers were not copied");
assert!(u.app.session.clipboard.nodes.is_empty());
assert_eq!(clip.borrow().as_deref(), Some("#FF0000"), "the colour goes to the system clipboard as its hex code");
// Another layer selected, the paste puts the fill on top of its own.
select(&mut u, b);
assert!(u.app.ui.property_row.is_none(), "the highlight belonged to A");
let layers = u.app.session.doc().unwrap().node_count();
paste(&mut u, &clip);
assert_eq!(fills(&u, b), ["00FF00", "FF0000"]);
assert_eq!(u.app.session.doc().unwrap().node_count(), layers, "no layer was pasted");
assert_eq!(fills(&u, a), ["FF0000"], "A keeps its fill");
// One undo step takes it back off.
u.app.run("edit.undo", json!({}));
assert_eq!(fills(&u, b), ["00FF00"]);
}
#[test]
fn a_paste_replaces_the_highlighted_row_of_the_target() {
let (mut u, _, b, clip) = setup();
click_row(&mut u, "FF0000");
copy(&mut u);
select(&mut u, b);
click_row(&mut u, "00FF00");
assert_eq!(u.app.ui.property_row.as_ref().map(|h| (h.kind, h.index)), Some((PropertyKind::Fill, 0)));
paste(&mut u, &clip);
assert_eq!(fills(&u, b), ["FF0000"], "in place of B's highlighted fill");
}
#[test]
fn a_stroke_row_copies_its_paint_and_settings() {
let (mut u, a, b, clip) = setup();
click_row(&mut u, "0000FF");
assert_eq!(u.app.ui.property_row.as_ref().map(|h| h.kind), Some(PropertyKind::Stroke));
copy(&mut u);
select(&mut u, b);
paste(&mut u, &clip);
let doc = u.app.session.doc().unwrap();
let (na, nb) = (doc.node(a).unwrap(), doc.node(b).unwrap());
assert_eq!(nb.strokes, na.strokes);
assert_eq!(nb.stroke.weight, 3.0, "the weight comes with the stroke");
assert_eq!(fills(&u, b), ["00FF00"], "the fills are untouched");
}
#[test]
fn an_effect_row_copies_and_pastes_its_effect() {
let (mut u, a, b, clip) = setup();
click_row(&mut u, "Layer blur");
assert_eq!(u.app.ui.property_row.as_ref().map(|h| (h.kind, h.index)), Some((PropertyKind::Effect, 0)));
copy(&mut u);
assert_eq!(clip.borrow().as_deref(), Some("figcraft://clipboard/effect"), "an effect has no text: a marker of our copy");
select(&mut u, b);
paste(&mut u, &clip);
let doc = u.app.session.doc().unwrap();
let blur = doc.node(a).unwrap().effects[0].clone();
assert_eq!(doc.node(b).unwrap().effects.len(), 2);
assert_eq!(doc.node(b).unwrap().effects[1], blur, "the blur goes on top of B's shadow");
}
#[test]
fn without_a_highlighted_row_the_keyboard_copies_and_pastes_layers() {
let (mut u, _, b, clip) = setup();
copy(&mut u);
assert!(u.app.session.clipboard.paint.is_none(), "no fill was copied");
assert_eq!(u.app.session.clipboard.nodes.len(), 1, "A was copied");
select(&mut u, b);
let layers = u.app.session.doc().unwrap().node_count();
paste(&mut u, &clip);
assert_eq!(u.app.session.doc().unwrap().node_count(), layers + 1, "a copy of A was pasted");
assert_eq!(fills(&u, b), ["00FF00"]);
}
#[test]
fn copying_layers_after_a_fill_pastes_the_layers() {
let (mut u, a, b, clip) = setup();
click_row(&mut u, "FF0000");
copy(&mut u);
// Another selection lets the highlight go; the next copy takes the layers.
select(&mut u, b);
select(&mut u, a);
assert!(u.app.ui.property_row.is_none(), "selecting A again doesn't bring the highlight back");
copy(&mut u);
select(&mut u, b);
let layers = u.app.session.doc().unwrap().node_count();
paste(&mut u, &clip);
assert_eq!(u.app.session.doc().unwrap().node_count(), layers + 1, "the layers were the last copy");
assert_eq!(fills(&u, b), ["00FF00"]);
}
#[test]
fn text_copied_in_another_app_afterwards_pastes_as_text() {
let (mut u, _, b, clip) = setup();
click_row(&mut u, "FF0000");
copy(&mut u);
select(&mut u, b);
*clip.borrow_mut() = Some("Hello".into());
let layers = u.app.session.doc().unwrap().node_count();
paste(&mut u, &clip);
assert_eq!(fills(&u, b), ["00FF00"], "the fill isn't what the clipboard holds now");
assert_eq!(u.app.session.doc().unwrap().node_count(), layers + 1, "the text became a text layer");
}
#[test]
fn a_press_elsewhere_lets_the_highlight_go() {
let (mut u, a, _, _) = setup();
@@ -89,6 +221,9 @@ fn a_press_elsewhere_lets_the_highlight_go() {
click(&mut u, text_center(&shapes, "Effects"));
assert_eq!(u.app.session.selection(), [a]);
assert!(u.app.ui.property_row.is_none());
// With nothing highlighted Cmd/Ctrl+C copies the layer again.
copy(&mut u);
assert!(u.app.session.clipboard.paint.is_none());
}
#[test]
@@ -141,6 +276,126 @@ fn a_click_on_the_canvas_lets_the_highlight_go() {
assert!(u.app.ui.property_row.is_none());
}
#[test]
fn edit_copy_from_the_menu_copies_the_highlighted_row() {
let (mut u, _, _, _) = setup();
click_row(&mut u, "FF0000");
crate::menus::invoke(&mut u.app, "edit.copy");
assert!(u.app.session.clipboard.paint.as_ref().is_some_and(|(fills, _)| *fills), "the fill was copied");
assert!(u.app.session.clipboard.nodes.is_empty(), "not the layer");
}
#[test]
fn a_paste_onto_several_layers_adds_the_fill_to_each_in_one_undo_step() {
let (mut u, a, b, clip) = setup();
let c = rect(&mut u.app.session, 200.0, "#0000FF");
// A new rectangle is selected: A again.
select(&mut u, a);
click_row(&mut u, "FF0000");
copy(&mut u);
// B and C have different fills: the panel shows mixed content, no row to highlight.
u.app.run("select.set", json!({"ids": [b, c]}));
u.frame(vec![]);
paste(&mut u, &clip);
assert_eq!(fills(&u, b), ["00FF00", "FF0000"]);
assert_eq!(fills(&u, c), ["0000FF", "FF0000"]);
u.app.run("edit.undo", json!({}));
assert_eq!((fills(&u, b), fills(&u, c)), (vec!["00FF00".to_string()], vec!["0000FF".to_string()]));
}
#[test]
fn a_paste_replaces_the_highlighted_row_on_every_selected_layer() {
let (mut u, a, b, clip) = setup();
let c = rect(&mut u.app.session, 200.0, "#00FF00");
// A new rectangle is selected: A again.
select(&mut u, a);
click_row(&mut u, "FF0000");
copy(&mut u);
// B and C share their fill: its row shows for both.
u.app.run("select.set", json!({"ids": [b, c]}));
u.frame(vec![]);
click_row(&mut u, "00FF00");
assert_eq!(u.app.ui.property_row.as_ref().map(|h| h.ids.clone()), Some(vec![b, c]));
paste(&mut u, &clip);
assert_eq!((fills(&u, b), fills(&u, c)), (vec!["FF0000".to_string()], vec!["FF0000".to_string()]));
u.app.run("edit.undo", json!({}));
assert_eq!((fills(&u, b), fills(&u, c)), (vec!["00FF00".to_string()], vec!["00FF00".to_string()]));
}
#[test]
fn a_copied_stroke_goes_to_the_strokes_with_a_fill_row_highlighted() {
let (mut u, a, b, clip) = setup();
click_row(&mut u, "0000FF");
copy(&mut u);
select(&mut u, b);
click_row(&mut u, "00FF00");
assert_eq!(u.app.ui.property_row.as_ref().map(|h| h.kind), Some(PropertyKind::Fill));
paste(&mut u, &clip);
let doc = u.app.session.doc().unwrap();
assert_eq!(doc.node(b).unwrap().strokes, doc.node(a).unwrap().strokes, "the stroke went to the strokes");
assert_eq!(fills(&u, b), ["00FF00"], "the highlighted fill wasn't replaced");
}
#[test]
fn an_effect_copied_after_a_fill_is_what_pastes() {
let (mut u, a, b, clip) = setup();
click_row(&mut u, "FF0000");
copy(&mut u);
click_row(&mut u, "Layer blur");
copy(&mut u);
select(&mut u, b);
paste(&mut u, &clip);
assert_eq!(fills(&u, b), ["00FF00"], "the fill was copied first: not pasted");
let doc = u.app.session.doc().unwrap();
assert_eq!(doc.node(b).unwrap().effects.get(1), doc.node(a).unwrap().effects.first(), "the blur went on top of B's shadow");
}
#[test]
fn a_row_menu_copy_is_pasted_by_the_keyboard() {
let (mut u, _, b, clip) = setup();
let shapes = flat(&u.frame_output(vec![]).shapes);
let p = text_center(&shapes, "FF0000");
for pressed in [true, false] {
u.frame(vec![Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }]);
}
let menu = flat(&u.frame_output(vec![]).shapes);
click(&mut u, text_center(&menu, crate::i18n::tr("Copy fill")));
assert_eq!(clip.borrow().as_deref(), Some("#FF0000"));
select(&mut u, b);
paste(&mut u, &clip);
assert_eq!(fills(&u, b), ["00FF00", "FF0000"]);
}
#[test]
fn programmatic_copy_and_paste_of_a_row_leave_the_system_clipboard_alone() {
let (mut u, a, b, clip) = setup();
*clip.borrow_mut() = Some("the user's text".into());
let doc = u.app.session.ds().unwrap().uid;
u.app.ui.property_row = Some(Highlight { kind: PropertyKind::Fill, index: 0, ids: vec![a], doc, rect: None });
u.app.control_synthetic = true;
crate::menus::invoke(&mut u.app, "edit.copy");
assert_eq!(clip.borrow().as_deref(), Some("the user's text"), "never written");
u.app.run("select.set", json!({"ids": [b]}));
crate::menus::paste(&mut u.app, None, None);
assert_eq!(fills(&u, b), ["00FF00", "FF0000"]);
}
#[test]
fn a_property_copy_whose_engine_copy_is_gone_pastes_no_stale_layers() {
let (mut u, a, b, clip) = setup();
// A layer copy first, then the fill row's.
copy(&mut u);
click_row(&mut u, "FF0000");
copy(&mut u);
// A stroke copied without its row (the Actions palette): the fill is no longer on the clipboard.
u.app.run("stroke.copy", json!({"id": a}));
select(&mut u, b);
let layers = u.app.session.doc().unwrap().node_count();
paste(&mut u, &clip);
assert_eq!(u.app.session.doc().unwrap().node_count(), layers, "the layer copied before wasn't pasted");
assert_eq!(fills(&u, b), ["00FF00"]);
}
#[test]
fn a_highlighted_row_whose_paint_an_undo_took_away_goes() {
let (mut u, a, _, _) = setup();
+2
View File
@@ -236,6 +236,8 @@ fn exchange(app: &mut FigApp, p: &mut Pane) {
std::mem::swap(&mut app.ui, &mut p.ui);
std::mem::swap(&mut app.ui.last_copy, &mut p.ui.last_copy);
std::mem::swap(&mut app.ui.last_copy_generation, &mut p.ui.last_copy_generation);
// A paint or effect copied from its row is on the one clipboard every window pastes from.
std::mem::swap(&mut app.ui.property_copy, &mut p.ui.property_copy);
std::mem::swap(&mut app.ui.quit, &mut p.ui.quit);
std::mem::swap(&mut app.ui.dark, &mut p.ui.dark);
std::mem::swap(&mut app.canvas, &mut p.canvas);
+3 -1
View File
File diff suppressed because one or more lines are too long