mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-12 08:51:11 +00:00
Merge main into #2244
This commit is contained in:
commit
8e42b1a4e2
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);
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Flatten bakes a vector's point corner radius into its path (#2195); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+ShifLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or thLine truncated
|
||||
|
||||
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
|
||||
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
|
||||
@@ -36,6 +36,7 @@ interaction feel not yet compared.
|
||||
| Colour picker image well | partial | the Image tab shows a checkered placeholder and Upload from computer until there is a file (no dialog at once); an image paint shows its asset preview; a file dropped on the open picker replaces the image, keeping the fill mode, crop, position and adjustments (`image.replace`). Layers' fills and strokes only (text ranges, styles and selection colours keep Upload). The Fill and Stroke swatches take no drop: a dragged file's position isn't known, so a drop with none goes to the open picker |
|
||||
| Colour picker on a new fill or stroke | done | + opens the picker on the new paint when the selection shares one paint list; on a mixed selection + adds a paint to each layer and no picker opens (#404) |
|
||||
| Fill section on a text with coloured characters | done | a text's Fill section shows what its characters show: characters coloured differently while editing are mixed content ("Click + to replace mixed content", as a mixed selection; the developer documentation's `fills`: "Text nodes can have multiple sets of fills if some characters are colored differently than others"), and characters that all share fills the layer doesn't have show those. Any layer-level fill edit (a row, its picker, +, reordering, `fill.*`, `node.set`) reaches every character, starting from the list they showed; the characters' own fills go in the same undo step; the canvas gradient and crop handles show and edit the paint the row shows, the characters' own, also when their image is already in Crop mode (`text_mixed_fill_tests`, `canvas::handles::character_fill_tests`, engine `text_fill_tests`; #1856). A range selected while editing keeps its own Fill row |
|
||||
| Fill, stroke and effect rows: click to highlight, Cmd/Ctrl+C, Cmd/Ctrl+V | done | a click on a row's reorder grip (left of its fields) or its background between its controls highlights it in the selected-row colour, one row at a time; Cmd/Ctrl+C (or Edit ▸ Copy) then copies that paint or effect instead of the layers (`fill.copy`, `stroke.copy`, `effect.copy`, as the row's menu does; a stroke takes its weight and settings along) and Cmd/Ctrl+V pastes it onto every selected layer (`fill.paste`, `stroke.paste`, `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 also goes to the system clipboard as its hex code; a programmatic copy never writes it. A later layer copy, or text or an image copied in another app, is pasted instead; every window pastes the same copy. Escape, a press on the canvas or elsewhere in the Design panel, another selection, file or tab let the highlight go (#1494). The help center says only that a property is highlighted with a click; adding on top follows the forum's accepted answer for adding one fill without replacing the others, not an observation. Delete and Cmd/Ctrl+X still act on the layers; the keyboard alone can't highlight a row |
|
||||
| Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas |
|
||||
|
||||
## Layers and Assets panels
|
||||
@@ -199,6 +200,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
| 2026-11-07 | patch | Main menu: a submenu opens on the first frame its row is hovered instead of after 0.15 s (Figma's main menu measured with synthetic pointer events and 1 ms polling: median 11 ms to open, 13 ms to switch rows, no hover delay); the safe-triangle aim is unchanged, though the same measurement saw no aim logic in Figma, left for the maintainer to decide |
|
||||
| 2026-11-10 | patch | M5, #1560: locked layers follow Figma's rules (Lock and unlock layers; measured in Figma's web app): a locked layer selected in the Layers panel ignores the arrow keys and the nudge commands while its properties still edit, and a child of a locked layer keeps its padlock inert (the Layers panel padlock, Lock/Unlock and `object.toggleLock` without a value no longer lock the child on its own) |
|
||||
| 2026-11-10 | patch | #2217: the main menu's submenus (Object, say) ran past the window's bottom, behind the taskbar, with the lowest commands out of reach, because submenus were not constrained to the window (to keep them beside their row). Every menu panel now scrolls when taller than the window less 8 pt margins, and a submenu is held within the window's height (moved up, never above the top) while keeping its place beside its row, so menu-aim is unchanged. Tests: `menu_overflow_tests.rs`. Numbers unchanged. |
|
||||
| 2026-11-09 | minor | M1, #1494: fill, stroke and effect rows in the Design panel couldn't be selected, so the keyboard couldn't copy one (only the row's menu could). A click on a row's grip or background now highlights it (`property_rows`; the row is a click target under its controls and its grip takes a click, neither focusable, so Tab still goes control to control), Cmd/Ctrl+C copies that paint or effect, and Cmd/Ctrl+V pastes it onto the selected layers, on top or in place of their highlighted row of that kind, while it is still the last copy (Copy and paste properties between layers). Escape or a press on the canvas lets the highlight go before the selection. Tests: `property_rows_tests` (22) |
|
||||
| 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` |
|
||||
| 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged |
|
||||
| 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. |
|
||||
|
||||
Reference in new issue
Block a user