mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 05:42:38 +00:00
M1: a click on a fill, stroke or effect row highlights it (#1494)
Fill, stroke and effect rows in the Design panel couldn't be selected.
A click on a row's reorder grip (the strip left of its fields) or on its
background between its controls now highlights it in the selected-row
colour, one row at a time, as the help center's "Copy and paste
properties between layers" describes ("Click to highlight the property
on the right sidebar").
- The row is a click target under its controls (`row_target`), and the
grip of these lists also takes a click (`ListDrag::row_clicked`).
Neither is focusable, so Tab still goes from control to control.
- The highlight belongs to the selection, file and Design tab it was made
on. Escape lets it go before it deselects; so does a press on the
canvas or elsewhere in the Design panel, or a frame its row isn't
drawn in (an undo took its paint away). It follows its paint or effect
when rows are reordered or removed.
Tests: property_rows_tests (7).
This commit is contained in:
9 files changed
+390
-17
No files matched your search
@@ -296,6 +296,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;
|
||||
|
||||
@@ -226,6 +226,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)]
|
||||
@@ -589,6 +592,8 @@ 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.
|
||||
pub property_row: Option<property_rows::Highlight>,
|
||||
/// 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>,
|
||||
@@ -732,6 +737,7 @@ impl Default for UiState {
|
||||
|
||||
last_copy: None,
|
||||
last_copy_generation: 0,
|
||||
property_row: None,
|
||||
after_field: None,
|
||||
held: egui::Modifiers::NONE,
|
||||
next_autosave: 0.0,
|
||||
|
||||
@@ -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 (`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,6 +67,8 @@ 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"), "") {
|
||||
@@ -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 ([`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,18 +544,22 @@ 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);
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Fill, stroke and effect rows of the Design panel that highlight, the first step of the help
|
||||
//! center's "Copy and paste properties between layers": click to highlight the property in the
|
||||
//! right sidebar.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
use egui::{Color32, Context, Rect, Response};
|
||||
use figcraft_doc::NodeId;
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// 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 a menu command can still act on it. 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! A fill, stroke or effect row of the Design panel highlights on a click on its background or its
|
||||
//! grip ("Copy and paste properties between layers": click to highlight the property in the right
|
||||
//! sidebar).
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/// 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![]);
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
#[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 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());
|
||||
}
|
||||
@@ -428,6 +428,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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user