mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 13:52:34 +00:00
Merge main into #2163
This commit is contained in:
commit
ef91c2a68c
13 files changed
+869
-21
No files matched your search
@@ -302,6 +302,8 @@ impl FigApp {
|
||||
self.ui.narrow = narrow;
|
||||
// A narrow window shows the right panel when its button asks for it, a board's too.
|
||||
let right_open = (self.right_panel_shown(hidden) && !narrow && !right_minimized) || mobile == Some(MobilePanel::Right);
|
||||
// A highlighted fill, stroke or effect row goes with the panel it's in, before it draws.
|
||||
crate::property_rows::reconcile(self, &ctx, right_open);
|
||||
// A right-to-left UI language mirrors the window: the "left" rail and panel go on the
|
||||
// right, the "right" (Design) panel on the left. Their ids and widths stay as they are.
|
||||
let mirrored = self.ui.mirrored;
|
||||
|
||||
@@ -232,6 +232,9 @@ mod present_tests;
|
||||
mod present_view_tests;
|
||||
#[cfg(test)]
|
||||
mod property_menu_tests;
|
||||
mod property_rows;
|
||||
#[cfg(test)]
|
||||
mod property_rows_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_editor_tests;
|
||||
#[cfg(test)]
|
||||
@@ -597,6 +600,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: 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>,
|
||||
@@ -740,6 +747,8 @@ 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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -89,6 +89,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})));
|
||||
}
|
||||
|
||||
@@ -75,8 +75,19 @@ impl ListDrag {
|
||||
/// Item `index` (model order) was laid out with its main row at `header`, covering `extent`
|
||||
/// (the header plus anything open under it). Its grip starts a drag carrying `label`.
|
||||
pub fn row(&mut self, ui: &mut Ui, index: usize, header: Rect, extent: Rect, label: impl FnOnce() -> String) {
|
||||
self.grip_row(ui, index, header, extent, Sense::drag(), label);
|
||||
}
|
||||
|
||||
/// [`Self::row`] whose grip also takes a click, the way a click beside the row's fields
|
||||
/// highlights a fill, stroke or effect row: `true` when it was clicked. The grip senses the
|
||||
/// click without being focusable, so Tab doesn't stop on it.
|
||||
pub fn row_clicked(&mut self, ui: &mut Ui, index: usize, header: Rect, extent: Rect, label: impl FnOnce() -> String) -> bool {
|
||||
self.grip_row(ui, index, header, extent, Sense::DRAG | Sense::CLICK, label)
|
||||
}
|
||||
|
||||
fn grip_row(&mut self, ui: &mut Ui, index: usize, header: Rect, extent: Rect, sense: Sense, label: impl FnOnce() -> String) -> bool {
|
||||
let grip = Rect::from_min_max(header.min, pos2(header.min.x + PAD, header.max.y));
|
||||
let resp = ui.interact(grip, grip_of(self.list, index), Sense::drag());
|
||||
let resp = ui.interact(grip, grip_of(self.list, index), sense);
|
||||
// The handle shows while the pointer is over the row (or the row is being dragged).
|
||||
let shown = match self.dragging {
|
||||
Some(d) => d == index,
|
||||
@@ -93,6 +104,7 @@ impl ListDrag {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
self.row_by(&resp, index, extent, label);
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
/// Item `index` laid out over `extent`, dragged by the whole row (`resp` senses drags; the
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use super::menu::{context_popup, menu_check, menu_item, menu_popup, row, row_context_menu, row_rect};
|
||||
use super::menu::{context_popup, menu_check, menu_item, menu_popup, row, row_context_menu, row_rect, row_target};
|
||||
use super::{
|
||||
Blur, BlurType, Document, Effect, EffectKind, FieldEdit, FigApp, Glass, GridAlign, GridKind, Icon, Id, LayoutGrid, Lead, ListDrag,
|
||||
MAX_BLUR_RADIUS, NodeId, Noise, NoiseMode, Paint, Sense, Shadow, StyleSlot, Texture, Ui, VariableType, common, icons, json, move_flags,
|
||||
moved_index, remove_flags, removed_index, style_picker, tr, trf, trn, vec2, widgets,
|
||||
};
|
||||
use crate::property_rows::PropertyKind;
|
||||
|
||||
/// Draw the effect list with add/style/type/toggle/remove/reorder, open settings, and keep an
|
||||
/// open picker and settings flags aligned when rows move or are removed.
|
||||
@@ -29,9 +30,12 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node
|
||||
let Some(list) = list else { return };
|
||||
let mut drag = ListDrag::new(ui, "effects");
|
||||
let mut removed = None;
|
||||
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);
|
||||
let header = row_rect(ui, 32.0, |ui| {
|
||||
// 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() {
|
||||
open = !open;
|
||||
ui.data_mut(|d| d.insert_temp(Id::unique((FX_OPEN, i)), open));
|
||||
@@ -63,10 +67,12 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node
|
||||
removed = Some(i);
|
||||
}
|
||||
});
|
||||
crate::property_rows::row(app, &header, PropertyKind::Effect, i, &ids);
|
||||
let header = header.rect;
|
||||
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"), "") {
|
||||
@@ -77,10 +83,13 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node
|
||||
if open {
|
||||
effect_settings(app, ui, &ids, i, e);
|
||||
}
|
||||
drag.row(ui, i, header, header.with_max_y(ui.cursor().min.y.max(header.max.y)), || tr(e.label()).to_string());
|
||||
if drag.row_clicked(ui, i, header, header.with_max_y(ui.cursor().min.y.max(header.max.y)), || tr(e.label()).to_string()) {
|
||||
crate::property_rows::click(app, PropertyKind::Effect, i, &ids, header);
|
||||
}
|
||||
}
|
||||
if let Some((from, to)) = drag.finish(ui) {
|
||||
app.run("effect.move", json!({"ids": ids, "from": from, "to": to}));
|
||||
crate::property_rows::follow(app, PropertyKind::Effect, from, Some(to));
|
||||
move_flags(ui, FX_OPEN, list.len(), from, to);
|
||||
if let Some(crate::colorpicker::PickerTarget::Effect { index, .. }) = &mut app.ui.picker {
|
||||
*index = moved_index(*index, from, to);
|
||||
@@ -88,6 +97,7 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node
|
||||
}
|
||||
// Open settings and an open colour picker stay with their effects, or go with theirs.
|
||||
if let Some(i) = removed {
|
||||
crate::property_rows::follow(app, PropertyKind::Effect, i, None);
|
||||
remove_flags(ui, FX_OPEN, list.len(), i);
|
||||
if let Some(crate::colorpicker::PickerTarget::Effect { index, .. }) = &mut app.ui.picker {
|
||||
match removed_index(*index, i) {
|
||||
|
||||
@@ -155,12 +155,31 @@ pub(crate) fn row_rect(ui: &mut Ui, h: f32, add: impl FnOnce(&mut Ui)) -> Rect {
|
||||
pub(crate) fn block(ui: &mut Ui, h: f32, inset_y: f32, add: impl FnOnce(&mut Ui)) -> Rect {
|
||||
let w = ui.available_width();
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
|
||||
lay_out(ui, r, inset_y, add);
|
||||
r
|
||||
}
|
||||
|
||||
/// A [`row_rect`] that is a pointer target itself: a click on its background (beside and between
|
||||
/// its controls, which are on top of it) comes back in the response. It senses clicks without
|
||||
/// being focusable, so Tab and F6 still go from control to control. `fill` is drawn under the
|
||||
/// controls (a highlighted fill, stroke or effect row).
|
||||
pub(crate) fn row_target(ui: &mut Ui, h: f32, fill: Option<Color32>, add: impl FnOnce(&mut Ui)) -> egui::Response {
|
||||
let w = ui.available_width();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, h), Sense::CLICK);
|
||||
if let Some(fill) = fill {
|
||||
ui.painter().rect_filled(r, CornerRadius::ZERO, fill);
|
||||
}
|
||||
lay_out(ui, r, (h - FIELD_H) / 2.0, add);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Add a row's controls in `r`, on the section's grid, inset `inset_y` from its top and bottom.
|
||||
fn lay_out(ui: &mut Ui, r: Rect, inset_y: f32, add: impl FnOnce(&mut Ui)) {
|
||||
// The section's grid: 16 in from the left, 8 from the right (not 16 on both sides).
|
||||
let inner = Rect::from_min_max(pos2(r.min.x + PAD, r.min.y + inset_y), pos2(r.max.x - crate::widgets::PAD_RIGHT, r.max.y - inset_y));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
child.spacing_mut().item_spacing = vec2(crate::widgets::GUTTER, 0.0);
|
||||
add(&mut child);
|
||||
r
|
||||
}
|
||||
|
||||
/// Width of the check column of [`menu_icon_item`] (the mark, or nothing, so labels line up).
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use super::menu::{blend_menu, context_popup, menu_check, menu_item, menu_popup, preview_blend, row, row_context_menu, row_rect};
|
||||
use super::menu::{blend_menu, context_popup, menu_check, menu_item, menu_popup, preview_blend, row, row_context_menu, row_target};
|
||||
use super::stroke::stroke_settings;
|
||||
use super::{
|
||||
Align2, BlendMode, CornerRadius, Document, FIELD_H, FieldEdit, FigApp, Icon, Id, Lead, ListDrag, MaskType, Node, NodeId, NodeKind, Paint,
|
||||
PaintKind, Rect, Sense, Stroke, StrokeAlign, StyleSlot, Tip, Tokens, Ui, Value, VariableType, common, icons, json, moved_index, pos2, regular,
|
||||
removed_index, style_picker, tr, trf, vec2, widgets,
|
||||
};
|
||||
use crate::property_rows::PropertyKind;
|
||||
|
||||
/// Draw visibility/blend/mask, opacity, uniform or per-corner radius, corner smoothing and
|
||||
/// the blend-mode row.
|
||||
@@ -341,10 +342,13 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI
|
||||
let mut drag = ListDrag::new(ui, if fills { "fills" } else { "strokes" });
|
||||
for (i, p) in l.iter().enumerate().rev() {
|
||||
let r = paint_row(app, ui, &ids, i, p, fills, region);
|
||||
drag.row(ui, i, r, r, || paint_label(p));
|
||||
if drag.row_clicked(ui, i, r, r, || paint_label(p)) {
|
||||
crate::property_rows::click(app, if fills { PropertyKind::Fill } else { PropertyKind::Stroke }, i, &ids, r);
|
||||
}
|
||||
}
|
||||
if let Some((from, to)) = drag.finish(ui) {
|
||||
app.run(if fills { "fill.move" } else { "stroke.move" }, json!({"ids": ids, "from": from, "to": to}));
|
||||
crate::property_rows::follow(app, if fills { PropertyKind::Fill } else { PropertyKind::Stroke }, from, Some(to));
|
||||
// An open colour picker keeps editing the paint it was on.
|
||||
if let Some(crate::colorpicker::PickerTarget::Paint { fills: f, index, .. }) = &mut app.ui.picker
|
||||
&& *f == fills
|
||||
@@ -451,12 +455,15 @@ 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).
|
||||
/// leaves out what belongs to the layer (its variable binding, the image crop). A click on its
|
||||
/// 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();
|
||||
let cmd = if fills { "fill.set" } else { "stroke.set" };
|
||||
let r = row_rect(ui, 32.0, |ui| {
|
||||
let kind = if fills { PropertyKind::Fill } else { PropertyKind::Stroke };
|
||||
let highlight = crate::property_rows::fill(app, kind, i, t.selected_row);
|
||||
let resp = row_target(ui, 32.0, highlight, |ui| {
|
||||
let w = ui.available_width() - 60.0;
|
||||
let (sresp, e) = widgets::paint_field(ui, Id::unique(("swatch", fills, i)), Id::unique(("pop", fills, i)), p, w, |child, text_r| {
|
||||
let r = child.max_rect();
|
||||
@@ -537,24 +544,28 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
|
||||
if toggle {
|
||||
app.run(cmd, json!({"ids": ids, "index": i, "visible": !p.visible}));
|
||||
}
|
||||
if remove
|
||||
&& app.run(if fills { "fill.remove" } else { "stroke.remove" }, json!({"ids": ids, "index": i})).is_some()
|
||||
&& let Some(crate::colorpicker::PickerTarget::Paint { fills: f, index, .. }) = &mut app.ui.picker
|
||||
&& *f == fills
|
||||
{
|
||||
if remove && app.run(if fills { "fill.remove" } else { "stroke.remove" }, json!({"ids": ids, "index": i})).is_some() {
|
||||
// A highlighted row stays on its paint, or goes with it.
|
||||
crate::property_rows::follow(app, kind, i, None);
|
||||
// An open colour picker keeps editing the paint it was on, or closes with it.
|
||||
match removed_index(*index, i) {
|
||||
Some(k) => *index = k,
|
||||
None => app.ui.picker = None,
|
||||
if let Some(crate::colorpicker::PickerTarget::Paint { fills: f, index, .. }) = &mut app.ui.picker
|
||||
&& *f == fills
|
||||
{
|
||||
match removed_index(*index, i) {
|
||||
Some(k) => *index = k,
|
||||
None => app.ui.picker = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::property_rows::row(app, &resp, kind, i, &ids);
|
||||
let r = resp.rect;
|
||||
let copy = if fills { tr("Copy fill") } else { tr("Copy stroke") };
|
||||
let paste = if fills { tr("Paste fill") } else { tr("Paste stroke") };
|
||||
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, "") {
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
//! 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. 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.
|
||||
//!
|
||||
//! A programmatic copy (the control channel) never writes the system clipboard, so it has no
|
||||
//! mirror there: a programmatic paste takes it from the engine clipboard, while a user's paste
|
||||
//! goes by what the system clipboard holds, as it does for copied layers.
|
||||
|
||||
use egui::{Color32, Context, Rect, Response};
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{FigApp, RightTab};
|
||||
|
||||
/// Which list of the Design panel a row belongs to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PropertyKind {
|
||||
Fill,
|
||||
Stroke,
|
||||
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)]
|
||||
pub struct Highlight {
|
||||
pub kind: PropertyKind,
|
||||
/// The paint's or effect's index in its list (model order: the top one is the last).
|
||||
pub index: usize,
|
||||
/// The selection the row was highlighted on.
|
||||
pub ids: Vec<NodeId>,
|
||||
/// The file it was highlighted in.
|
||||
pub doc: u64,
|
||||
/// Where the row was drawn this frame (`None` until it draws): a press elsewhere lets it go,
|
||||
/// and so does a frame it isn't drawn in.
|
||||
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()?;
|
||||
let ds = app.session.ds().ok()?;
|
||||
(app.ui.right_tab == RightTab::Design && ds.uid == h.doc && ds.selection == h.ids).then_some(h)
|
||||
}
|
||||
|
||||
/// 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 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() {
|
||||
return;
|
||||
}
|
||||
let pressed = ctx.input(|i| if i.pointer.any_pressed() { i.pointer.press_origin() } else { None });
|
||||
// A menu or popup is a foreground area over whatever is under it.
|
||||
let lets_go = pressed.filter(|p| ctx.layer_id_at(*p).is_none_or(|l| l.order != egui::Order::Foreground)).is_some_and(|p| {
|
||||
let on_row = app.ui.property_row.as_ref().and_then(|h| h.rect).is_some_and(|r| r.contains(p));
|
||||
let in_panel = app.ui.right_panel_rect.is_some_and(|r| r.contains(p));
|
||||
!on_row && (in_panel || app.canvas.rect.contains(p))
|
||||
});
|
||||
// The row draws itself each frame (`row`): one that wasn't drawn last frame is gone (an undo
|
||||
// took its paint away, say), and its index may now name another row or none.
|
||||
let drawn = app.ui.property_row.as_ref().is_some_and(|h| h.rect.is_some());
|
||||
if lets_go || !shown || !drawn || current(app).is_none() {
|
||||
app.ui.property_row = None;
|
||||
} else if let Some(h) = app.ui.property_row.as_mut() {
|
||||
h.rect = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape with a highlighted row: the highlight goes and the selection stays. `false` when no row
|
||||
/// is highlighted (Escape does what it does otherwise).
|
||||
pub(crate) fn escape(app: &mut FigApp) -> bool {
|
||||
let highlighted = current(app).is_some();
|
||||
app.ui.property_row = None;
|
||||
highlighted
|
||||
}
|
||||
|
||||
/// Row `index` of the `kind` list, drawn as `resp` (a row target, see [`fill`]) for the selection
|
||||
/// `ids`: a click on its background highlights it; the highlighted row keeps where it was drawn.
|
||||
pub(crate) fn row(app: &mut FigApp, resp: &Response, kind: PropertyKind, index: usize, ids: &[NodeId]) {
|
||||
if resp.clicked() {
|
||||
click(app, kind, index, ids, resp.rect);
|
||||
} else if let Some(h) = app.ui.property_row.as_mut()
|
||||
&& h.kind == kind
|
||||
&& h.index == index
|
||||
{
|
||||
h.rect = Some(resp.rect);
|
||||
}
|
||||
}
|
||||
|
||||
/// A click on row `index` of the `kind` list (drawn at `rect`) for the selection `ids`, on its
|
||||
/// background or on its reorder grip, the strip left of its fields: it is the highlighted row now.
|
||||
pub(crate) fn click(app: &mut FigApp, kind: PropertyKind, index: usize, ids: &[NodeId], rect: Rect) {
|
||||
let Some(doc) = app.session.ds().ok().map(|d| d.uid) else { return };
|
||||
app.ui.property_row = Some(Highlight { kind, index, ids: ids.to_vec(), doc, rect: Some(rect) });
|
||||
}
|
||||
|
||||
/// The background of row `index` of the `kind` list: the selected-row colour when it is the
|
||||
/// highlighted one.
|
||||
pub(crate) fn fill(app: &FigApp, kind: PropertyKind, index: usize, selected: Color32) -> Option<Color32> {
|
||||
current(app).is_some_and(|h| h.kind == kind && h.index == index).then_some(selected)
|
||||
}
|
||||
|
||||
/// The highlight follows its paint or effect when the rows of `kind` are reordered (`from` moved
|
||||
/// to `to`) or one is removed (`to` is `None`); it goes with a removed one.
|
||||
pub(crate) fn follow(app: &mut FigApp, kind: PropertyKind, from: usize, to: Option<usize>) {
|
||||
let Some(h) = app.ui.property_row.as_mut().filter(|h| h.kind == kind) else { return };
|
||||
let moved = match to {
|
||||
Some(to) => Some(figcraft_engine::cmd::lists::moved_index(h.index, from, to)),
|
||||
None => figcraft_engine::cmd::lists::removed_index(h.index, from),
|
||||
};
|
||||
match moved {
|
||||
Some(i) => h.index = i,
|
||||
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 {
|
||||
// No layers were copied since this copy, 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. For a user's paste, only while the system clipboard still
|
||||
// holds this copy's mirror; a programmatic paste reads only the engine clipboard.
|
||||
let ours = app.control_synthetic
|
||||
|| (c.mirror.is_some() && app.ui.last_copy == c.mirror && app.ui.last_copy_generation == app.session.clipboard.layers_generation);
|
||||
return ours && 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
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
//! 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;
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, pos2};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Services;
|
||||
use crate::layers_drag_tests::Ui;
|
||||
use crate::live_tests::{flat, text_center};
|
||||
use crate::property_rows::{Highlight, PropertyKind};
|
||||
|
||||
/// The test machine's system clipboard: the text last written, which another app may replace.
|
||||
struct Clip(Rc<RefCell<Option<String>>>);
|
||||
|
||||
impl Services for Clip {
|
||||
fn clipboard_set_text(&mut self, s: &str) {
|
||||
*self.0.borrow_mut() = Some(s.to_string());
|
||||
}
|
||||
fn clipboard_get_text(&mut self) -> Option<String> {
|
||||
self.0.borrow().clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// A 40 x 40 rectangle filled with `color`; its id.
|
||||
fn rect(s: &mut Session, x: f64, color: &str) -> NodeId {
|
||||
let id = NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("fill.set", &json!({"ids": [id], "color": color})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// Rectangles A (red, a 3 pt blue stroke and a layer blur) and B (green, a drop shadow), A
|
||||
/// selected, in a window whose system clipboard the test reads and writes.
|
||||
fn setup() -> (Ui, NodeId, NodeId, Rc<RefCell<Option<String>>>) {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, "#FF0000");
|
||||
let b = rect(&mut s, 100.0, "#00FF00");
|
||||
s.execute("stroke.add", &json!({"ids": [a], "paint": {"type": "solid", "color": "#0000FF"}})).unwrap();
|
||||
s.execute("node.set", &json!({"ids": [a], "props": {"strokeWeight": 3.0}})).unwrap();
|
||||
s.execute("effect.add", &json!({"ids": [a], "type": "layerBlur"})).unwrap();
|
||||
s.execute("effect.add", &json!({"ids": [b], "type": "dropShadow"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
let clip = Rc::new(RefCell::new(None));
|
||||
u.app.services = Box::new(Clip(clip.clone()));
|
||||
u.frame(vec![]);
|
||||
(u, a, b, clip)
|
||||
}
|
||||
|
||||
/// A primary click at `p`, and a few frames after it.
|
||||
fn click(u: &mut Ui, p: Pos2) {
|
||||
u.frame(vec![Event::PointerMoved(p)]);
|
||||
for pressed in [true, false] {
|
||||
u.frame(vec![Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]);
|
||||
}
|
||||
u.frame(vec![]);
|
||||
}
|
||||
|
||||
/// Click the background of the Design panel row showing `label`: its left margin, where no
|
||||
/// control is (the reorder grip shows there on hover).
|
||||
fn click_row(u: &mut Ui, label: &str) {
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
let y = text_center(&shapes, label).y;
|
||||
let panel = u.app.ui.right_panel_rect.expect("the Design panel is open");
|
||||
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();
|
||||
click_row(&mut u, "FF0000");
|
||||
assert!(u.app.ui.property_row.is_some());
|
||||
// A press on the row's own background keeps it.
|
||||
click_row(&mut u, "FF0000");
|
||||
assert!(u.app.ui.property_row.is_some());
|
||||
// One anywhere else in the panel lets it go, the selection staying as it is.
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
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]
|
||||
fn the_highlight_follows_its_row_when_rows_move_or_go() {
|
||||
let (mut u, a, _, _) = setup();
|
||||
let doc = u.app.session.ds().unwrap().uid;
|
||||
let at = |u: &Ui| u.app.ui.property_row.as_ref().map(|h| h.index);
|
||||
u.app.ui.property_row = Some(Highlight { kind: PropertyKind::Fill, index: 1, ids: vec![a], doc, rect: None });
|
||||
crate::property_rows::follow(&mut u.app, PropertyKind::Fill, 1, Some(3));
|
||||
assert_eq!(at(&u), Some(3), "moved with its paint");
|
||||
crate::property_rows::follow(&mut u.app, PropertyKind::Stroke, 3, None);
|
||||
assert_eq!(at(&u), Some(3), "another list's change leaves it");
|
||||
crate::property_rows::follow(&mut u.app, PropertyKind::Fill, 0, None);
|
||||
assert_eq!(at(&u), Some(2), "a paint under it went");
|
||||
crate::property_rows::follow(&mut u.app, PropertyKind::Fill, 2, None);
|
||||
assert_eq!(at(&u), None, "its paint went");
|
||||
}
|
||||
|
||||
/// A key press with no modifiers, as the desktop shell delivers it.
|
||||
fn key(u: &mut Ui, key: egui::Key) {
|
||||
u.frame(vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
|
||||
u.frame(vec![Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_lets_the_highlight_go_and_keeps_the_selection() {
|
||||
let (mut u, a, _, _) = setup();
|
||||
click_row(&mut u, "FF0000");
|
||||
assert!(u.app.ui.property_row.is_some());
|
||||
key(&mut u, egui::Key::Escape);
|
||||
assert!(u.app.ui.property_row.is_none(), "Escape let the highlight go");
|
||||
assert_eq!(u.app.session.selection(), [a], "and only that: A is still selected");
|
||||
// The next Escape deselects, as it does with no row highlighted.
|
||||
key(&mut u, egui::Key::Escape);
|
||||
assert!(u.app.session.selection().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_on_the_canvas_lets_the_highlight_go() {
|
||||
let (mut u, a, _, _) = setup();
|
||||
u.app.run("view.zoomFit", json!({}));
|
||||
u.frame(vec![]);
|
||||
click_row(&mut u, "FF0000");
|
||||
assert!(u.app.ui.property_row.is_some());
|
||||
// A click on A itself: the selection stays as it is, the highlight goes.
|
||||
let on_a = u.app.to_screen(20.0, 20.0);
|
||||
assert!(u.app.canvas.rect.contains(on_a), "A is in view");
|
||||
click(&mut u, on_a);
|
||||
assert_eq!(u.app.session.selection(), [a]);
|
||||
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();
|
||||
u.app.run("fill.add", json!({"ids": [a], "paint": {"type": "solid", "color": "#123456"}}));
|
||||
u.frame(vec![]);
|
||||
click_row(&mut u, "123456");
|
||||
assert_eq!(u.app.ui.property_row.as_ref().map(|h| h.index), Some(1));
|
||||
u.app.run("edit.undo", json!({}));
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
assert!(u.app.ui.property_row.is_none(), "its row isn't drawn any more");
|
||||
assert_eq!(u.app.session.selection(), [a]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_beside_the_rows_controls_highlights_it_too() {
|
||||
let (mut u, _, _, _) = setup();
|
||||
// The row's grip, left of the swatch, highlights it (`click_row`); so does the gap between its
|
||||
// colour field and its visibility button (the grid's 8 pt gutter, 60 pt from the row's right).
|
||||
click_row(&mut u, "FF0000");
|
||||
let row = u.app.ui.property_row.as_ref().and_then(|h| h.rect).expect("the highlighted row was drawn");
|
||||
key(&mut u, egui::Key::Escape);
|
||||
assert!(u.app.ui.property_row.is_none());
|
||||
click(&mut u, pos2(row.max.x - 64.0, row.center().y));
|
||||
assert_eq!(u.app.ui.property_row.as_ref().map(|h| (h.kind, h.index)), Some((PropertyKind::Fill, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_on_a_rows_grip_highlights_that_row_for_the_selection() {
|
||||
let (mut u, a, b, _) = setup();
|
||||
for (label, kind) in [("FF0000", PropertyKind::Fill), ("0000FF", PropertyKind::Stroke), ("Layer blur", PropertyKind::Effect)] {
|
||||
click_row(&mut u, label);
|
||||
assert_eq!(
|
||||
u.app.ui.property_row.as_ref().map(|h| (h.kind, h.index, h.ids.clone())),
|
||||
Some((kind, 0, vec![a])),
|
||||
"{label}: one row highlighted, for A"
|
||||
);
|
||||
}
|
||||
// The row is drawn in the selected-row colour.
|
||||
let t = crate::theme::Tokens::get(&u.ctx);
|
||||
assert_eq!(crate::property_rows::fill(&u.app, PropertyKind::Effect, 0, t.selected_row), Some(t.selected_row));
|
||||
assert_eq!(crate::property_rows::fill(&u.app, PropertyKind::Fill, 0, t.selected_row), None);
|
||||
// It belonged to A: another selection lets it go, and A selected again doesn't bring it back.
|
||||
select(&mut u, b);
|
||||
assert!(u.app.ui.property_row.is_none());
|
||||
select(&mut u, a);
|
||||
assert!(u.app.ui.property_row.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_programmatic_paste_after_a_property_copy_whose_engine_copy_is_gone_pastes_no_stale_layers() {
|
||||
let (mut u, a, b, _) = setup();
|
||||
u.app.control_synthetic = true;
|
||||
// A layer copy first, then the fill row's, both programmatic.
|
||||
crate::menus::invoke(&mut u.app, "edit.copy");
|
||||
assert_eq!(u.app.session.clipboard.nodes.len(), 1, "A was copied");
|
||||
crate::property_rows::copy(&mut u.app, PropertyKind::Fill, 0, &[a]);
|
||||
// A stroke copied without its row: the fill is no longer on the clipboard.
|
||||
u.app.run("stroke.copy", json!({"id": a}));
|
||||
u.app.run("select.set", json!({"ids": [b]}));
|
||||
let layers = u.app.session.doc().unwrap().node_count();
|
||||
crate::menus::paste(&mut u.app, None, None);
|
||||
assert_eq!(u.app.session.doc().unwrap().node_count(), layers, "the layer copied before wasn't pasted");
|
||||
assert_eq!(fills(&u, b), ["00FF00"]);
|
||||
assert!(u.app.session.doc().unwrap().node(b).unwrap().strokes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_effect_paste_replaces_the_highlighted_effect_above_the_bottom_one() {
|
||||
let (mut u, a, b, clip) = setup();
|
||||
u.app.run("effect.add", json!({"ids": [b], "type": "innerShadow"}));
|
||||
click_row(&mut u, "Layer blur");
|
||||
copy(&mut u);
|
||||
select(&mut u, b);
|
||||
// B's effects, bottom to top: a drop shadow, an inner shadow (drawn first, at the top).
|
||||
click_row(&mut u, "Inner shadow");
|
||||
assert_eq!(u.app.ui.property_row.as_ref().map(|h| (h.kind, h.index)), Some((PropertyKind::Effect, 1)));
|
||||
paste(&mut u, &clip);
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
let (blur, effects) = (&doc.node(a).unwrap().effects[0], &doc.node(b).unwrap().effects);
|
||||
assert_eq!(effects.len(), 2, "replaced, not added");
|
||||
assert_eq!(effects[1], *blur, "the blur took the inner shadow's place");
|
||||
assert_eq!(effects[0].label(), "Drop shadow", "the drop shadow under it stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_property_copied_in_one_window_pastes_in_another_and_the_highlight_stays_in_its_window() {
|
||||
let mut s = Session::empty();
|
||||
s.execute("file.new", &json!({"name": "A"})).unwrap();
|
||||
let a = rect(&mut s, 0.0, "#FF0000");
|
||||
s.execute("file.new", &json!({"name": "B"})).unwrap();
|
||||
let b = rect(&mut s, 0.0, "#00FF00");
|
||||
let clip = Rc::new(RefCell::new(None));
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_embed_viewports(false);
|
||||
let mut app = crate::FigApp::new(s, Box::new(Clip(clip.clone())));
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.ui(ui)).textures_delta.clear();
|
||||
// File A goes to a window of its own; the main window shows B.
|
||||
let w = crate::control::handle(&mut app, &ctx, "window.new", &json!({"index": 0})).unwrap()["window"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [b]}));
|
||||
let doc = app.session.ds().unwrap().uid;
|
||||
app.ui.property_row = Some(Highlight { kind: PropertyKind::Fill, index: 0, ids: vec![b], doc, rect: None });
|
||||
crate::menus::invoke(&mut app, "edit.copy");
|
||||
assert_eq!(clip.borrow().as_deref(), Some("#00FF00"));
|
||||
let text = clip.borrow().clone();
|
||||
let pasted = crate::windows::as_window(&mut app, w, |app| {
|
||||
assert!(app.ui.property_row.is_none(), "the highlight belongs to the main window");
|
||||
app.run("select.set", json!({"ids": [a]}));
|
||||
crate::menus::paste(app, text.as_deref(), None);
|
||||
app.session.doc().unwrap().node(a).unwrap().fills.iter().map(|p| p.solid_color().unwrap().to_hex()).collect::<Vec<_>>()
|
||||
});
|
||||
let fills = pasted.expect("the window exists");
|
||||
assert_eq!(fills, ["FF0000", "00FF00"], "B's fill went on top of A's");
|
||||
assert!(app.ui.property_row.is_some(), "the main window keeps its highlight");
|
||||
}
|
||||
@@ -433,6 +433,11 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, re
|
||||
if key == Key::Escape && !egui::Popup::is_any_open(ctx) && crate::colorpicker::close_if_open(app) {
|
||||
return true;
|
||||
}
|
||||
// A fill, stroke or effect row highlighted in the Design panel: Escape lets it go, before the
|
||||
// binding deselects the layers.
|
||||
if key == Key::Escape && m.is_none() && !egui::Popup::is_any_open(ctx) && crate::property_rows::escape(app) {
|
||||
return true;
|
||||
}
|
||||
if key_pick(app, key, m) || key_box(app, key, m) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user