mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
M8: a large selection's overlay and Design panel values are kept for frames where nothing changed (#1611)
* M8: a large selection's overlay and Design panel values are kept for frames where nothing changed (#1571) The selection's outlines, box, size and slots on the canvas, and the Design panel's instance checks and position values, are kept with the document snapshot (held weakly), the selection and, for the overlay, the view's mapping to the screen and the overlay green, and made again when any changes. Dragging 648 selected layers: overlays 1.99 to 0.86 ms a frame, Design panel 2.54 to 1.40 ms. * M8: split the position fields into one function per row (Sonar S3776)
This commit is contained in:
+2
-1
File diff suppressed because one or more lines are too long
@@ -374,6 +374,9 @@ mod builder_poly_tests;
|
||||
#[cfg(test)]
|
||||
mod outline_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod selection_cache_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod wheel_zoom_tests;
|
||||
|
||||
|
||||
@@ -71,7 +71,30 @@ pub(super) fn quad(painter: &egui::Painter, q: [Pos2; 4], s: Stroke) {
|
||||
/// The slots of the selected components and instances (and a slot being dropped onto): a dashed
|
||||
/// outline and the slot's name above its top-left corner, like a frame label (never over the
|
||||
/// content).
|
||||
pub(super) fn slot_badges(app: &FigApp, painter: &egui::Painter, doc: &Document, sel: &[NodeId], drop: Option<NodeId>) {
|
||||
pub(super) fn slot_badges(app: &FigApp, painter: &egui::Painter, doc: &Arc<Document>, sel: &[NodeId], drop: Option<NodeId>) {
|
||||
let key = [drop.map_or(0, |d| d.0.wrapping_add(1))];
|
||||
let slots = crate::selection_cache::cached(painter.ctx(), "slots", doc, sel, &key, || slots_shown(doc, sel, drop));
|
||||
for (id, name) in slots {
|
||||
let Some(q) = node_quad(app, doc, id) else { continue };
|
||||
if !Rect::from_points(&q).intersects(app.canvas.rect) {
|
||||
continue;
|
||||
}
|
||||
painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(COMPONENT, 1.0), 4.0, 3.0));
|
||||
// The label, when the slot is wide enough on screen to hold it.
|
||||
let galley = painter.layout_no_wrap(name, regular(10.0), COMPONENT);
|
||||
let (w, h) = (galley.size().x + 14.0, galley.size().y.max(11.0));
|
||||
if (q[1] - q[0]).length() < w {
|
||||
continue;
|
||||
}
|
||||
let at = q[0] - vec2(0.0, h + 2.0);
|
||||
crate::icons::paint(painter, Rect::from_min_size(pos2(at.x, at.y + (h - 11.0) / 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Slot, COMPONENT);
|
||||
painter.galley(pos2(at.x + 14.0, at.y + (h - galley.size().y) / 2.0), galley, COMPONENT);
|
||||
}
|
||||
}
|
||||
|
||||
/// The slots [`slot_badges`] shows, with their names: those of the selected components and
|
||||
/// instances (and of the ones the selected layers are in), and the slot being dropped onto.
|
||||
fn slots_shown(doc: &Document, sel: &[NodeId], drop: Option<NodeId>) -> Vec<(NodeId, String)> {
|
||||
use figcraft_engine::components::{owning_component, owning_instance, slot_name, slots_of};
|
||||
let mut roots: Vec<NodeId> = Vec::new();
|
||||
for id in sel {
|
||||
@@ -90,22 +113,7 @@ pub(super) fn slot_badges(app: &FigApp, painter: &egui::Painter, doc: &Document,
|
||||
{
|
||||
slots.push((d, name));
|
||||
}
|
||||
for (id, name) in slots {
|
||||
let Some(q) = node_quad(app, doc, id) else { continue };
|
||||
if !Rect::from_points(&q).intersects(app.canvas.rect) {
|
||||
continue;
|
||||
}
|
||||
painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(COMPONENT, 1.0), 4.0, 3.0));
|
||||
// The label, when the slot is wide enough on screen to hold it.
|
||||
let galley = painter.layout_no_wrap(name, regular(10.0), COMPONENT);
|
||||
let (w, h) = (galley.size().x + 14.0, galley.size().y.max(11.0));
|
||||
if (q[1] - q[0]).length() < w {
|
||||
continue;
|
||||
}
|
||||
let at = q[0] - vec2(0.0, h + 2.0);
|
||||
crate::icons::paint(painter, Rect::from_min_size(pos2(at.x, at.y + (h - 11.0) / 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Slot, COMPONENT);
|
||||
painter.galley(pos2(at.x + 14.0, at.y + (h - galley.size().y) / 2.0), galley, COMPONENT);
|
||||
}
|
||||
slots
|
||||
}
|
||||
|
||||
/// The instance an Alt-drop of a component from the Assets panel swaps: tinted and outlined in
|
||||
@@ -197,16 +205,13 @@ fn outline_shape(app: &FigApp, doc: &Document, id: NodeId) -> Option<Vec<Vec<Pos
|
||||
|
||||
/// A layer's outline on the canvas: its shape where [`outline_path`] has one, its box otherwise.
|
||||
pub(super) fn draw_outline(app: &FigApp, painter: &egui::Painter, doc: &Document, id: NodeId, s: Stroke) {
|
||||
painter.extend(outline_shapes(app, doc, id, s));
|
||||
}
|
||||
|
||||
/// The shapes [`draw_outline`] draws.
|
||||
pub(super) fn outline_shapes(app: &FigApp, doc: &Document, id: NodeId, s: Stroke) -> Vec<Shape> {
|
||||
match outline_shape(app, doc, id) {
|
||||
Some(lines) => {
|
||||
for l in lines {
|
||||
painter.add(Shape::line(l, s));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Some(q) = node_quad(app, doc, id) {
|
||||
quad(painter, q, s);
|
||||
}
|
||||
}
|
||||
Some(lines) => lines.into_iter().map(|l| Shape::line(l, s)).collect(),
|
||||
None => node_quad(app, doc, id).map(|q| Shape::closed_line(q.to_vec(), s)).into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::builder::{builder_overlay, hatched, region_hatch};
|
||||
use super::outline::{TextOnScreen, draw_outline, node_quad, outline_colour, quad, slot_badges, stroke, swap_highlight};
|
||||
use super::outline::{TextOnScreen, draw_outline, node_quad, outline_colour, outline_shapes, quad, slot_badges, stroke, swap_highlight};
|
||||
use super::rulers::{boards, fmt, rulers};
|
||||
use super::{
|
||||
Align2, COMPONENT, Color32, CornerRadius, Document, FigApp, NodeId, NodeKind, Point, Pos2, Rect, Shape, Stroke, StrokeKind, Tokens, ToolId,
|
||||
@@ -234,20 +236,51 @@ fn text_edit_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Do
|
||||
painter.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
}
|
||||
|
||||
/// What the selection's overlay draws that only the document, the selection, the view and the
|
||||
/// overlay colour decide: kept for the frames that show the same ([`crate::selection_cache`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct SelectionGeometry {
|
||||
/// The outline colour: the component colour when any selected layer is in one.
|
||||
pub(super) col: Color32,
|
||||
/// Every selected layer's outline on screen.
|
||||
pub(super) outlines: Arc<Vec<Shape>>,
|
||||
/// The transform box (page space) and its width and height.
|
||||
bbox: Option<[Point; 4]>,
|
||||
size: Option<(f64, f64)>,
|
||||
/// A selected layer is locked (no handles).
|
||||
locked: bool,
|
||||
}
|
||||
|
||||
pub(super) fn selection_geometry(app: &FigApp, painter: &egui::Painter, doc: &Arc<Document>, sel: &[NodeId]) -> SelectionGeometry {
|
||||
let green = accent(painter);
|
||||
let mapping = [app.to_screen(0.0, 0.0), app.to_screen(1.0, 1.0)].map(crate::selection_cache::bits);
|
||||
let extra = [mapping[0][0], mapping[0][1], mapping[1][0], mapping[1][1], u64::from(u32::from_le_bytes(green.to_array()))];
|
||||
crate::selection_cache::cached(painter.ctx(), "selection overlay", doc, sel, &extra, || {
|
||||
let col = if sel.iter().any(|i| outline_colour(doc, *i, green) == COMPONENT) { COMPONENT } else { green };
|
||||
let outlines = sel.iter().flat_map(|id| outline_shapes(app, doc, *id, stroke(col, 1.0))).collect();
|
||||
SelectionGeometry {
|
||||
col,
|
||||
outlines: Arc::new(outlines),
|
||||
bbox: selection_box(doc, sel),
|
||||
size: selection_frame(doc, sel).map(|(_, w, h)| (w, h)),
|
||||
locked: sel.iter().any(|i| figcraft_engine::tools::locked_in(doc, *i)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The selection's outlines, transform box and handles; returns the size label's box.
|
||||
fn selection_overlay(
|
||||
app: &FigApp,
|
||||
painter: &egui::Painter,
|
||||
ds: &figcraft_engine::DocState,
|
||||
doc: &Document,
|
||||
doc: &Arc<Document>,
|
||||
sel: &[NodeId],
|
||||
zoom: f64,
|
||||
) -> Option<Rect> {
|
||||
let mut size_label = None;
|
||||
let col = if sel.iter().any(|i| outline_colour(doc, *i, accent(painter)) == COMPONENT) { COMPONENT } else { accent(painter) };
|
||||
for id in sel {
|
||||
draw_outline(app, painter, doc, *id, stroke(col, 1.0));
|
||||
}
|
||||
let geo = selection_geometry(app, painter, doc, sel);
|
||||
let col = geo.col;
|
||||
painter.extend(geo.outlines.iter().cloned());
|
||||
// Constraint guides belong to the normal selection overlay: not while the Shape Builder
|
||||
// reshapes by regions, nor while measuring to another layer (Alt), whose lines take over.
|
||||
if app.session.tool.tool != ToolId::ShapeBuilder && app.session.tool.overlay.measure_box.is_none() {
|
||||
@@ -255,8 +288,8 @@ fn selection_overlay(
|
||||
}
|
||||
// The shape builder reshapes by regions: no transform box.
|
||||
if app.session.tool.tool != ToolId::ShapeBuilder
|
||||
&& let Some(b) = selection_box(doc, sel)
|
||||
&& let Some((_, w, h)) = selection_frame(doc, sel)
|
||||
&& let Some(b) = geo.bbox
|
||||
&& let Some((w, h)) = geo.size
|
||||
{
|
||||
let is_line = sel.len() == 1 && matches!(sel.first().and_then(|i| doc.node(*i)).map(|n| &n.kind), Some(NodeKind::Line));
|
||||
// A selected connector's handles are its two ends (drag one onto another object).
|
||||
@@ -275,12 +308,7 @@ fn selection_overlay(
|
||||
// Mode: no handles either.
|
||||
// Crop mode draws its own marks on the box instead (`handles::crop_marks`).
|
||||
let cropping = app.ui.crop.is_some();
|
||||
let handles = if cropping
|
||||
|| measuring
|
||||
|| app.session.tool.dev_mode
|
||||
|| connector.is_some()
|
||||
|| sel.iter().any(|i| figcraft_engine::tools::locked_in(doc, *i))
|
||||
{
|
||||
let handles = if cropping || measuring || app.session.tool.dev_mode || connector.is_some() || geo.locked {
|
||||
0
|
||||
} else if is_line {
|
||||
2
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! The selection overlay's geometry and the Design panel's values are kept for the frames that
|
||||
//! show the same (#1571), made again on every input they show (an edit, the selection, zoom, pan,
|
||||
//! the canvas's place, the theme's overlay green), and the same as when made afresh; what they
|
||||
//! don't keep (the hover and drop-target highlight) shows on the next frame.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Color32, Context, LayerId, Painter, Rect, pos2, vec2};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::overlays::{SelectionGeometry, overlays, selection_geometry};
|
||||
use crate::theme::{Tokens, set_canvas_page};
|
||||
use crate::{FigApp, NoServices};
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// Three rectangles (the middle one rounded, outlined along its corners) selected, and a fourth
|
||||
/// that isn't, on a light page.
|
||||
fn setup() -> (FigApp, Context, Vec<NodeId>) {
|
||||
let mut s = Session::new();
|
||||
let mut ids = Vec::new();
|
||||
for x in [0, 80, 160, 240] {
|
||||
ids.push(id(s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 50, "height": 50})).unwrap()));
|
||||
}
|
||||
let radii = json!({"topLeftRadius": 12, "topRightRadius": 12, "bottomRightRadius": 12, "bottomLeftRadius": 12});
|
||||
s.execute("node.set", &json!({"id": ids[1], "props": radii})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": ids[..3]})).unwrap();
|
||||
let mut app = FigApp::new(s, Box::new(NoServices));
|
||||
app.canvas.rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0));
|
||||
let ctx = Context::default();
|
||||
set_canvas_page(&ctx, Color32::WHITE);
|
||||
(app, ctx, ids)
|
||||
}
|
||||
|
||||
fn geometry(app: &FigApp, ctx: &Context) -> SelectionGeometry {
|
||||
let ds = app.session.ds().unwrap();
|
||||
selection_geometry(app, &Painter::new(ctx.clone(), LayerId::background(), Rect::EVERYTHING), &ds.doc.clone(), &ds.selection.clone())
|
||||
}
|
||||
|
||||
/// The geometry made with nothing kept, on a page of colour `page`.
|
||||
fn fresh(app: &FigApp, page: Color32) -> SelectionGeometry {
|
||||
let ctx = Context::default();
|
||||
set_canvas_page(&ctx, page);
|
||||
geometry(app, &ctx)
|
||||
}
|
||||
|
||||
fn kept(a: &SelectionGeometry, b: &SelectionGeometry) -> bool {
|
||||
Arc::ptr_eq(&a.outlines, &b.outlines)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unchanged_frame_reuses_the_selection_geometry() {
|
||||
let (app, ctx, _) = setup();
|
||||
let first = geometry(&app, &ctx);
|
||||
assert_eq!(first.outlines.len(), 3);
|
||||
assert!(kept(&first, &geometry(&app, &ctx)));
|
||||
assert_eq!(first, fresh(&app, Color32::WHITE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_value_does_not_keep_its_document_alive() {
|
||||
let (app, ctx, _) = setup();
|
||||
let doc = app.session.ds().unwrap().doc.clone();
|
||||
let holders = Arc::strong_count(&doc);
|
||||
geometry(&app, &ctx);
|
||||
assert_eq!(Arc::strong_count(&doc), holders);
|
||||
}
|
||||
|
||||
type Change = Box<dyn Fn(&mut FigApp, &Context)>;
|
||||
|
||||
#[test]
|
||||
fn every_input_the_geometry_shows_makes_it_again() {
|
||||
let (mut app, ctx, ids) = setup();
|
||||
let (a, b) = (ids[0], ids[1]);
|
||||
let changes: Vec<(&str, Change)> = vec![
|
||||
(
|
||||
"an edit",
|
||||
Box::new(move |app: &mut FigApp, _: &Context| {
|
||||
app.set_props(&[b], json!({"x": 90}));
|
||||
}),
|
||||
),
|
||||
(
|
||||
"the selection",
|
||||
Box::new(move |app: &mut FigApp, _: &Context| {
|
||||
app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
}),
|
||||
),
|
||||
("zoom", Box::new(|app: &mut FigApp, _: &Context| app.session.ds_mut().unwrap().view.zoom *= 2.0)),
|
||||
("pan", Box::new(|app: &mut FigApp, _: &Context| app.session.ds_mut().unwrap().view.cx += 30.0)),
|
||||
("the canvas moving", Box::new(|app: &mut FigApp, _: &Context| app.canvas.rect = app.canvas.rect.translate(vec2(40.0, 0.0)))),
|
||||
("the theme", Box::new(|_: &mut FigApp, ctx: &Context| set_canvas_page(ctx, Color32::BLACK))),
|
||||
];
|
||||
let mut page = Color32::WHITE;
|
||||
for (what, change) in changes {
|
||||
let before = geometry(&app, &ctx);
|
||||
change(&mut app, &ctx);
|
||||
if what == "the theme" {
|
||||
page = Color32::BLACK;
|
||||
assert_ne!(crate::theme::canvas_accent(&ctx), before.col, "a dark page takes the other green");
|
||||
}
|
||||
let after = geometry(&app, &ctx);
|
||||
assert!(!kept(&before, &after), "{what}");
|
||||
assert_ne!(before, after, "{what}");
|
||||
assert_eq!(after, fresh(&app, page), "{what}: as if nothing were kept");
|
||||
assert!(kept(&after, &geometry(&app, &ctx)), "{what}: kept again");
|
||||
}
|
||||
}
|
||||
|
||||
/// How many shapes one frame of the canvas overlays draws.
|
||||
fn drawn(app: &FigApp, ctx: &Context) -> usize {
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
overlays(app, &ui.ctx().layer_painter(LayerId::background()), &Tokens::light());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out.shapes.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hover_and_drop_highlights_show_over_kept_geometry() {
|
||||
let (mut app, ctx, ids) = setup();
|
||||
let other = ids[3];
|
||||
let plain = drawn(&app, &ctx);
|
||||
let first = geometry(&app, &ctx);
|
||||
app.session.ds_mut().unwrap().hover = Some(other);
|
||||
assert_eq!(drawn(&app, &ctx), plain + 1, "the hovered layer's outline");
|
||||
assert!(kept(&first, &geometry(&app, &ctx)), "the hover isn't part of the selection's geometry");
|
||||
app.session.ds_mut().unwrap().hover = None;
|
||||
app.session.tool.overlay.drop_target = Some(other);
|
||||
assert_eq!(drawn(&app, &ctx), plain + 1, "the drop target's outline");
|
||||
app.session.tool.overlay.drop_target = None;
|
||||
assert_eq!(drawn(&app, &ctx), plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_design_panels_instance_facts_follow_edits_and_the_selection() {
|
||||
let mut s = Session::new();
|
||||
let f = id(s.execute("node.create", &json!({"type": "frame", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
id(s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 10, "width": 20, "height": 20, "parent": f})).unwrap());
|
||||
let main = id(s.execute("component.create", &json!({"ids": [f]})).unwrap());
|
||||
let inst = id(s.execute("instance.create", &json!({"component": main, "x": 300, "y": 0})).unwrap());
|
||||
let child = s.doc().unwrap().node(inst).unwrap().children[0];
|
||||
let ctx = Context::default();
|
||||
let facts = |s: &Session| {
|
||||
let ds = s.ds().unwrap();
|
||||
crate::right::in_instance(&ctx, &ds.doc.clone(), &ds.selection.clone())
|
||||
};
|
||||
let facts_of = |s: &mut Session, ids: Value| {
|
||||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
let f = facts(s);
|
||||
(f.any, f.nested)
|
||||
};
|
||||
assert_eq!(facts_of(&mut s, json!([main])), (false, false));
|
||||
assert_eq!(facts_of(&mut s, json!([inst])), (true, false));
|
||||
assert_eq!(facts_of(&mut s, json!([inst, child])), (true, true));
|
||||
assert_eq!(facts_of(&mut s, json!([inst])), (true, false));
|
||||
s.execute("component.detach", &json!({"ids": [inst]})).unwrap();
|
||||
assert!(s.doc().unwrap().node(inst).is_some(), "detaching keeps the layer");
|
||||
assert_eq!(s.ds().unwrap().selection, vec![inst], "and the selection");
|
||||
assert_eq!(facts(&s), crate::right::InInstance { any: false, nested: false }, "an edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_position_fields_follow_edits_and_the_selection() {
|
||||
let (mut app, ctx, ids) = setup();
|
||||
let x = |app: &FigApp| {
|
||||
let ds = app.session.ds().unwrap();
|
||||
crate::right::position_values(&ctx, &ds.doc.clone(), &ds.selection.clone()).x
|
||||
};
|
||||
assert_eq!(x(&app), None, "mixed");
|
||||
app.session.execute("select.set", &json!({"ids": [ids[1]]})).unwrap();
|
||||
assert_eq!(x(&app), Some(80.0), "the selection");
|
||||
app.set_props(&[ids[1]], json!({"x": 90}));
|
||||
assert_eq!(x(&app), Some(90.0), "an edit");
|
||||
}
|
||||
@@ -307,6 +307,7 @@ pub use control::{ControlReply, ControlRequest, RequestGate, RequestState};
|
||||
mod app;
|
||||
mod clock;
|
||||
mod open;
|
||||
mod selection_cache;
|
||||
mod services;
|
||||
mod state;
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! alignment in its cell, and clip content. Everything goes through `node.set` props or `layout.*`
|
||||
//! commands.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use figcraft_doc::{AutoLayout, CellAlign, ContentAlign, CrossAlign, Document, LayoutMode, MainAlign, Node, NodeId, NodeKind, Sizing};
|
||||
use serde_json::json;
|
||||
@@ -25,7 +27,7 @@ fn parent_of(doc: &Document, id: NodeId) -> Option<&Node> {
|
||||
|
||||
/// Show selection sizing and layout controls; offer Add only when every selected container has
|
||||
/// no auto layout and the selection is editable outside an instance.
|
||||
pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, sel: &[NodeId]) {
|
||||
let ids = sel.to_vec();
|
||||
let frames = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like()));
|
||||
let layout_containers = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Group)));
|
||||
@@ -35,8 +37,8 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node
|
||||
let title = if has_al { tr("Auto layout") } else { tr("Layout") };
|
||||
// An instance's auto layout is its main's (only padding, gap and alignment are its own); the
|
||||
// layers inside it override their size and resizing, not their place in the layout.
|
||||
let in_instance = sel.iter().any(|i| figcraft_engine::components::owning_instance(doc, *i).is_some());
|
||||
let nested = sel.iter().any(|i| figcraft_engine::components::inside_instance(doc, *i));
|
||||
let facts = crate::right::in_instance(ui.ctx(), doc, sel);
|
||||
let (in_instance, nested) = (facts.any, facts.nested);
|
||||
let btns: Vec<(Icon, &str)> = if in_instance {
|
||||
vec![]
|
||||
} else if layout_containers && no_al {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! The right panel: Design, Prototype and Inspect tabs.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use figcraft_color::{BlendMode, Paint, PaintKind};
|
||||
use figcraft_doc::{
|
||||
@@ -274,7 +276,7 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
|
||||
crate::layout_panel::layout_section(app, ui, &doc, &sel);
|
||||
widgets::separator(ui);
|
||||
}
|
||||
if vector_edit_none && (arc_section(app, ui, &doc, &sel) || points_section(app, ui, &doc, &sel)) {
|
||||
if vector_edit_none && arc_section(app, ui, &doc, &sel) || points_section(app, ui, &doc, &sel) {
|
||||
widgets::separator(ui);
|
||||
}
|
||||
appearance(app, ui, &doc, &sel);
|
||||
@@ -500,21 +502,95 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
crate::component_panel::instance_section(app, ui, doc, sel);
|
||||
}
|
||||
|
||||
fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
/// Whether a selected layer is an instance or in one, and whether one is inside an instance (not
|
||||
/// the instance itself): the Design panel asks every frame, kept while the document and the
|
||||
/// selection stay the same ([`crate::selection_cache`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct InInstance {
|
||||
pub any: bool,
|
||||
pub nested: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn in_instance(ctx: &egui::Context, doc: &Arc<Document>, sel: &[NodeId]) -> InInstance {
|
||||
use figcraft_engine::components::{inside_instance, owning_instance};
|
||||
crate::selection_cache::cached(ctx, "in instance", doc, sel, &[], || InInstance {
|
||||
any: sel.iter().any(|i| owning_instance(doc, *i).is_some()),
|
||||
nested: sel.iter().any(|i| inside_instance(doc, *i)),
|
||||
})
|
||||
}
|
||||
|
||||
fn position(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, sel: &[NodeId]) {
|
||||
widgets::section_header(ui, tr("Position"), &[], false);
|
||||
// Layers inside an instance take their place and constraints from the main component.
|
||||
let nested = sel.iter().any(|i| figcraft_engine::components::inside_instance(doc, *i));
|
||||
let nested = in_instance(ui.ctx(), doc, sel).nested;
|
||||
ui.add_enabled_ui(!nested, |ui| position_fields(app, ui, doc, sel))
|
||||
.response
|
||||
.on_disabled_hover_text(tr("Layers in an instance take their position from the main component"));
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
|
||||
/// What the position fields show of the selection (`None`: mixed), kept like [`in_instance`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct PositionValues {
|
||||
pub can_align: bool,
|
||||
pub x: Option<f64>,
|
||||
pub y: Option<f64>,
|
||||
pub rot: Option<f64>,
|
||||
/// Every selected layer is in an auto layout frame, and whether they ignore its layout.
|
||||
pub parent_al: bool,
|
||||
pub absolute: Option<bool>,
|
||||
/// Every selected layer is in a frame that places it by constraints, and those constraints.
|
||||
pub in_frame: bool,
|
||||
pub constraints: Option<figcraft_doc::Constraints>,
|
||||
}
|
||||
|
||||
/// What the X field shows of a layer (in its frame's space: groups have none of their own); a
|
||||
/// relative edit of mixed values starts from it.
|
||||
fn x_of(doc: &Document, n: &Node) -> f64 {
|
||||
(doc.frame_relative(n.id).as_coeffs()[4] * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
fn y_of(doc: &Document, n: &Node) -> f64 {
|
||||
(doc.frame_relative(n.id).as_coeffs()[5] * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
fn rot_of(n: &Node) -> f64 {
|
||||
(figcraft_geom::rotation_degrees(n.transform) * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
pub(crate) fn position_values(ctx: &egui::Context, doc: &Arc<Document>, sel: &[NodeId]) -> PositionValues {
|
||||
let parent = |n: &Node| n.parent.and_then(|p| doc.node(p));
|
||||
crate::selection_cache::cached(ctx, "position", doc, sel, &[], || PositionValues {
|
||||
can_align: figcraft_engine::cmd::arrange::can_align(doc, sel),
|
||||
x: common(doc, sel, |n| x_of(doc, n)),
|
||||
y: common(doc, sel, |n| y_of(doc, n)),
|
||||
rot: common(doc, sel, rot_of),
|
||||
parent_al: sel.iter().all(|i| doc.node(*i).and_then(parent).is_some_and(|p| p.layout.is_some())),
|
||||
absolute: common(doc, sel, |n| n.child_layout.absolute),
|
||||
// Constraints: children of frames without auto layout, or ignoring it.
|
||||
in_frame: sel.iter().all(|i| {
|
||||
doc.node(*i).is_some_and(|n| parent(n).is_some_and(|p| p.kind.is_frame_like() && (p.layout.is_none() || n.child_layout.absolute)))
|
||||
}),
|
||||
constraints: common(doc, sel, |n| n.constraints),
|
||||
})
|
||||
}
|
||||
|
||||
/// Draw alignment and distribution, X/Y and rotation with flip/rotate controls, the
|
||||
/// absolute-position toggle and frame constraints.
|
||||
fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
let ids = sel.to_vec();
|
||||
fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, sel: &[NodeId]) {
|
||||
let pv = position_values(ui.ctx(), doc, sel);
|
||||
let col = widgets::col_width(ui.available_width());
|
||||
align_row(app, ui, sel, pv.can_align, col);
|
||||
position_row(app, ui, doc, sel, &pv, col);
|
||||
rotation_row(app, ui, sel, pv.rot, col);
|
||||
if pv.in_frame {
|
||||
constraints_row(app, ui, sel, pv.constraints, col);
|
||||
}
|
||||
}
|
||||
|
||||
/// Alignment, and distribution and tidy up for three or more layers.
|
||||
fn align_row(app: &mut FigApp, ui: &mut Ui, sel: &[NodeId], can_align: bool, col: f32) {
|
||||
let ids = sel.to_vec();
|
||||
widgets::sublabels(ui, tr("Alignment"), None);
|
||||
row(ui, 32.0, |ui| {
|
||||
let cmds = ["align.left", "align.hcenter", "align.right", "align.top", "align.vcenter", "align.bottom"];
|
||||
@@ -528,7 +604,7 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]
|
||||
(Icon::AlignBottom, tip(tr("Align bottom"), cmds[5])),
|
||||
];
|
||||
let w = col / 3.0;
|
||||
let picked = ui.add_enabled_ui(figcraft_engine::cmd::arrange::can_align(doc, sel), |ui| {
|
||||
let picked = ui.add_enabled_ui(can_align, |ui| {
|
||||
let h = widgets::segmented(ui, &items[..3], None, w);
|
||||
let v = widgets::segmented(ui, &items[3..], None, w).map(|i| i + 3);
|
||||
h.or(v)
|
||||
@@ -553,23 +629,20 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]
|
||||
});
|
||||
}
|
||||
});
|
||||
// In the frame's space: groups have none of their own.
|
||||
// What each field shows of a layer (a relative edit of mixed values starts from it).
|
||||
let x_of = |n: &Node| (doc.frame_relative(n.id).as_coeffs()[4] * 100.0).round() / 100.0;
|
||||
let y_of = |n: &Node| (doc.frame_relative(n.id).as_coeffs()[5] * 100.0).round() / 100.0;
|
||||
let rot_of = |n: &Node| (figcraft_geom::rotation_degrees(n.transform) * 100.0).round() / 100.0;
|
||||
let (x, y, rot) = (common(doc, sel, x_of), common(doc, sel, y_of), common(doc, sel, rot_of));
|
||||
let parent_al = sel.iter().all(|i| doc.node(*i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()));
|
||||
let absolute = common(doc, sel, |n| n.child_layout.absolute);
|
||||
}
|
||||
|
||||
/// X and Y, and the absolute-position toggle for children of auto layout frames.
|
||||
fn position_row(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], pv: &PositionValues, col: f32) {
|
||||
let ids = sel.to_vec();
|
||||
widgets::sublabels(ui, tr("Position"), None);
|
||||
row(ui, 32.0, |ui| {
|
||||
let e = widgets::number_field_mixed(ui, Id::unique("pos_x"), Lead::Text("X"), x, "", col, 1.0);
|
||||
app.field_of(&ids, "x", e, x_of, |v| json!(v));
|
||||
let e = widgets::number_field_mixed(ui, Id::unique("pos_y"), Lead::Text("Y"), y, "", col, 1.0);
|
||||
app.field_of(&ids, "y", e, y_of, |v| json!(v));
|
||||
let e = widgets::number_field_mixed(ui, Id::unique("pos_x"), Lead::Text("X"), pv.x, "", col, 1.0);
|
||||
app.field_of(&ids, "x", e, |n| x_of(doc, n), |v| json!(v));
|
||||
let e = widgets::number_field_mixed(ui, Id::unique("pos_y"), Lead::Text("Y"), pv.y, "", col, 1.0);
|
||||
app.field_of(&ids, "y", e, |n| y_of(doc, n), |v| json!(v));
|
||||
// Children of auto layout frames can ignore the layout (absolute position).
|
||||
if parent_al {
|
||||
let on = absolute.unwrap_or(false);
|
||||
if pv.parent_al {
|
||||
let on = pv.absolute.unwrap_or(false);
|
||||
if icons::button(
|
||||
ui,
|
||||
Icon::AbsolutePosition,
|
||||
@@ -583,6 +656,11 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Rotation, with the rotate and flip buttons.
|
||||
fn rotation_row(app: &mut FigApp, ui: &mut Ui, sel: &[NodeId], rot: Option<f64>, col: f32) {
|
||||
let ids = sel.to_vec();
|
||||
widgets::sublabels(ui, tr("Rotation"), None);
|
||||
row(ui, 32.0, |ui| {
|
||||
let e = widgets::number_field_mixed(ui, Id::unique("pos_rot"), Lead::Icon(Icon::Angle, tr("Rotation")), rot, "°", col, 1.0);
|
||||
@@ -596,50 +674,38 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]
|
||||
app.run(["object.rotateRight", "object.flipH", "object.flipV"][i], json!({"ids": ids}));
|
||||
}
|
||||
});
|
||||
// Constraints (children of frames without auto layout, or ignoring it).
|
||||
let in_frame = sel.iter().all(|i| {
|
||||
doc.node(*i).is_some_and(|n| {
|
||||
n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like() && (p.layout.is_none() || n.child_layout.absolute))
|
||||
})
|
||||
});
|
||||
if in_frame {
|
||||
let c = common(doc, sel, |n| n.constraints);
|
||||
widgets::sublabels(ui, tr("Constraints"), None);
|
||||
row(ui, 32.0, |ui| {
|
||||
let h = c.map(|c| format!("{:?}", c.horizontal)).unwrap_or_else(|| "Mixed".into());
|
||||
let resp = widgets::dropdown(ui, tr(&pretty(&h)), col, None);
|
||||
menu_popup(&resp).show(|ui| {
|
||||
for (l, v) in [
|
||||
(tr("Left"), "left"),
|
||||
(tr("Right"), "right"),
|
||||
(tr("Left & right"), "leftRight"),
|
||||
(tr("Center"), "center"),
|
||||
(tr("Scale"), "scale"),
|
||||
] {
|
||||
if menu_item(ui, l, "") {
|
||||
app.set_props(&ids, json!({"constraints": {"horizontal": v}}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// The horizontal and vertical constraints of children of frames without auto layout.
|
||||
fn constraints_row(app: &mut FigApp, ui: &mut Ui, sel: &[NodeId], c: Option<figcraft_doc::Constraints>, col: f32) {
|
||||
let ids = sel.to_vec();
|
||||
widgets::sublabels(ui, tr("Constraints"), None);
|
||||
row(ui, 32.0, |ui| {
|
||||
let h = c.map(|c| format!("{:?}", c.horizontal)).unwrap_or_else(|| "Mixed".into());
|
||||
let resp = widgets::dropdown(ui, tr(&pretty(&h)), col, None);
|
||||
menu_popup(&resp).show(|ui| {
|
||||
for (l, v) in
|
||||
[(tr("Left"), "left"), (tr("Right"), "right"), (tr("Left & right"), "leftRight"), (tr("Center"), "center"), (tr("Scale"), "scale")]
|
||||
{
|
||||
if menu_item(ui, l, "") {
|
||||
app.set_props(&ids, json!({"constraints": {"horizontal": v}}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
let v = c.map(|c| format!("{:?}", c.vertical)).unwrap_or_else(|| "Mixed".into());
|
||||
let resp = widgets::dropdown(ui, tr(&pretty(&v)), col, None);
|
||||
menu_popup(&resp).show(|ui| {
|
||||
for (l, val) in [
|
||||
(tr("Top"), "top"),
|
||||
(tr("Bottom"), "bottom"),
|
||||
(tr("Top & bottom"), "topBottom"),
|
||||
(tr("Center"), "center"),
|
||||
(tr("Scale"), "scale"),
|
||||
] {
|
||||
if menu_item(ui, l, "") {
|
||||
app.set_props(&ids, json!({"constraints": {"vertical": val}}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
let v = c.map(|c| format!("{:?}", c.vertical)).unwrap_or_else(|| "Mixed".into());
|
||||
let resp = widgets::dropdown(ui, tr(&pretty(&v)), col, None);
|
||||
menu_popup(&resp).show(|ui| {
|
||||
for (l, val) in
|
||||
[(tr("Top"), "top"), (tr("Bottom"), "bottom"), (tr("Top & bottom"), "topBottom"), (tr("Center"), "center"), (tr("Scale"), "scale")]
|
||||
{
|
||||
if menu_item(ui, l, "") {
|
||||
app.set_props(&ids, json!({"constraints": {"vertical": val}}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The selected ellipses' arc: Start (degrees), Sweep (a share of a full turn, negative running
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Values worked out from the document and the selection that several frames show unchanged
|
||||
//! (#1571): a pointer move draws about three frames, and a large selection's outlines, slots and
|
||||
//! mixed values cost milliseconds each time. A value is kept with the document snapshot it was
|
||||
//! made from (compared by pointer: an edit is a new snapshot), the selection and whatever else it
|
||||
//! depends on (`extra`: the view's mapping to the screen, a colour…), and made again when any
|
||||
//! differs.
|
||||
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
|
||||
/// One kept value and what it was made from. The document is held weakly, so a kept value never
|
||||
/// keeps an old snapshot alive; the weak reference keeps its address from being reused.
|
||||
#[derive(Clone)]
|
||||
struct Kept<T> {
|
||||
doc: Weak<Document>,
|
||||
sel: Vec<NodeId>,
|
||||
extra: Vec<u64>,
|
||||
value: T,
|
||||
}
|
||||
|
||||
/// The value `name` for `doc`, `sel` and `extra`: the kept one when they are what it was made
|
||||
/// from, else `make()`, kept for next time.
|
||||
pub(crate) fn cached<T: Clone + Send + Sync + 'static>(
|
||||
ctx: &egui::Context,
|
||||
name: &'static str,
|
||||
doc: &Arc<Document>,
|
||||
sel: &[NodeId],
|
||||
extra: &[u64],
|
||||
make: impl FnOnce() -> T,
|
||||
) -> T {
|
||||
let id = egui::Id::unique(("selection_cache", name));
|
||||
if let Some(k) = ctx.data(|d| d.get_temp::<Kept<T>>(id))
|
||||
&& std::ptr::eq(k.doc.as_ptr(), Arc::as_ptr(doc))
|
||||
&& k.sel == sel
|
||||
&& k.extra == extra
|
||||
{
|
||||
return k.value;
|
||||
}
|
||||
let value = make();
|
||||
ctx.data_mut(|d| d.insert_temp(id, Kept { doc: Arc::downgrade(doc), sel: sel.to_vec(), extra: extra.to_vec(), value: value.clone() }));
|
||||
value
|
||||
}
|
||||
|
||||
/// A screen point as key material.
|
||||
pub(crate) fn bits(p: egui::Pos2) -> [u64; 2] {
|
||||
[u64::from(p.x.to_bits()), u64::from(p.y.to_bits())]
|
||||
}
|
||||
+2
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user