mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-12 17:01:09 +00:00
Merge remote-tracking branch 'origin/main' into landb/2240
This commit is contained in:
commit
2c7bffbc32
27 files changed
+802
-78
No files matched your search
Generated
+1
@@ -1812,6 +1812,7 @@ dependencies = [
|
||||
"windows",
|
||||
"winit",
|
||||
"winresource",
|
||||
"winsafe",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate};
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::{JsFuture, future_to_promise};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use crate::saved_files::SavedFiles;
|
||||
|
||||
@@ -150,15 +150,6 @@ async fn sleep(ms: i32) {
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
|
||||
/// A reply as a promise outcome: the `result`, or an `Error` carrying the `error`.
|
||||
fn outcome(reply: &Value) -> Result<JsValue, JsValue> {
|
||||
if reply.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
Ok(to_js(reply.get("result").unwrap_or(&Value::Null)))
|
||||
} else {
|
||||
Err(js_sys::Error::new(reply.get("error").and_then(Value::as_str).unwrap_or("error")).into())
|
||||
}
|
||||
}
|
||||
|
||||
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
|
||||
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
|
||||
}
|
||||
@@ -169,20 +160,20 @@ pub fn install() -> Result<(), JsValue> {
|
||||
Rc::new(PageApi::new(APP, Methods::default(), request)).install()?;
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let obj: js_sys::Object = js_sys::Reflect::get(&window, &APP.into())?.unchecked_into();
|
||||
set_fn(
|
||||
&obj,
|
||||
"inspect",
|
||||
Closure::<dyn Fn() -> js_sys::Promise>::new(|| future_to_promise(async { outcome(&send("document.inspect", json!({})).await) }))
|
||||
.into_js_value(),
|
||||
)?;
|
||||
// `inspect` is `docInspect` under its earlier name: the crate's function, so it times out too.
|
||||
let doc_inspect = js_sys::Reflect::get(&obj, &"docInspect".into())?;
|
||||
js_sys::Reflect::set(&obj, &"inspect".into(), &doc_inspect)?;
|
||||
// `openFile` runs `file.open` through the crate's `execute`, so a call the app does not answer times out and is cancelled.
|
||||
let execute: js_sys::Function = js_sys::Reflect::get(&obj, &"execute".into())?.unchecked_into();
|
||||
let this = obj.clone();
|
||||
set_fn(
|
||||
&obj,
|
||||
"openFile",
|
||||
Closure::<dyn Fn(String, js_sys::Uint8Array) -> js_sys::Promise>::new(|name: String, bytes: js_sys::Uint8Array| {
|
||||
Closure::<dyn Fn(String, js_sys::Uint8Array) -> JsValue>::new(move |name: String, bytes: js_sys::Uint8Array| {
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(bytes.to_vec());
|
||||
future_to_promise(async move {
|
||||
outcome(&send("engine.execute", json!({"command": "file.open", "params": {"name": name, "data": data}})).await)
|
||||
})
|
||||
execute
|
||||
.call2(&this, &"file.open".into(), &to_js(&json!({"name": name, "data": data})))
|
||||
.unwrap_or_else(|e| js_sys::Promise::reject(&e).into())
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
|
||||
@@ -39,6 +39,9 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
|
||||
# The custom title bar's window styles (src/window_frame.rs): safe Win32 wrappers (MIT); the `user`
|
||||
# feature is User32.dll only. Same version as DesignCraft and PhotoCraft.
|
||||
winsafe = { version = "0.0.29", default-features = false, features = ["user"] }
|
||||
|
||||
# The system colour scheme (org.freedesktop.portal.Settings): winit reports no theme on Linux and
|
||||
# the BSDs, so the interface's "system" mode follows the portal's instead. zbus comes in through
|
||||
|
||||
@@ -29,6 +29,7 @@ mod graphics;
|
||||
mod recovery;
|
||||
mod services;
|
||||
mod startup;
|
||||
mod window_frame;
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod apple_events_tests;
|
||||
@@ -63,6 +64,8 @@ struct App {
|
||||
title: String,
|
||||
/// Shared with the services, which write the copies after a panic.
|
||||
recovery: recovery::Shared,
|
||||
/// Keeps the custom title bar's undecorated windows borderless (Windows, #1343).
|
||||
window_frame: window_frame::WindowFrame,
|
||||
/// Documents and quit requests from Finder, the Dock and Open With.
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events: fmv_macos_events::Inbox,
|
||||
@@ -111,6 +114,9 @@ impl eframe::App for App {
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
// winit gives an undecorated window its caption back when it changes the window's flags
|
||||
// (maximize, restore, ...): take it off again before the frame is laid out (#1343).
|
||||
self.window_frame.run();
|
||||
// Pointer and key events sent over the control channel (`ui.input`), for the window
|
||||
// about to draw.
|
||||
if let Some(events) = with_app(|fig| figcraft_ui_egui::windows::take_input(fig, raw_input.viewport_id)) {
|
||||
@@ -268,6 +274,7 @@ fn main() -> eframe::Result {
|
||||
// the old bespoke xdg-decoration probe that always drew FigCraft's custom title bar on KDE.
|
||||
fig.ui.title_bar = desktop_title_bar();
|
||||
viewport = window_chrome(viewport, fig.ui.title_bar);
|
||||
let custom_titlebar = fig.ui.title_bar == figcraft_ui_egui::TitleBar::Custom;
|
||||
if let Some(icon) = icon {
|
||||
viewport = viewport.with_icon(icon);
|
||||
}
|
||||
@@ -304,6 +311,9 @@ fn main() -> eframe::Result {
|
||||
ctx_slot,
|
||||
title: String::new(),
|
||||
recovery,
|
||||
// The custom title bar's undecorated window must stay borderless, or its caption
|
||||
// and border offset the content and the pointer on some Windows systems (#1343).
|
||||
window_frame: window_frame::WindowFrame::new(custom_titlebar),
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events: apple_events.connect(&cc.egui_ctx),
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Keeps the custom (undecorated) title bar windows truly borderless on Windows (#1343).
|
||||
//!
|
||||
//! winit 0.30 gives an undecorated window the `WS_CAPTION | WS_BORDER` styles and hides the
|
||||
//! non-client area by spreading the client rect over it (`WM_NCCALCSIZE`). On some systems the
|
||||
//! desktop compositor still reserves that frame for the window surface: the content is drawn about
|
||||
//! 30 px low (a black band above it) while the pointer keeps the un-offset client coordinates, so
|
||||
//! clicks land on something other than what is drawn under them.
|
||||
//!
|
||||
//! winit 0.27 omitted those styles for undecorated windows and had no offset. [`WindowFrame`]
|
||||
//! restores that: it strips `WS_CAPTION` and `WS_BORDER` from every window of this process that
|
||||
//! draws its own title bar, and has Windows recalculate the frame. winit puts the styles back
|
||||
//! whenever it changes the window flags (maximize, restore, ...), so the step runs every frame; it
|
||||
//! reads each style and writes only when it differs, so it costs next to nothing.
|
||||
//!
|
||||
//! The same fix is in DesignCraft (#2139) and PhotoCraft (#2401). The long-term fix is in winit
|
||||
//! (storytold/winit#17); this module goes when the pinned winit has it.
|
||||
//!
|
||||
//! The Windows calls go through safe `winsafe` wrappers (the workspace forbids `unsafe`). Which
|
||||
//! windows are ours is found by enumerating this process's top-level windows of winit's window
|
||||
//! class, so the main window and every other window opened later are covered alike, and a file
|
||||
//! dialog or message box of the same process is left alone.
|
||||
|
||||
/// `WS_BORDER`.
|
||||
#[cfg(any(windows, test))]
|
||||
const WS_BORDER: u32 = 0x0080_0000;
|
||||
/// `WS_CAPTION`, which already includes `WS_BORDER`.
|
||||
#[cfg(any(windows, test))]
|
||||
const WS_CAPTION: u32 = 0x00C0_0000;
|
||||
|
||||
/// `style` without the OS title bar and frame (`WS_CAPTION`, `WS_BORDER`). The sizing border
|
||||
/// (`WS_THICKFRAME`) and the window buttons stay, so edge resizing, Aero snap and the system menu
|
||||
/// keep working, as they did with winit 0.27's undecorated windows.
|
||||
#[cfg(any(windows, test))]
|
||||
pub fn borderless_style(style: u32) -> u32 {
|
||||
style & !(WS_CAPTION | WS_BORDER)
|
||||
}
|
||||
|
||||
/// The per-frame step that keeps the windows borderless; a no-op where the OS draws the frame
|
||||
/// (macOS, Linux, the BSDs), where the app uses the system title bar, and in the browser.
|
||||
pub struct WindowFrame {
|
||||
step: Option<Box<dyn FnMut()>>,
|
||||
}
|
||||
|
||||
impl WindowFrame {
|
||||
/// The step for an app whose windows draw their own title bar when `custom_titlebar`.
|
||||
pub fn new(custom_titlebar: bool) -> Self {
|
||||
Self { step: platform::step(custom_titlebar) }
|
||||
}
|
||||
|
||||
/// Run the step; call it once per frame, before the frame is laid out.
|
||||
pub fn run(&mut self) {
|
||||
if let Some(step) = self.step.as_mut() {
|
||||
step();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn with_step(step: Box<dyn FnMut()>) -> Self {
|
||||
Self { step: Some(step) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod platform {
|
||||
use winsafe::{HWND, HwndPlace, POINT, SIZE, co};
|
||||
|
||||
/// winit's window class (`WindowAttributes`' default `class_name`).
|
||||
const WINIT_CLASS: &str = "Window Class";
|
||||
|
||||
/// A step that strips the frame styles from this process's windows each call, or `None` when
|
||||
/// the app uses the system title bar (the windows are decorated).
|
||||
pub fn step(custom_titlebar: bool) -> Option<Box<dyn FnMut()>> {
|
||||
if !custom_titlebar {
|
||||
return None;
|
||||
}
|
||||
let pid = std::process::id();
|
||||
Some(Box::new(move || {
|
||||
// The windows are handed over as real `HWND`s; nothing is wrapped from a raw handle.
|
||||
let _ = winsafe::EnumWindows(|hwnd| {
|
||||
strip_if_ours(&hwnd, pid);
|
||||
true
|
||||
});
|
||||
}))
|
||||
}
|
||||
|
||||
/// Strip `hwnd` when it is one of this process's winit windows.
|
||||
fn strip_if_ours(hwnd: &HWND, pid: u32) {
|
||||
if hwnd.GetWindowThreadProcessId().1 != pid || !matches!(hwnd.GetClassName().as_deref(), Ok(WINIT_CLASS)) {
|
||||
return;
|
||||
}
|
||||
strip(hwnd);
|
||||
}
|
||||
|
||||
/// Removes `WS_CAPTION`/`WS_BORDER` and has Windows recalculate the frame; a no-op once they
|
||||
/// are gone.
|
||||
fn strip(hwnd: &HWND) {
|
||||
let style = hwnd.style();
|
||||
// The decision is the tested arithmetic; the write uses the same bits as winsafe's flags.
|
||||
if super::borderless_style(style.raw()) == style.raw() {
|
||||
return;
|
||||
}
|
||||
hwnd.set_style(style & !(co::WS::CAPTION | co::WS::BORDER));
|
||||
let _ = hwnd.SetWindowPos(
|
||||
HwndPlace::None,
|
||||
POINT::new(),
|
||||
SIZE::new(),
|
||||
co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
mod platform {
|
||||
pub fn step(_custom_titlebar: bool) -> Option<Box<dyn FnMut()>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod window_frame_tests {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use super::{WS_BORDER, WS_CAPTION, WindowFrame, borderless_style};
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn the_style_bits_match_winsafes() {
|
||||
use winsafe::co;
|
||||
assert_eq!(co::WS::CAPTION.raw(), WS_CAPTION);
|
||||
assert_eq!(co::WS::BORDER.raw(), WS_BORDER);
|
||||
}
|
||||
|
||||
const WS_SYSMENU: u32 = 0x0008_0000;
|
||||
const WS_THICKFRAME: u32 = 0x0004_0000;
|
||||
const WS_MINIMIZEBOX: u32 = 0x0002_0000;
|
||||
const WS_MAXIMIZEBOX: u32 = 0x0001_0000;
|
||||
const WS_VISIBLE: u32 = 0x1000_0000;
|
||||
|
||||
#[test]
|
||||
fn the_borderless_style_drops_the_caption_and_border_only() {
|
||||
let decorated = WS_CAPTION | WS_BORDER | WS_SYSMENU | WS_THICKFRAME | WS_MAXIMIZEBOX | WS_MINIMIZEBOX | WS_VISIBLE;
|
||||
let bare = borderless_style(decorated);
|
||||
assert_eq!(bare & WS_CAPTION, 0, "caption gone");
|
||||
assert_eq!(bare & WS_BORDER, 0, "border gone");
|
||||
assert_eq!(bare & WS_THICKFRAME, WS_THICKFRAME, "edge resizing kept");
|
||||
assert_eq!(bare & (WS_MAXIMIZEBOX | WS_MINIMIZEBOX | WS_SYSMENU), WS_MAXIMIZEBOX | WS_MINIMIZEBOX | WS_SYSMENU, "window buttons kept");
|
||||
assert_eq!(bare & WS_VISIBLE, WS_VISIBLE, "unrelated bits kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_already_borderless_style_is_unchanged_so_no_frame_change_is_made() {
|
||||
let bare = WS_THICKFRAME | WS_SYSMENU | WS_MAXIMIZEBOX | WS_MINIMIZEBOX | WS_VISIBLE;
|
||||
assert_eq!(borderless_style(bare), bare);
|
||||
assert_eq!(borderless_style(borderless_style(bare | WS_CAPTION)), bare);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_step_runs_each_time_the_frame_runs_it() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let n = calls.clone();
|
||||
let mut frame = WindowFrame::with_step(Box::new(move || {
|
||||
n.fetch_add(1, Ordering::SeqCst);
|
||||
}));
|
||||
for _ in 0..3 {
|
||||
frame.run();
|
||||
}
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_step_is_fine() {
|
||||
// Decorated windows and other platforms have no step; running must do nothing.
|
||||
let mut frame = WindowFrame::new(false);
|
||||
frame.run();
|
||||
}
|
||||
}
|
||||
+127
-18
@@ -355,14 +355,15 @@ pub fn enter_target(doc: &Document, leaf: NodeId, selection: &[NodeId]) -> NodeI
|
||||
inner.unwrap_or_else(|| select_target(doc, leaf, selection, false))
|
||||
}
|
||||
|
||||
/// Nodes on `page` whose world bounds intersect `rect` (marquee): top-level children, or the
|
||||
/// children of top-level frames when the marquee starts inside a frame (`within`).
|
||||
/// Nodes a marquee over `rect` touches, one level only: the children of `within` (a focus view's
|
||||
/// frame), or the top-level layers of `page`. Frames are not opened or enclosed here; see
|
||||
/// [`marquee_select`] for a plain marquee.
|
||||
pub fn marquee(doc: &Document, page: NodeId, rect: Rect, within: Option<NodeId>) -> Vec<NodeId> {
|
||||
let parent = within.unwrap_or(page);
|
||||
doc.node(parent).map(|p| marquee_children(doc, p, rect)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Nodes on `page` a Cmd/Ctrl marquee selects: the innermost layers whose bounds touch `rect`
|
||||
/// Nodes on `page` a Cmd/Ctrl marquee selects: the innermost layers the marquee touches
|
||||
/// (frames, groups and instances are looked into; a container none of whose children are touched
|
||||
/// counts itself; boolean groups count as one shape).
|
||||
pub fn marquee_deep(doc: &Document, page: NodeId, rect: Rect) -> Vec<NodeId> {
|
||||
@@ -384,25 +385,133 @@ pub fn marquee_deep(doc: &Document, page: NodeId, rect: Rect) -> Vec<NodeId> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The visible, unlocked children of `p` whose world bounds touch `rect` (on both axes: lines and
|
||||
/// other flat layers too).
|
||||
/// The visible, unlocked children of `p` the marquee touches (see [`marquee_touches`]).
|
||||
fn marquee_children(doc: &Document, p: &Node, rect: Rect) -> Vec<NodeId> {
|
||||
p.children
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.locked))
|
||||
.filter(|c| doc.world_bounds(*c).is_some_and(|b| b.x0 <= rect.x1 && b.x1 >= rect.x0 && b.y0 <= rect.y1 && b.y1 >= rect.y0))
|
||||
.collect()
|
||||
p.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.locked && marquee_touches(doc, *c, n, rect))).collect()
|
||||
}
|
||||
|
||||
/// Figma marquee rule: a marquee that fully contains a top-level frame selects the frame; one
|
||||
/// starting on empty canvas selects top-level objects it touches.
|
||||
pub fn marquee_select(doc: &Document, page: NodeId, rect: Rect, start_inside: Option<NodeId>) -> Vec<NodeId> {
|
||||
if let Some(frame) = start_inside {
|
||||
let inside = marquee(doc, page, rect, Some(frame));
|
||||
return inside;
|
||||
/// The outline a rotated shape is touched by. One line: return the turned box instead
|
||||
/// (`figcraft_geom::rounded_rect(n.width, n.height, [0.0; 4], 0.0)`) if Figma proves to test that.
|
||||
fn marquee_outline(n: &Node) -> Option<BezPath> {
|
||||
shape_path(n)
|
||||
}
|
||||
|
||||
/// Whether the marquee touches layer `id`: its world box overlaps `rect` (inclusive, so lines and
|
||||
/// zero-size marquees count) and, for a rotated shape, its outline in page space does too.
|
||||
fn marquee_touches(doc: &Document, id: NodeId, n: &Node, rect: Rect) -> bool {
|
||||
let box_hit = doc.world_bounds(id).is_some_and(|b| b.x0 <= rect.x1 && b.x1 >= rect.x0 && b.y0 <= rect.y1 && b.y1 >= rect.y0);
|
||||
if !box_hit
|
||||
|| !matches!(
|
||||
n.kind,
|
||||
NodeKind::Rectangle
|
||||
| NodeKind::Ellipse { .. }
|
||||
| NodeKind::Polygon { .. }
|
||||
| NodeKind::Star { .. }
|
||||
| NodeKind::Line
|
||||
| NodeKind::Vector { .. }
|
||||
)
|
||||
{
|
||||
return box_hit;
|
||||
}
|
||||
marquee(doc, page, rect, None)
|
||||
let xf = doc.world_transform(id);
|
||||
let [a, b, c, d, e, f] = xf.as_coeffs();
|
||||
const EPS: f64 = 1e-9;
|
||||
let rotated = !((b.abs() < EPS && c.abs() < EPS) || (a.abs() < EPS && d.abs() < EPS));
|
||||
if !rotated {
|
||||
return true;
|
||||
}
|
||||
let det = a * d - b * c;
|
||||
if ![a, b, c, d, e, f, det].iter().all(|v| v.is_finite()) || det.abs() < EPS {
|
||||
return true;
|
||||
}
|
||||
marquee_outline(n).is_none_or(|path| path_touches_rect(n, &path, xf, rect))
|
||||
}
|
||||
|
||||
/// Whether the marquee and the shape drawn as local `path` under `xf` share a point: a path end
|
||||
/// inside the marquee, a path segment crossing a marquee edge, or the marquee inside the filled shape.
|
||||
fn path_touches_rect(n: &Node, path: &BezPath, xf: figcraft_geom::Affine, rect: Rect) -> bool {
|
||||
use figcraft_geom::kurbo::{Line, ParamCurve, PathSeg};
|
||||
let inside = |p: Point| p.x >= rect.x0 && p.x <= rect.x1 && p.y >= rect.y0 && p.y <= rect.y1;
|
||||
let mut world = path.clone();
|
||||
world.apply_affine(xf);
|
||||
if world.segments().any(|s| inside(s.start()) || inside(s.end())) {
|
||||
return true;
|
||||
}
|
||||
let (tl, tr, br, bl) = (Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1));
|
||||
let edges = [(tl, tr), (tr, br), (br, bl), (bl, tl)];
|
||||
for (p, q) in edges {
|
||||
if (q - p).hypot() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let line = Line::new(p, q);
|
||||
if world.segments().any(|s: PathSeg| s.intersect_line(line).iter().any(|h| (0.0..=1.0).contains(&h.line_t))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
is_closed(n) && fills_point(n, path, xf.inverse() * tl)
|
||||
}
|
||||
|
||||
/// Whether a marquee takes a touched top-level layer whole, opens it for the layers in it, or skips it.
|
||||
enum Touch {
|
||||
Whole,
|
||||
/// The frame the drag started in: the layers in it the marquee touches, or the frame itself
|
||||
/// (whole) once the marquee is at the top level.
|
||||
Open,
|
||||
/// A partly covered frame or section: the layers in it, or the frame itself once the marquee is at the top level.
|
||||
Partial,
|
||||
Skip,
|
||||
}
|
||||
|
||||
/// Figma's marquee rule (measured in the web app, 2026-11-11): each top-level layer the marquee
|
||||
/// touches is selected whole, except a frame with children or a section with its contents shown:
|
||||
/// enclosed by the marquee it is selected itself, otherwise the marquee takes the layers in it that
|
||||
/// it touches. The frame the drag started in (`start_inside`) is selected only when the marquee also
|
||||
/// takes another top-level frame (then whole); otherwise only the layers in it the marquee touches.
|
||||
/// Selecting a frame or section whole keeps the marquee at the top level: no frame opens then, and a
|
||||
/// partly covered one is selected whole. Components, instances, board items and sections with hidden
|
||||
/// contents are selected only when enclosed.
|
||||
pub fn marquee_select(doc: &Document, page: NodeId, rect: Rect, start_inside: Option<NodeId>) -> Vec<NodeId> {
|
||||
let Some(page_node) = doc.node(page) else { return Vec::new() };
|
||||
let touched = marquee_children(doc, page_node, rect);
|
||||
let classified: Vec<(NodeId, Touch, bool)> = touched
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
let n = doc.node(*t)?;
|
||||
let enclosed = doc.world_bounds(*t).is_some_and(|b| rect.contains(b.origin()) && rect.contains(Point::new(b.x1, b.y1)));
|
||||
let container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. });
|
||||
let opens = match &n.kind {
|
||||
NodeKind::Frame | NodeKind::Section { .. } => !n.is_board_atom() && !n.hides_contents() && !n.children.is_empty(),
|
||||
_ => false,
|
||||
};
|
||||
let plain_empty_frame = matches!(n.kind, NodeKind::Frame) && n.children.is_empty() && !n.is_board_atom();
|
||||
let act = if start_inside == Some(*t) {
|
||||
Touch::Open
|
||||
} else if opens {
|
||||
if enclosed { Touch::Whole } else { Touch::Partial }
|
||||
} else if plain_empty_frame || !(container || n.is_board_atom()) || enclosed {
|
||||
Touch::Whole
|
||||
} else {
|
||||
Touch::Skip
|
||||
};
|
||||
let whole_container = container && matches!(act, Touch::Whole);
|
||||
Some((*t, act, whole_container))
|
||||
})
|
||||
.collect();
|
||||
let top_level_mode = classified.iter().any(|(_, _, whole_container)| *whole_container);
|
||||
let mut out = Vec::new();
|
||||
for (t, act, _) in &classified {
|
||||
match act {
|
||||
Touch::Whole => out.push(*t),
|
||||
Touch::Open | Touch::Partial if top_level_mode => out.push(*t),
|
||||
Touch::Open | Touch::Partial => {
|
||||
if let Some(n) = doc.node(*t) {
|
||||
out.extend(marquee_children(doc, n, rect));
|
||||
}
|
||||
}
|
||||
Touch::Skip => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bounds of a node's outline in page coordinates for snapping (the box corners and centre).
|
||||
|
||||
@@ -298,6 +298,30 @@ pub fn prop_list(doc: &Document, c: NodeId) -> Vec<(String, PropDef)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The properties an instance of `component` lists: [`prop_list`] without the boolean, text and
|
||||
/// instance swap properties that no layer of the component reads. A set's variants share its
|
||||
/// definitions, so one a variant's layers don't bind (another variant's text, a leftover of a
|
||||
/// deleted variant) stays defined, and shows on the set, but not on this variant's instances
|
||||
/// (#2022). Slots stay. Walks as [`slots_of`] does: a nested instance counts, its own layers and
|
||||
/// the content put into slots are not entered.
|
||||
pub fn instance_prop_list(doc: &Document, component: NodeId) -> Vec<(String, PropDef)> {
|
||||
let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut stack = vec![component];
|
||||
let mut seen = 0usize;
|
||||
while let Some(c) = stack.pop() {
|
||||
seen += 1;
|
||||
if seen > 1_000_000 {
|
||||
break;
|
||||
}
|
||||
let Some(n) = doc.node(c) else { continue };
|
||||
used.extend(n.prop_refs.values().cloned());
|
||||
if c == component || (!is_instance(doc, c) && !holds_content(n)) {
|
||||
stack.extend(n.children.iter().rev().copied());
|
||||
}
|
||||
}
|
||||
prop_list(doc, component).into_iter().filter(|(k, d)| !takes_value(d) || used.contains(k)).collect()
|
||||
}
|
||||
|
||||
/// Whether instances store a value for the property (every kind but slots).
|
||||
pub fn takes_value(d: &PropDef) -> bool {
|
||||
!matches!(d, PropDef::Slot { .. })
|
||||
|
||||
@@ -395,8 +395,11 @@ fn unbind_delete_and_vary(s: &mut Session, c: NodeId, i: NodeId, title: NodeId,
|
||||
ex(s, "component.deleteProperty", json!({"id": c, "name": "Heading"}));
|
||||
assert!(node(s, title).prop_refs.is_empty());
|
||||
assert!(matches!(&node(s, i).kind, NodeKind::Instance { props, .. } if !props.contains_key("Heading")));
|
||||
let desc = ex(s, "component.properties", json!({"id": i}));
|
||||
// "Show dot" is still defined but no layer reads it any more: the main lists it, the instance doesn't (#2022).
|
||||
let desc = ex(s, "component.properties", json!({"id": c}));
|
||||
assert_eq!(desc["properties"].as_array().unwrap().len(), 1);
|
||||
let desc = ex(s, "component.properties", json!({"id": i}));
|
||||
assert!(desc["properties"].as_array().unwrap().is_empty());
|
||||
// A variant property on a lone component makes it a component set.
|
||||
let set = idv(&ex(s, "component.addProperty", json!({"id": c, "name": "Theme", "type": "variant", "default": "Light"})));
|
||||
assert!(matches!(node(s, set).kind, NodeKind::ComponentSet { .. }));
|
||||
|
||||
@@ -47,16 +47,23 @@ impl Session {
|
||||
/// the layers it touches (`deep`: nested ones too); `additive` (Shift) toggles them.
|
||||
pub(crate) fn marquee_selection(&self, r: Rect, within: Option<NodeId>, deep: bool, base: &[NodeId], additive: bool) -> Result<Vec<NodeId>> {
|
||||
let (doc, page) = (self.doc()?, self.page()?);
|
||||
let mut hits =
|
||||
if deep { figcraft_doc::geometry::marquee_deep(doc, page, r) } else { figcraft_doc::geometry::marquee_select(doc, page, r, within) };
|
||||
if let Some(f) = self.ds()?.focused() {
|
||||
let focused = self.ds()?.focused();
|
||||
let mut hits = match (deep, focused) {
|
||||
(true, _) => figcraft_doc::geometry::marquee_deep(doc, page, r),
|
||||
(false, Some(f)) => figcraft_doc::geometry::marquee(doc, page, r, Some(f)),
|
||||
(false, None) => figcraft_doc::geometry::marquee_select(doc, page, r, within),
|
||||
};
|
||||
if let Some(f) = focused {
|
||||
hits.retain(|h| *h != f && doc.is_ancestor(f, *h));
|
||||
}
|
||||
// A marquee over a frame from outside selects the frame only when it covers it fully.
|
||||
if within.is_none() && !deep {
|
||||
// A widget's layers are part of the widget: a marquee from outside never opens one.
|
||||
if !deep {
|
||||
hits.retain(|h| {
|
||||
let full = doc.world_bounds(*h).is_some_and(|b| r.contains(b.origin()) && r.contains(Point::new(b.x1, b.y1)));
|
||||
full || !doc.node(*h).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }))
|
||||
doc.node(*h)
|
||||
.and_then(|n| n.parent)
|
||||
.filter(|p| Some(*p) != within)
|
||||
.and_then(|p| doc.node(p))
|
||||
.is_none_or(|p| !crate::widgets::is_widget(p))
|
||||
});
|
||||
}
|
||||
// Shift toggles: what was selected and is touched again comes out.
|
||||
|
||||
@@ -8,7 +8,7 @@ use super::{
|
||||
req_string, req_targets, set_boolean_default, slot_edited, slots_of, string, takes_value, targets, variant_conflicts, variant_name,
|
||||
variant_options, variant_props, variant_set,
|
||||
};
|
||||
use crate::components::prop_list;
|
||||
use crate::components::{instance_prop_list, prop_list};
|
||||
|
||||
/// Whether instance `id` has a component property `name` that a `kind` variable can drive (a
|
||||
/// boolean property takes a boolean variable, a text or variant property a string one).
|
||||
@@ -606,7 +606,8 @@ pub(crate) fn describe_props(doc: &Document, id: NodeId) -> Value {
|
||||
}
|
||||
// Slots: the frames (on a main) or this instance's copy, with whether it holds its own content.
|
||||
let slots = slots_of(doc, if values.is_some() { id } else { variant_set(doc, comp).unwrap_or(comp) });
|
||||
for (k, d) in prop_list(doc, comp) {
|
||||
let defs = if values.is_some() { instance_prop_list(doc, comp) } else { prop_list(doc, comp) };
|
||||
for (k, d) in defs {
|
||||
let mut v = def_json(&k, &d);
|
||||
if !takes_value(&d) {
|
||||
let layers: Vec<NodeId> = slots.iter().filter(|(_, n)| *n == k).map(|(l, _)| *l).collect();
|
||||
|
||||
@@ -423,6 +423,8 @@ mod live_tests;
|
||||
#[cfg(test)]
|
||||
mod lock_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod marquee_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod mask_tests;
|
||||
#[cfg(test)]
|
||||
mod masks_behaviour_tests;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Marquee selection, as measured in Figma's web app (2026-11-11, the "marquee rules" and "confirming
|
||||
//! pass" readings, and the two evening re-measures) and described in the help-center article "Select layers and objects": a drag
|
||||
//! selects each top-level layer it touches; a partly covered frame or section gives the layers in it
|
||||
//! the marquee touches, an enclosed one is selected itself; a drag started inside a frame selects that frame
|
||||
//! only when the marquee also takes another top-level frame (then whole); selecting a frame whole keeps the marquee at the top level; a rotated shape is touched by
|
||||
//! its outline, an unrotated one by its box.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_geom::Point;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option<NodeId>) -> NodeId {
|
||||
let mut p = json!({"x": x, "y": y, "width": w, "height": h});
|
||||
if let Some(parent) = parent {
|
||||
p["parent"] = json!(parent);
|
||||
}
|
||||
id(&ex(s, "shape.rectangle", p))
|
||||
}
|
||||
|
||||
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||||
PointerEvent::at(kind, x, y, 1.0)
|
||||
}
|
||||
|
||||
fn drag(s: &mut Session, a: (f64, f64), b: (f64, f64)) {
|
||||
s.pointer(&ev(PointerKind::Down, a.0, a.1)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, b.0, b.1)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, b.0, b.1)).unwrap();
|
||||
}
|
||||
|
||||
fn sorted(mut v: Vec<NodeId>) -> Vec<NodeId> {
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
struct Scene {
|
||||
f1: NodeId,
|
||||
r1: NodeId,
|
||||
r2: NodeId,
|
||||
e: NodeId,
|
||||
u: NodeId,
|
||||
rr: NodeId,
|
||||
re: NodeId,
|
||||
f2: NodeId,
|
||||
g: NodeId,
|
||||
s: NodeId,
|
||||
r5: NodeId,
|
||||
}
|
||||
|
||||
/// The confirming pass's layout: `f1` (0,0 300x200) holding `r1` (20,20) and `r2` (220,120), both 60x60;
|
||||
/// ellipse `e` (500,0 200x200); rectangle `u` (900,0 100x100); `rr` rectangle (0,400 200x100) and ellipse
|
||||
/// `re` (500,400 200x100) turned 45 degrees about their centres; the empty frame `f2` (0,700 200x150);
|
||||
/// the group `g` of two rectangles (300,700) and (360,760); the section `s` (600,700 300x200) holding `r5`.
|
||||
fn scene(s: &mut Session) -> Scene {
|
||||
let f1 = id(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200})));
|
||||
let r1 = rect(s, 20.0, 20.0, 60.0, 60.0, Some(f1));
|
||||
let r2 = rect(s, 220.0, 120.0, 60.0, 60.0, Some(f1));
|
||||
let e = id(&ex(s, "shape.ellipse", json!({"x": 500, "y": 0, "width": 200, "height": 200})));
|
||||
let u = rect(s, 900.0, 0.0, 100.0, 100.0, None);
|
||||
let rr = rect(s, 0.0, 400.0, 200.0, 100.0, None);
|
||||
ex(s, "node.set", json!({"id": rr, "props": {"rotation": 45}}));
|
||||
let re = id(&ex(s, "shape.ellipse", json!({"x": 500, "y": 400, "width": 200, "height": 100})));
|
||||
ex(s, "node.set", json!({"id": re, "props": {"rotation": 45}}));
|
||||
let f2 = id(&ex(s, "frame.create", json!({"x": 0, "y": 700, "width": 200, "height": 150})));
|
||||
let g1 = rect(s, 300.0, 700.0, 80.0, 80.0, None);
|
||||
let g2 = rect(s, 360.0, 760.0, 80.0, 80.0, None);
|
||||
let g = id(&ex(s, "object.group", json!({"ids": [g1, g2]})));
|
||||
let sec = id(&ex(s, "node.create", json!({"type": "section", "x": 600, "y": 700, "width": 300, "height": 200})));
|
||||
let r5 = rect(s, 20.0, 20.0, 60.0, 60.0, Some(sec));
|
||||
ex(s, "select.none", json!({}));
|
||||
Scene { f1, r1, r2, e, u, rr, re, f2, g, s: sec, r5 }
|
||||
}
|
||||
|
||||
fn selected(s: &mut Session, a: (f64, f64), b: (f64, f64)) -> Vec<NodeId> {
|
||||
ex(s, "select.none", json!({}));
|
||||
drag(s, a, b);
|
||||
let sel = sorted(s.selection());
|
||||
ex(s, "select.none", json!({}));
|
||||
sel
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_partly_covered_frame_gives_the_layers_it_touches() {
|
||||
let mut s = Session::new();
|
||||
let k = scene(&mut s);
|
||||
assert_eq!(selected(&mut s, (-50.0, -50.0), (60.0, 60.0)), vec![k.r1], "measured case 2: Rectangle 1, not F1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_enclosed_frame_is_selected_itself() {
|
||||
let mut s = Session::new();
|
||||
let k = scene(&mut s);
|
||||
assert_eq!(selected(&mut s, (-20.0, -20.0), (320.0, 220.0)), vec![k.f1], "measured b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_frame_is_selected_on_touch() {
|
||||
let mut s = Session::new();
|
||||
let k = scene(&mut s);
|
||||
assert_eq!(selected(&mut s, (-20.0, 680.0), (50.0, 720.0)), vec![k.f2], "measured c: Frame 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_is_selected_whole_on_touch() {
|
||||
let mut s = Session::new();
|
||||
let k = scene(&mut s);
|
||||
assert_eq!(selected(&mut s, (290.0, 690.0), (310.0, 710.0)), vec![k.g], "measured c: Group 1, not the rectangle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_partly_covered_section_gives_the_layers_it_touches() {
|
||||
let mut s = Session::new();
|
||||
let k = scene(&mut s);
|
||||
assert_eq!(selected(&mut s, (590.0, 690.0), (650.0, 750.0)), vec![k.r5], "measured c: Rectangle 5, not the section");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unrotated_shape_is_touched_by_its_box() {
|
||||
let mut s = Session::new();
|
||||
let k = scene(&mut s);
|
||||
assert_eq!(selected(&mut s, (490.0, -10.0), (505.0, 5.0)), vec![k.e], "measured case 3: the ellipse's box corner selects it");
|
||||
assert_eq!(selected(&mut s, (890.0, -10.0), (905.0, 5.0)), vec![k.u], "control");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotated_rectangle_is_touched_by_its_outline() {
|
||||
let mut s = Session::new();
|
||||
let k = scene(&mut s);
|
||||
assert_eq!(selected(&mut s, (-30.0, 320.0), (4.0, 354.0)), Vec::<NodeId>::new(), "measured case 3: the box corner selects nothing");
|
||||
assert_eq!(selected(&mut s, (-30.0, 320.0), (100.0, 450.0)), vec![k.rr]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotated_ellipse_is_not_touched_by_its_axis_aligned_box() {
|
||||
let mut s = Session::new();
|
||||
let k = scene(&mut s);
|
||||
assert_eq!(selected(&mut s, (470.0, 320.0), (504.0, 354.0)), Vec::<NodeId>::new(), "measured case 3: the box corner selects nothing");
|
||||
assert_eq!(selected(&mut s, (470.0, 320.0), (600.0, 450.0)), vec![k.re]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotated_ellipse_is_not_touched_through_the_corner_of_its_turned_box() {
|
||||
let mut s = Session::new();
|
||||
let k = scene(&mut s);
|
||||
// The re-measure's literal marquees (evening, section 1): corner A, the deeper B, and the control.
|
||||
let p = Point::new(692.0, 419.0);
|
||||
let doc = s.doc().unwrap();
|
||||
let n = doc.node(k.re).unwrap();
|
||||
assert!(n.local_rect().contains(doc.world_transform(k.re).inverse() * p), "the probe is inside the turned box");
|
||||
assert_eq!(selected(&mut s, (715.0, 411.0), (692.0, 419.0)), Vec::<NodeId>::new(), "measured marquee A");
|
||||
assert_eq!(selected(&mut s, (715.0, 411.0), (683.0, 419.0)), Vec::<NodeId>::new(), "measured marquee B");
|
||||
assert_eq!(selected(&mut s, (715.0, 411.0), (667.0, 419.0)), vec![k.re], "measured control");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_from_inside_a_frame_selects_only_what_it_touches() {
|
||||
let mut s = Session::new();
|
||||
let k = scene(&mut s);
|
||||
let start_x = |s: &Session| s.doc().unwrap().world_bounds(k.f1).map(|b| (b.x0, b.y0));
|
||||
assert_eq!(selected(&mut s, (150.0, 100.0), (180.0, 150.0)), Vec::<NodeId>::new(), "measured e1");
|
||||
assert_eq!(start_x(&s), Some((0.0, 0.0)));
|
||||
assert_eq!(selected(&mut s, (150.0, 100.0), (180.0, 250.0)), Vec::<NodeId>::new(), "measured e2");
|
||||
assert_eq!(start_x(&s), Some((0.0, 0.0)));
|
||||
assert_eq!(selected(&mut s, (150.0, 100.0), (600.0, 100.0)), vec![k.e], "measured e3: the ellipse only");
|
||||
assert_eq!(start_x(&s), Some((0.0, 0.0)));
|
||||
assert_eq!(selected(&mut s, (150.0, 100.0), (250.0, 150.0)), vec![k.r2], "today's rule, not measured");
|
||||
assert_eq!(start_x(&s), Some((0.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_a_frame_whole_keeps_the_marquee_at_the_top_level() {
|
||||
let mut s = Session::new();
|
||||
let k = scene(&mut s);
|
||||
// Encloses the empty f2, covers rr, and partly covers f1 over r1: no frame opens, f1 comes whole.
|
||||
assert_eq!(
|
||||
selected(&mut s, (-20.0, 50.0), (210.0, 860.0)),
|
||||
sorted(vec![k.f2, k.f1, k.rr]),
|
||||
"the article: selecting a top-level frame selects only top-level layers; measured: a partly covered frame comes whole"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_marquee_enclosing_an_empty_frame_takes_a_partly_covered_frame_whole() {
|
||||
let mut s = Session::new();
|
||||
let f1 = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200})));
|
||||
let _r1 = rect(&mut s, 20.0, 20.0, 60.0, 60.0, Some(f1));
|
||||
let f2 = id(&ex(&mut s, "frame.create", json!({"x": 400, "y": 0, "width": 200, "height": 150})));
|
||||
assert_eq!(selected(&mut s, (650.0, 170.0), (40.0, -10.0)), sorted(vec![f1, f2]), "measured re-measure: F2 and F1, not R1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_from_inside_a_frame_takes_it_whole_when_it_takes_another_top_level_frame() {
|
||||
let mut s = Session::new();
|
||||
let f1 = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200})));
|
||||
let r1 = rect(&mut s, 20.0, 20.0, 60.0, 60.0, Some(f1));
|
||||
let _r2 = rect(&mut s, 220.0, 120.0, 60.0, 60.0, Some(f1));
|
||||
let f2 = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 200, "height": 150})));
|
||||
let rr = rect(&mut s, 400.0, 400.0, 200.0, 100.0, None);
|
||||
ex(&mut s, "select.none", json!({}));
|
||||
assert_eq!(selected(&mut s, (150.0, 100.0), (90.0, 720.0)), sorted(vec![f1, f2]), "re-measure 2, case 1");
|
||||
let sel = selected(&mut s, (150.0, 50.0), (50.0, 720.0));
|
||||
assert_eq!(sel, sorted(vec![f1, f2]), "re-measure 2, case 2b: R1 is covered in part and still not selected");
|
||||
assert!(!sel.contains(&r1));
|
||||
assert_eq!(selected(&mut s, (150.0, 100.0), (450.0, 450.0)), sorted(vec![f1, f2, rr]), "re-measure 2, case 3");
|
||||
assert_eq!(selected(&mut s, (290.0, 100.0), (450.0, 450.0)), vec![rr], "re-measure 2, case 3b: the frame only when another is taken");
|
||||
}
|
||||
@@ -77,8 +77,9 @@ fn every_kind_of_property_keeps_its_place_on_rename() {
|
||||
let icon = idv(&ex(&mut s, "component.create", json!({"ids": [icon_f]})));
|
||||
let nested = idv(&ex(&mut s, "instance.create", json!({"component": icon, "x": 60, "y": 10, "parent": f})));
|
||||
let small = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
let zoom_layer = idv(&ex(&mut s, "shape.rectangle", json!({"x": 150, "y": 10, "width": 10, "height": 10, "parent": small})));
|
||||
// Made in an order that isn't by name: Zoom (boolean), Label (text), Glyph (instance swap), Body (slot).
|
||||
ex(&mut s, "component.addProperty", json!({"id": small, "name": "Zoom", "type": "boolean"}));
|
||||
ex(&mut s, "component.addProperty", json!({"id": small, "name": "Zoom", "type": "boolean", "layer": zoom_layer}));
|
||||
ex(&mut s, "component.addProperty", json!({"id": small, "name": "Label", "type": "text", "layer": label}));
|
||||
ex(&mut s, "component.addProperty", json!({"id": small, "name": "Glyph", "type": "instanceSwap", "layer": nested}));
|
||||
ex(&mut s, "component.makeSlot", json!({"id": body}));
|
||||
@@ -133,3 +134,27 @@ fn make_slot_takes_the_name_typed() {
|
||||
by_name.sort();
|
||||
assert_eq!(listed, by_name);
|
||||
}
|
||||
|
||||
/// An instance lists the properties its own variant's layers use (#2022): in Figma's data a set
|
||||
/// has one text property per variant (and leftovers of deleted ones), measured: an instance of
|
||||
/// variant A lists `State` and `A label`, never `B label` or a property no layer reads.
|
||||
#[test]
|
||||
fn an_instance_lists_only_the_properties_its_variant_uses() {
|
||||
let mut s = Session::new();
|
||||
let mut variants = Vec::new();
|
||||
let mut texts = Vec::new();
|
||||
for (k, name) in ["Type=Flag, State=Default", "Type=Flag, State=Selected"].iter().enumerate() {
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": k * 60, "width": 120, "height": 40, "name": name})));
|
||||
texts.push(idv(&ex(&mut s, "text.create", json!({"text": "Label", "x": 4, "y": 4, "parent": f, "name": "Label"}))));
|
||||
variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
|
||||
}
|
||||
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants})));
|
||||
ex(&mut s, "component.addProperty", json!({"id": set, "name": "Default label", "type": "text", "layer": texts[0]}));
|
||||
ex(&mut s, "component.addProperty", json!({"id": set, "name": "Selected label", "type": "text", "layer": texts[1]}));
|
||||
ex(&mut s, "component.addProperty", json!({"id": set, "name": "Gone label", "type": "text"}));
|
||||
assert_eq!(names(&mut s, set), ["Type", "State", "Default label", "Selected label", "Gone label"], "the set keeps every definition");
|
||||
let inst = idv(&ex(&mut s, "instance.create", json!({"component": variants[0], "x": 400, "y": 0})));
|
||||
assert_eq!(names(&mut s, inst), ["Type", "State", "Default label"]);
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [inst], "properties": {"State": "Selected"}}));
|
||||
assert_eq!(names(&mut s, inst), ["Type", "State", "Selected label"]);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! `select.area`: what a marquee over a box selects, without a pointer (keyboard box selection in
|
||||
//! the app, agents over MCP): top-level layers, a frame only when fully covered, the children of
|
||||
//! the frame the box starts in, Shift-like toggling, Cmd/Ctrl-like nested layers; no box asks the
|
||||
//! the app, agents over MCP): top-level layers, a frame only when fully covered (otherwise the
|
||||
//! layers in it the box touches), the children of the frame the box starts in, Shift-like toggling, Cmd/Ctrl-like nested layers; no box asks the
|
||||
//! app to draw one.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -40,8 +40,8 @@ fn a_box_selects_like_a_marquee() {
|
||||
let inner = rect(&mut s, 20.0, 20.0, Some(frame));
|
||||
let a = rect(&mut s, 0.0, 0.0, None);
|
||||
let b = rect(&mut s, 100.0, 0.0, None);
|
||||
// Top-level layers it touches; a frame only touched from outside isn't selected.
|
||||
assert_eq!(area(&mut s, -10.0, -10.0, 340.0, 40.0, json!({})), sorted(vec![a, b]));
|
||||
// Top-level layers it touches; a frame only touched from outside gives the layers in it the box touches.
|
||||
assert_eq!(area(&mut s, -10.0, -10.0, 340.0, 40.0, json!({})), sorted(vec![a, b, inner]));
|
||||
// A negative size draws the box the other way.
|
||||
assert_eq!(area(&mut s, 130.0, 30.0, -140.0, -40.0, json!({})), sorted(vec![a, b]));
|
||||
// Covering the frame selects it.
|
||||
|
||||
@@ -214,8 +214,8 @@ fn marquee_selects_what_it_touches() {
|
||||
(450.0, 40.0),
|
||||
NONE,
|
||||
vec![],
|
||||
vec![],
|
||||
"from the canvas, a top-level frame is selected only when the marquee covers all of it",
|
||||
vec![k.g, k.n],
|
||||
"from the canvas, a partly covered top-level frame gives the layers in it the marquee touches (measured); the nested frame comes whole",
|
||||
),
|
||||
((-10.0, -10.0), (450.0, 310.0), NONE, vec![], vec![k.f], "a marquee covering a top-level frame selects the frame"),
|
||||
(
|
||||
|
||||
@@ -66,10 +66,10 @@ fn cmd_click_selects_the_nested_layer() {
|
||||
#[test]
|
||||
fn cmd_marquee_selects_nested_layers() {
|
||||
let mut s = Session::new();
|
||||
let (f, _, [a, b, c], out) = scene(&mut s);
|
||||
// From empty canvas, a plain marquee across the frame only touches top-level layers (and the
|
||||
// frame isn't fully covered).
|
||||
assert_eq!(marquee(&mut s, (-10.0, 10.0), (500.0, 70.0), false, false), vec![out]);
|
||||
let (f, g, [a, b, c], out) = scene(&mut s);
|
||||
// From empty canvas, a plain marquee across the frame (not fully covered) gives the layers in it
|
||||
// it touches (the group whole), plus the top-level layer.
|
||||
assert_eq!(sorted(marquee(&mut s, (-10.0, 10.0), (500.0, 70.0), false, false)), sorted(vec![g, out]));
|
||||
// Cmd/Ctrl: the innermost layers it touches, through the frame and the group.
|
||||
assert_eq!(sorted(marquee(&mut s, (-10.0, 10.0), (500.0, 70.0), false, true)), sorted(vec![a, b, out]));
|
||||
assert_eq!(sorted(marquee(&mut s, (-10.0, 10.0), (100.0, 190.0), false, true)), sorted(vec![a, b, c]));
|
||||
|
||||
@@ -417,12 +417,16 @@ fn assert_slot_text(s: &mut Session, instance: NodeId, expected: &str) {
|
||||
assert!(html.contains(&format!(">{expected}</p>")), "slot Label HTML: {html}");
|
||||
}
|
||||
|
||||
fn assert_local_slot_text(s: &mut Session, instance: NodeId, expected: &str) {
|
||||
fn assert_stored_slot_text(s: &Session, instance: NodeId, expected: &str) {
|
||||
assert!(
|
||||
matches!(&node(s, instance).kind, NodeKind::Instance { props, .. } if props.get("Text") == Some(&json!(expected))),
|
||||
"instance {instance} must store its own Text value {expected:?}: {:?}",
|
||||
node(s, instance).kind
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_local_slot_text(s: &mut Session, instance: NodeId, expected: &str) {
|
||||
assert_stored_slot_text(s, instance, expected);
|
||||
let properties = props_of(s, instance);
|
||||
let text = properties.iter().find(|p| p["name"] == "Text").unwrap_or_else(|| panic!("instance {instance} has no Text property: {properties:?}"));
|
||||
assert_eq!(text["value"], expected, "instance {instance} Text property");
|
||||
@@ -475,14 +479,15 @@ fn slot_text_properties_do_not_read_an_unbound_base_property() {
|
||||
ex(&mut s, "component.addProperty", json!({"id": base, "name": "Text", "type": "text", "default": "Wrong owner"}));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": composition, "x": 400})));
|
||||
let nested_base = kids(&s, i)[0];
|
||||
assert_local_slot_text(&mut s, nested_base, "Wrong owner");
|
||||
// Bound to no layer, the base's Text isn't listed on its instance (#2269); its value stays stored.
|
||||
assert_stored_slot_text(&s, nested_base, "Wrong owner");
|
||||
let nested = copy_of(&s, i, main_content);
|
||||
assert_local_slot_text(&mut s, nested, "Default");
|
||||
assert_slot_text(&mut s, nested, "Default");
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [nested], "properties": {"Text": "Custom"}}));
|
||||
assert_local_slot_text(&mut s, nested, "Custom");
|
||||
assert_slot_text(&mut s, nested, "Custom");
|
||||
assert_local_slot_text(&mut s, nested_base, "Wrong owner");
|
||||
assert_stored_slot_text(&s, nested_base, "Wrong owner");
|
||||
assert_slot_text(&mut s, main_content, "Default");
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
@@ -186,3 +186,43 @@ fn property_values_written_only_as_variables_are_applied() {
|
||||
assert_eq!(props.get("Hint"), Some(&Value::Bool(false)));
|
||||
assert_eq!(props.get("Label"), Some(&Value::String("Name on card".into())));
|
||||
}
|
||||
|
||||
/// #2022: a set keeps each named text definition under its own name (Figma writes one per variant,
|
||||
/// plus leftovers of deleted variants), and each variant's text reads its own copy.
|
||||
#[test]
|
||||
fn a_set_keeps_each_named_text_definition_and_layers_read_their_own() {
|
||||
let text = |chars: &str| o(vec![("textValue", o(vec![("characters", s(chars))]))]);
|
||||
let names = ["Type=Flag / State=Default label", "Type=Flag / State=Selected label", "Type=Issue / State=Default label"];
|
||||
let set_defs: Vec<Kv<'static>> =
|
||||
names.iter().zip([70, 71, 72]).zip(["One", "Two", "Gone"]).map(|((n, id), c)| definition(id, n, "TEXT", text(c))).collect();
|
||||
let copies = |first: u32| Kv::Array((0..3).map(|k| o(vec![("id", g(first + k)), ("parentPropDefId", g(70 + k))])).collect());
|
||||
let msg = message(vec![
|
||||
node(9, "FRAME", "Chip", Some(2), vec![("isStateGroup", Kv::Bool(true)), ("componentPropDefs", Kv::Array(set_defs))]),
|
||||
node(10, "SYMBOL", "Type=Flag, State=Default", Some(9), vec![("componentPropDefs", copies(80))]),
|
||||
node(11, "TEXT", "Label", Some(10), vec![bound("TEXT_DATA", 80)]),
|
||||
node(20, "SYMBOL", "Type=Flag, State=Selected", Some(9), vec![("componentPropDefs", copies(90))]),
|
||||
node(21, "TEXT", "Label", Some(20), vec![bound("TEXT_DATA", 91)]),
|
||||
]);
|
||||
let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES);
|
||||
let defs = &by_id(&rest["document"], "9:9")["componentPropertyDefinitions"];
|
||||
for (k, n) in names.iter().enumerate() {
|
||||
assert_eq!(defs[format!("{n}#9:{}", 70 + k)]["type"], "TEXT", "{n}");
|
||||
}
|
||||
assert_eq!(defs.as_object().map(|d| d.len()), Some(3), "{defs}");
|
||||
assert_eq!(by_id(&rest["document"], "9:11")["componentPropertyReferences"]["characters"], format!("{}#9:70", names[0]));
|
||||
assert_eq!(by_id(&rest["document"], "9:21")["componentPropertyReferences"]["characters"], format!("{}#9:71", names[1]));
|
||||
|
||||
let (doc, _) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap();
|
||||
let refs: Vec<Option<String>> = doc
|
||||
.ids()
|
||||
.filter_map(|id| doc.node(id))
|
||||
.filter(|n| n.name == "Label" && n.source.is_none())
|
||||
.map(|n| n.prop_refs.get("characters").cloned())
|
||||
.collect();
|
||||
assert_eq!(refs.len(), 2, "{refs:?}");
|
||||
assert!(refs.contains(&Some(names[0].to_string())) && refs.contains(&Some(names[1].to_string())), "{refs:?}");
|
||||
for set in doc.ids().filter_map(|id| doc.node(id)).filter(|n| matches!(n.kind, NodeKind::Component { .. })) {
|
||||
let NodeKind::Component { props, .. } = &set.kind else { continue };
|
||||
assert!(names.iter().all(|n| props.contains_key(*n)), "{} holds {:?}", set.name, props.keys().collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,7 @@ name = "Svenska"
|
||||
"Another server, or one that needs its key. The usual server is set in Preferences." = "En annan server, eller en som kräver sin nyckel. Standardservern anges i Inställningar."
|
||||
"Any status" = "Valfri status"
|
||||
"Anyone on your network with this code can edit the file, while this app stays open." = "Alla i ditt nätverk som har den här koden kan redigera filen så länge appen är öppen."
|
||||
"Anyone on your network with this code can look at the file, not change it, while this app stays open." = "Alla i ditt nätverk som har den här koden kan titta på filen, men inte ändra den, så länge appen är öppen."
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Alla med redigeringslänken kan ändra filen; visningslänken låter bara andra titta. Länkarna innehåller sina hemligheter: dela dem som lösenord. `figcraft-server links` skapar fler och återkallar dem."
|
||||
"Anyone with this code can edit the file." = "Alla med den här koden kan redigera filen."
|
||||
"Anyone with this code can look at the file, not change it." = "Alla med den här koden kan titta på filen, men inte ändra den."
|
||||
@@ -388,6 +389,8 @@ name = "Svenska"
|
||||
"Crop marks" = "Skärmärken"
|
||||
"Ctrl+arrows" = "Ctrl+piltangenter"
|
||||
"Ctrl+click" = "Ctrl+klick"
|
||||
"Ctrl+click a padding field" = "Ctrl+klicka på ett padding-fält"
|
||||
"⌘ click a padding field" = "⌘ klicka på ett padding-fält"
|
||||
"Ctrl+scroll" = "Ctrl+rullning"
|
||||
"Current name" = "Aktuellt namn"
|
||||
"Current version" = "Aktuell version"
|
||||
@@ -407,6 +410,40 @@ name = "Svenska"
|
||||
"Decoration style" = "Dekorationsstil"
|
||||
"Decoration" = "Dekoration"
|
||||
"Deep select" = "Djupmarkering"
|
||||
"Scrub a field faster (2x) or slower (1/2, 1/4)" = "Dragjustera ett fält snabbare (2x) eller långsammare (1/2, 1/4)"
|
||||
"Move up or down while dragging its label" = "Flytta upp eller ned medan du drar dess etikett"
|
||||
"Scrubbing at 2x" = "Dragjustering med 2x hastighet"
|
||||
"Scrubbing at 1x" = "Dragjustering med 1x hastighet"
|
||||
"Scrubbing at 1/2" = "Dragjustering med 1/2 hastighet"
|
||||
"Scrubbing at 1/4" = "Dragjustering med 1/4 hastighet"
|
||||
"Delete {n} variables" = { one = "Radera {n} variabel", other = "Radera {n} variabler" }
|
||||
"Move out of any group" = "Flytta ut ur alla grupper"
|
||||
"Move to group {group}" = "Flytta till gruppen {group}"
|
||||
"Scope: all supported properties" = "Omfång: alla egenskaper som stöds"
|
||||
"Scope: hidden from pickers" = "Omfång: dold i väljare"
|
||||
"Scope: only {scope}" = "Omfång: endast {scope}"
|
||||
"Disconnected. Reconnecting…" = "Frånkopplad. Återansluter…"
|
||||
"Disconnected. Your edits stay here and sync when the connection returns. Reconnecting…" = "Frånkopplad. Dina redigeringar finns kvar här och synkas när anslutningen är tillbaka. Återansluter…"
|
||||
"Dismiss" = "Stäng"
|
||||
"Not connected: {reason}. Your work is still in this tab." = "Inte ansluten: {reason}. Ditt arbete finns kvar i den här fliken."
|
||||
"Retry now" = "Försök igen nu"
|
||||
"Save a copy…" = "Spara en kopia…"
|
||||
"Wrap style" = "Radbrytningsstil"
|
||||
"Text direction" = "Textriktning"
|
||||
"Auto: by the first letter with a direction" = "Auto: efter den första bokstaven som har en riktning"
|
||||
"Left to right" = "Vänster till höger"
|
||||
"Right to left" = "Höger till vänster"
|
||||
"Balance" = "Balans"
|
||||
"Pretty" = "Snygg"
|
||||
"Auto: as many words as fit on each line" = "Auto: så många ord som får plats på varje rad"
|
||||
"Balance: every line close to the same length" = "Balans: alla rader ungefär lika långa"
|
||||
"Pretty: full lines, no one word alone on the last" = "Snygg: fulla rader, inget ensamt ord på den sista"
|
||||
"Set hug contents" = "Ställ in Omslut innehåll"
|
||||
"Set fill container" = "Ställ in Fyll behållaren"
|
||||
"Double-click an edge of an auto layout frame" = "Dubbelklicka på en kant av en auto layout-frame"
|
||||
"{name}, disconnected from its live session" = "{name}, frånkopplad från sin livesession"
|
||||
"⌥ double-click an edge of an auto layout child" = "⌥ dubbelklicka på en kant av ett underordnat objekt i auto layout"
|
||||
"Alt+double-click an edge of an auto layout child" = "Alt+dubbelklicka på en kant av ett underordnat objekt i auto layout"
|
||||
"Default" = "Standard"
|
||||
"Default content" = "Standardinnehåll"
|
||||
"Default value (the top-left variant)" = "Standardvärde (varianten längst upp till vänster)"
|
||||
@@ -494,6 +531,7 @@ name = "Svenska"
|
||||
"Edit dev resource" = "Redigera utvecklarresurs"
|
||||
"Edit link" = "Redigeringslänk"
|
||||
"Edit link copied" = "Redigeringslänken har kopierats"
|
||||
"Edit padding on all sides" = "Redigera padding på alla sidor"
|
||||
"Edit style" = "Redigera stil"
|
||||
"Edit variable" = "Redigera variabel"
|
||||
"Edit with the builder" = "Redigera med byggaren"
|
||||
@@ -531,6 +569,7 @@ name = "Svenska"
|
||||
"Exclusion" = "Uteslutning"
|
||||
"Exit focus view (Esc)" = "Lämna fokusvyn (Esc)"
|
||||
"Expand" = "Expandera"
|
||||
"Expand the interface" = "Expandera gränssnittet"
|
||||
"Export" = "Exportera"
|
||||
"Export all" = "Exportera alla"
|
||||
"Export frames and layers as PNG, JPG, SVG or PDF" = "Exportera frames och lager som PNG, JPG, SVG eller PDF"
|
||||
@@ -1073,6 +1112,7 @@ name = "Svenska"
|
||||
"Pages: {pages}" = "Sidor: {pages}"
|
||||
"Paint bucket" = "Färghink"
|
||||
"Pan the canvas" = "Panorera arbetsytan"
|
||||
"Show and move the placement crosshair (its tool chosen, nothing selected)" = "Visa och flytta placeringshårkorset (med dess verktyg valt och inget markerat)"
|
||||
"Pan with nothing selected (Shift: farther)" = "Panorera när inget är markerat (Shift: längre)"
|
||||
"Panels" = "Paneler"
|
||||
"Paper" = "Papper"
|
||||
@@ -1297,6 +1337,7 @@ name = "Svenska"
|
||||
"See all templates" = "Se alla mallar"
|
||||
"Select layers and they space themselves as their content changes" = "Markera lager så fördelar de avståndet själva när innehållet ändras"
|
||||
"SHA-256: {sha}…" = "SHA-256: {sha}…"
|
||||
"Same as file ({profile})" = "Samma som filen ({profile})"
|
||||
"Same as layer" = "Samma som lagret"
|
||||
"Saturation" = "Mättnad"
|
||||
"Save" = "Spara"
|
||||
@@ -1969,6 +2010,17 @@ name = "Svenska"
|
||||
"⌘ click" = "⌘ klicka"
|
||||
"⌘ scroll" = "⌘ rulla"
|
||||
"⌥ drag" = "⌥ dra"
|
||||
"Selection box: switch to pick select" = "Markeringsruta: växla till plockmarkering"
|
||||
"Pick select: next or previous layer" = "Plockmarkering: nästa eller föregående lager"
|
||||
"Pick select: into a layer, out to its parent" = "Plockmarkering: in i ett lager, ut till dess överordnade"
|
||||
"Pick select: select the picked layer" = "Plockmarkering: markera det valda lagret"
|
||||
"⇥ or ⌥ Space again" = "⇥ eller ⌥ Blanksteg igen"
|
||||
"Tab or Ctrl+Space again" = "Tab eller Ctrl+Blanksteg igen"
|
||||
"↵ / ⇧↵" = "↵ / ⇧↵"
|
||||
"Enter / Shift+Enter" = "Enter / Shift+Enter"
|
||||
"Space" = "Blanksteg"
|
||||
"Nothing to pick: the page is empty" = "Inget att välja: sidan är tom"
|
||||
"Pick select: Tab and Shift+Tab move between layers, Enter goes into one, Shift+Enter out, Space selects, Escape cancels" = "Plockmarkering: Tab och Shift+Tab flyttar mellan lager, Enter går in i ett, Shift+Enter ut, Blanksteg markerar, Esc avbryter"
|
||||
|
||||
[commands]
|
||||
|
||||
@@ -2005,6 +2057,7 @@ name = "Svenska"
|
||||
"board.label" = "Kopplingsetikett"
|
||||
"board.nextSticky" = "Nästa lapp"
|
||||
"board.reattach" = "Koppla om koppling"
|
||||
"board.reshape" = "Omforma koppling"
|
||||
"board.setColor" = "Whiteboardfärg"
|
||||
"board.setStyle" = "Whiteboardstil"
|
||||
"board.shape" = "Form med text"
|
||||
@@ -2047,6 +2100,7 @@ name = "Svenska"
|
||||
"collab.openFile" = "Öppna onlinefil"
|
||||
"collab.peers" = "Personer i den här filen"
|
||||
"collab.publish" = "Publicera på server"
|
||||
"collab.reconnect" = "Återanslut livesessionen"
|
||||
"collab.recoverFile" = "Återställ onlinefil"
|
||||
"collab.registerLinks" = "Öppna inbjudningslänkar i FigCraft"
|
||||
"collab.removePerson" = "Ta bort från session"
|
||||
@@ -2195,6 +2249,7 @@ name = "Svenska"
|
||||
"image.deletePreset" = "Radera förinställning"
|
||||
"image.place" = "Placera bild"
|
||||
"image.presets" = "Bildförinställningar"
|
||||
"image.replace" = "Ersätt bild"
|
||||
"image.savePreset" = "Spara förinställning"
|
||||
"inspect.assets" = "Resurser"
|
||||
"inspect.code" = "Kod"
|
||||
@@ -2212,6 +2267,7 @@ name = "Svenska"
|
||||
"layers.collapseAll" = "Fäll ihop alla"
|
||||
"layout.add" = "Lägg till auto layout"
|
||||
"layout.remove" = "Ta bort auto layout"
|
||||
"layout.removeAll" = "Ta bort all auto layout"
|
||||
"layout.setTrack" = "Ange spår"
|
||||
"layout.suggest" = "Föreslå layout"
|
||||
"libraries.open" = "Öppna bibliotek"
|
||||
@@ -2306,6 +2362,9 @@ name = "Svenska"
|
||||
"pref.showDimensions" = "Visa dimensioner på objekt"
|
||||
"pref.startOnHome" = "Börja på Start"
|
||||
"pref.uiScale" = "Gränssnittsstorlek"
|
||||
"pref.uiScaleDown" = "Mindre gränssnitt"
|
||||
"pref.uiScaleReset" = "Standardstorlek för gränssnittet"
|
||||
"pref.uiScaleUp" = "Större gränssnitt"
|
||||
"pref.userName" = "Ditt namn"
|
||||
"proto.action.add" = "Lägg till prototypåtgärd"
|
||||
"proto.action.move" = "Ändra ordning på prototypåtgärd"
|
||||
@@ -2353,6 +2412,7 @@ name = "Svenska"
|
||||
"selection.applyColorStyle" = "Använd färgformatmall på markeringen"
|
||||
"selection.bindColorVariable" = "Använd färgvariabel på markeringen"
|
||||
"selection.colors" = "Markeringsfärger"
|
||||
"selection.createColorStyle" = "Skapa färgstil från markeringen"
|
||||
"selection.detachColor" = "Koppla bort markeringsfärg"
|
||||
"selection.selectColor" = "Markera lager med den här färgen"
|
||||
"selection.setPaint" = "Ändra markeringens färg"
|
||||
@@ -2399,6 +2459,10 @@ name = "Svenska"
|
||||
"text.caseUpper" = "Versaler"
|
||||
"text.create" = "Text"
|
||||
"text.decoration" = "Dekoration"
|
||||
"text.direction" = "Textriktning"
|
||||
"text.directionAuto" = "Textriktning: auto"
|
||||
"text.directionLtr" = "Textriktning: vänster till höger"
|
||||
"text.directionRtl" = "Textriktning: höger till vänster"
|
||||
"text.edit.begin" = "Redigera text"
|
||||
"text.edit.caret" = "Placera markören"
|
||||
"text.edit.end" = "Sluta redigera text"
|
||||
@@ -2423,6 +2487,7 @@ name = "Svenska"
|
||||
"text.missingFonts" = "Saknade typsnitt"
|
||||
"text.numberList" = "Numrerad lista"
|
||||
"text.onPath" = "Text på bana"
|
||||
"text.openLink" = "Öppna länk"
|
||||
"text.paragraphIndent" = "Styckeindrag"
|
||||
"text.paragraphSpacing" = "Styckeavstånd"
|
||||
"text.pathOptions" = "Alternativ för textbana"
|
||||
@@ -2442,9 +2507,11 @@ name = "Svenska"
|
||||
"text.underline" = "Understrykning"
|
||||
"text.weightDown" = "Minska teckenvikten"
|
||||
"text.weightUp" = "Öka teckenvikten"
|
||||
"text.wrapStyle" = "Radbrytningsstil"
|
||||
"tool.arrow" = "Pil"
|
||||
"tool.comment" = "Kommentar"
|
||||
"tool.connector" = "Koppling"
|
||||
"tool.crosshair" = "Flytta placeringshårkorset"
|
||||
"tool.ellipse" = "Ellips"
|
||||
"tool.eraser" = "Suddgummi"
|
||||
"tool.frame" = "Frame"
|
||||
@@ -2488,6 +2555,7 @@ name = "Svenska"
|
||||
"variable.deleteCollection" = "Radera samling"
|
||||
"variable.duplicate" = "Duplicera variabel"
|
||||
"variable.export" = "Exportera variabler"
|
||||
"variable.extendCollection" = "Utöka samlingen"
|
||||
"variable.import" = "Importera variabler"
|
||||
"variable.list" = "Lista variabler"
|
||||
"variable.moveMode" = "Flytta läge"
|
||||
@@ -2561,6 +2629,7 @@ name = "Svenska"
|
||||
"view.layoutGrids" = "Layoutrutnät"
|
||||
"view.leftSidebar" = "Visa/dölj vänster sidofält"
|
||||
"view.maskOutlines" = "Konturlinjer för masker"
|
||||
"view.minimize" = "Minimera gränssnittet"
|
||||
"view.multiplayerCursors" = "Samarbetsmarkörer"
|
||||
"view.nextFrame" = "Zooma till nästa frame"
|
||||
"view.nextPage" = "Nästa sida"
|
||||
|
||||
@@ -1575,7 +1575,6 @@ status = "draft — needs native review"
|
||||
"Texts" = "உரைகள்"
|
||||
"Texture" = "அமைப்பு"
|
||||
"The bars are hidden. {shortcut} shows them again" = "பட்டைகள் மறைக்கப்பட்டுள்ளன. {shortcut} அவற்றை மீண்டும் காட்டும்"
|
||||
"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "கேன்வாஸ் நிறங்களை sRGB இல் காட்டுகிறது; ஏற்றுமதி செய்த PNG, JPG, WebP கோப்புகள் Display P3 எனக் குறிக்கப்படும்."
|
||||
"The destination document is unavailable. Nothing was imported." = "இலக்கு ஆவணம் கிடைக்கவில்லை. எதுவும் இறக்குமதி செய்யப்படவில்லை."
|
||||
"The destination document was closed. Nothing was imported." = "இலக்கு ஆவணம் மூடப்பட்டது. எதுவும் இறக்குமதி செய்யப்படவில்லை."
|
||||
"The destination page was removed. Nothing was imported." = "இலக்குப் பக்கம் அகற்றப்பட்டது. எதுவும் இறக்குமதி செய்யப்படவில்லை."
|
||||
|
||||
@@ -19,12 +19,19 @@ fn id(v: serde_json::Value) -> NodeId {
|
||||
fn setup() -> (Ui, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let mut variants = Vec::new();
|
||||
let mut icons = Vec::new();
|
||||
for (k, name) in ["Size=Small, State=Default", "Size=Large, State=Default", "Size=Small, State=Hover"].iter().enumerate() {
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": k * 60, "width": 80, "height": 40, "name": name})).unwrap());
|
||||
// A layer in every variant reads "Icon": an instance lists only the properties its variant uses.
|
||||
let icon = id(s.execute("shape.rectangle", &json!({"x": 4, "y": 4, "width": 10, "height": 10, "parent": f})).unwrap());
|
||||
icons.push(icon);
|
||||
variants.push(id(s.execute("component.create", &json!({"ids": [f]})).unwrap()));
|
||||
}
|
||||
let set = id(s.execute("component.combineVariants", &json!({"ids": variants})).unwrap());
|
||||
s.execute("component.addProperty", &json!({"id": set, "name": "Icon", "type": "boolean", "default": true})).unwrap();
|
||||
s.execute("component.addProperty", &json!({"id": set, "name": "Icon", "type": "boolean", "default": true, "layer": icons[0]})).unwrap();
|
||||
for icon in &icons[1..] {
|
||||
s.execute("component.bindProperty", &json!({"layer": icon, "property": "Icon"})).unwrap();
|
||||
}
|
||||
let inst = id(s.execute("instance.create", &json!({"component": set, "x": 400, "y": 0})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [set]})).unwrap();
|
||||
(Ui::new(s), set, inst)
|
||||
@@ -123,3 +130,25 @@ fn escape_cancels_a_property_drag_and_keeps_the_selection() {
|
||||
assert_eq!(undo_len(&u), undo);
|
||||
assert_eq!(u.app.session.ds().unwrap().selection, [set], "the Escape only cancels the drag");
|
||||
}
|
||||
|
||||
/// A long property name is cut to one line inside its label box instead of wrapping over the rows
|
||||
/// above and below (#2022); the name itself stays whole.
|
||||
#[test]
|
||||
fn a_long_property_name_stays_on_one_line() {
|
||||
let long = "Flag Default label with a long name";
|
||||
let mut s = Session::new();
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 120, "height": 40, "name": "Chip"})).unwrap());
|
||||
let t = id(s.execute("text.create", &json!({"text": "Label", "x": 4, "y": 4, "parent": f})).unwrap());
|
||||
let c = id(s.execute("component.create", &json!({"ids": [f]})).unwrap());
|
||||
s.execute("component.addProperty", &json!({"id": c, "name": long, "type": "text", "layer": t})).unwrap();
|
||||
let inst = id(s.execute("instance.create", &json!({"component": c, "x": 400, "y": 0})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [inst]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
let texts = crate::export_name_tests::painted(&mut u);
|
||||
let panel = u.app.ui.right_panel_rect.expect("the right panel shows");
|
||||
let labels: Vec<(String, egui::Rect)> = texts.into_iter().filter(|(t, r)| t.starts_with("Flag") && r.min.x >= panel.min.x).collect();
|
||||
let [(text, rect)] = labels.as_slice() else { panic!("one property label: {labels:?}") };
|
||||
assert!(text.ends_with('…'), "cut with an ellipsis, not {text:?}");
|
||||
assert!(rect.height() <= crate::widgets::FIELD_H, "one line, not {} tall", rect.height());
|
||||
assert_eq!(names(&mut u, inst), [long], "the name itself stays whole");
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use crate::layers_drag_tests::Ui;
|
||||
use crate::widgets::FIELD_H;
|
||||
|
||||
/// The text painted this frame, with each piece's box.
|
||||
fn painted(u: &mut Ui) -> Vec<(String, Rect)> {
|
||||
pub(crate) fn painted(u: &mut Ui) -> Vec<(String, Rect)> {
|
||||
fn walk(shape: &Shape, out: &mut Vec<(String, Rect)>) {
|
||||
match shape {
|
||||
Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
|
||||
|
||||
@@ -14,8 +14,8 @@ use egui::{Id, Sense, Ui, vec2};
|
||||
use figcraft_doc::{Document, Node, NodeId, NodeKind, PropDef, VariableType};
|
||||
use figcraft_engine::cmd::component::{can_add_variant, can_combine_variants};
|
||||
use figcraft_engine::components::{
|
||||
PROP_BINDING, default_variant, inside_instance, owning_component, owning_instance, prop_list, slot_def, slot_edited, slots_of, variant_conflicts,
|
||||
variant_options, variant_props, variant_set,
|
||||
PROP_BINDING, default_variant, inside_instance, instance_prop_list, owning_component, owning_instance, prop_list, slot_def, slot_edited,
|
||||
slots_of, variant_conflicts, variant_options, variant_props, variant_set,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -748,7 +748,7 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps
|
||||
}
|
||||
}
|
||||
// Boolean, text and instance-swap properties.
|
||||
for (k, d) in prop_list(doc, *component) {
|
||||
for (k, d) in instance_prop_list(doc, *component) {
|
||||
let v = props.get(&k).cloned().unwrap_or_else(|| figcraft_engine::components::prop_default_value(&d));
|
||||
row(ui, 32.0, |ui| {
|
||||
prop_label(ui, &k, type_icon(&d));
|
||||
@@ -968,8 +968,12 @@ fn comp_rows(ui: &mut Ui, found: &CompSearch, checked: &impl Fn(NodeId) -> bool)
|
||||
|
||||
fn prop_label(ui: &mut Ui, name: &str, icon: Icon) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(84.0, 20.0), Sense::hover());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(84.0, 20.0), Sense::hover());
|
||||
icons::paint(ui.painter(), egui::Rect::from_min_size(egui::pos2(r.min.x, r.center().y - 6.0), vec2(12.0, 12.0)), icon, t.icon_secondary);
|
||||
let g = ui.painter().layout(name.to_string(), crate::theme::regular(11.0), t.text_secondary, r.width() - 18.0);
|
||||
let shown = widgets::elide(ui, name, r.width() - 18.0);
|
||||
let g = ui.painter().layout_no_wrap(shown.clone(), crate::theme::regular(11.0), t.text_secondary);
|
||||
ui.painter().galley(egui::pos2(r.min.x + 18.0, r.center().y - g.size().y / 2.0), g, t.text_secondary);
|
||||
if shown != name {
|
||||
resp.on_hover_text(name);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (with several layers selected the Design panel header's component button offers Create multiple components (#2088); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (an instance lists only the properties its variant's layers use and long property names are cut to one line (#2022); with several layers selected the Design panel header's component button offers Create multiple components (#2088); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
|
||||
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
|
||||
@@ -12,6 +12,8 @@ accounts).
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---|---|---|
|
||||
| 2026-11-11 | minor | M3, #2022: an instance lists only the properties its variant's layers use (its variant properties, slots, and the boolean, text and instance swap properties a layer of its current variant reads), as measured in Figma's web app: an instance of variant A lists `State` and `A label`, never `B label` or a property no layer reads. The set and its variants still list every definition, so unused ones can be deleted there. A long property name in the panel is cut to one line with an ellipsis, the full name on hover, instead of wrapping over the rows around it. Tests: `prop_order_tests::an_instance_lists_only_the_properties_its_variant_uses`, `component_props_reorder_tests::a_long_property_name_stays_on_one_line`, `prop_value_tests::a_set_keeps_each_named_text_definition_and_layers_read_their_own`. Grade unchanged. |
|
||||
| Create component, instances, detach, reset, go to main, restore main, push changes | done | push from a nested layer goes to the outer main |
|
||||
| Create component, instances, detach, reset, go to main, restore main, push changes | done | push from a nested layer goes to the outer main; with two or more layers selected the Design panel header's component button opens a menu with Create component, Create multiple components (each layer its own component in place, one undo step) and Create component set (each layer a component, wrapped in place in a set 20 px around: variants `Property 1=<layer name>` left to right, `prop=value` names kept, layers that all share a name give a set of that name with `Property 1=Default` variants; measured in Figma), one layer keeps the plain click (#2088) |
|
||||
| Per-layer overrides with reset; kept across main edits, nested swaps, copy/paste | done | main edits match a full rebuild exactly at 2,000 instances |
|
||||
| What an instance overrides: fills, strokes, effects, visibility, layer names, text content and every text property (resizing included), auto layout padding, gap and alignment, layout grids, export settings, prototype interactions, nested swaps and property values; the size and resizing (hug, fill, fixed, min and max) of layers inside it | done | each kept when the main changes that property, reset per group; opacity and corner radius too (documentation silent); size and resizing are kept through relayout, undo, save, `.fig` and REST import and detach, and stay with the instance on push |
|
||||
@@ -22,7 +24,7 @@ accounts).
|
||||
| Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | |
|
||||
| Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | |
|
||||
| Variant property order (names rewritten), duplicate combinations reported | done | drag a property in the Properties section to reorder it, one undo step (#679), or `component.editProperty {index}` |
|
||||
| Properties: boolean, text, instance swap (preferred values), variant | done | listed in the order they were made, a rename keeping the place (#1536; the shared ones don't reorder by drag yet); every component menu scrolls past 320 pt, the default of an instance-swap property's too (#1338); the instance-swap menus (an instance's property and swap menus, a property's default, the playground) open with a search field that filters by name as you type, with Up, Down and Enter to pick and Escape to clear then close; the slot and preferred components menus have no search yet |
|
||||
| Properties: boolean, text, instance swap (preferred values), variant | done | listed in the order they were made, a rename keeping the place (#1536; the shared ones don't reorder by drag yet); every component menu scrolls past 320 pt, the default of an instance-swap property's too (#1338); the instance-swap menus (an instance's property and swap menus, a property's default, the playground) open with a search field that filters by name as you type, with Up, Down and Enter to pick and Escape to clear then close; the slot and preferred components menus have no search yet; an instance lists the properties its variant's layers use, so a set with one text property per variant shows one text field per instance (#2022; measured in Figma's web app, 2026-11-11); long names are cut to one line, the full name on hover |
|
||||
| Slots (preferred components, fill menu, kept across updates) | done | Figma 2025 feature; Make slot asks for the name first (a field under the frame's Slot row), and slots are renamed in the component's Properties list (#1537); no rename with only the slot's frame selected yet |
|
||||
| Exposed nested instances | done | hidden ones hide their properties |
|
||||
| Descriptions | done | |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hardware and platform parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** patch (under Wayland a minimised window answers control and MCP requests in about half a second instead of timing out after 60 s (#1906); images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** patch (Windows: the custom title bar's windows stay borderless, so the content is no longer drawn about 30 px low with clicks landing above it (#1343); under Wayland a minimised window answers control and MCP requests in about half a second instead of timing out after 60 s (#1906); images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
|
||||
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
|
||||
@@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-10 | patch | M10, #1343 (also #1422, #2233): Windows: the window no longer draws below a hidden caption. FigCraft draws its own title bar on Windows and opens undecorated windows, but winit 0.30 still gives them `WS_CAPTION` and `WS_BORDER`; on some systems the desktop compositor reserves that caption, so the content was drawn about 30 px low (a black band above it) while clicks kept the client coordinates and landed above what was drawn. A per-frame step (`apps/figcraft/src/window_frame.rs`, safe `winsafe` calls, Windows only) clears `WS_CAPTION` and `WS_BORDER` from every FigCraft window, the main one and the others, keeping the sizing border and window buttons so edge resizing and snap work, and writes only when a style changed (winit puts them back on maximize and restore). The same fix as DesignCraft #2139 and PhotoCraft #2401; the maximize-to-work-area trim from DesignCraft is left for a later change. Not run on a Windows machine that shows the offset: the style math and the per-frame hook are tested, the window itself needs a live check. Tests: `window_frame_tests`. Numbers unchanged |
|
||||
| 2026-11-06 | patch | M8, #1906: under Wayland (seen on GNOME and KDE Plasma) a minimised FigCraft window still reports itself visible and the compositor stops asking it to draw, so every control or attached-MCP request waited for a frame and timed out after 60 s ("it was not applied"), though eframe kept running the app's logic. `control::background` now treats a window that drew no frame for `NOT_DRAWING_AFTER` (0.2 s) while a request waited for it as hidden: state-only requests are answered from the logic passes (0.54 s each, measured on GNOME Wayland; they took 13.7 s or timed out before), the wait starts again when the window draws, and screenshots and `ui.input` still wait for a frame. Test: `idle_tests::a_window_that_stopped_drawing_answers_from_its_logic_passes` |
|
||||
| 2026-10-31 | minor | M1, #492: Web row: the browser pastes images copied in other apps, read from the paste event under the same limits as the desktop. Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M8: text reads only the fallback fonts it draws with. Each fallback family reads the face nearest a run's style, not all of them (Noto Sans CJK is seven files of 16–20 MB); families naming the faces of one file share its bytes; and the faces are worked out once per style until fonts change, not for every text laid out. First text laid out on a Linux machine with Noto Sans CJK, DejaVu Sans and Inter: 41 files (265 MiB, kept in memory) and 41 faces parsed → 3 files (20 MiB) and 4 faces; fallback lookups per text laid out ~45 µs → 0.2 µs. Tests: `fontdb_fallback_tests`. Numbers unchanged |
|
||||
|
||||
+6
-3
@@ -691,8 +691,11 @@ on; `key` resolves keys the way the canvas does. Commands behind keys:
|
||||
panel has the keyboard (after F6 or a click on a row), else on the canvas below the selection; the
|
||||
menu takes the keyboard and gives it back when it closes (no-op headless).
|
||||
- `select.area {x, y, width, height, add?, deep?}`: select what a marquee from `x, y` over that box
|
||||
(page units) would: top-level layers, a frame only when the box covers it, the children of the
|
||||
top-level frame the box starts in; `add` toggles like a Shift marquee, `deep` takes nested layers
|
||||
(page units) would: the top-level layers it touches (a rotated shape by its outline); an empty
|
||||
frame on touch; any other frame or section only when the box covers it, otherwise the layers in it
|
||||
the box touches, except that once the box takes a frame whole, a partly covered frame comes whole
|
||||
too, and the frame the box starts in is selected only then (when the box also takes another
|
||||
top-level frame); `add` toggles like a Shift marquee, `deep` takes nested layers
|
||||
like Cmd/Ctrl. Returns `{selection}`. Without a box (`F8` in the app) it draws one for the
|
||||
keyboard around the selection (or in the middle of the view): the arrows move it, Shift+arrows
|
||||
move its far corner (10 screen pixels a press), Enter selects (Shift+Enter adds, Cmd/Ctrl+Enter
|
||||
@@ -2075,7 +2078,7 @@ turned off 0°.
|
||||
/ Shift+Enter), `select.next` / `select.previous` (Tab / Shift+Tab). With `pointer`, `cmd: true`
|
||||
clicks select the nested layer, `cmd` drags from empty canvas select the nested layers they touch,
|
||||
and `shift` drags toggle what they touch. A press on the background of a top-level frame with
|
||||
layers in it (the frame not selected) starts a marquee inside it, as on the canvas; an empty frame
|
||||
layers in it (the frame not selected) starts a marquee, as on the canvas; an empty frame
|
||||
is picked and dragged. Locked layers (selectable with `select.set`) get no handles and stay put
|
||||
when the rest of the selection is dragged. `select.all` / `select.inverse` skip hidden and locked
|
||||
layers.
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (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+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variaLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (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+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colourLine 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:
|
||||
@@ -56,7 +56,7 @@ interaction feel not yet compared.
|
||||
|
||||
| Item | Status | Notes |
|
||||
|---|---|---|
|
||||
| Click, Shift-click, marquee, Shift-marquee toggle | done | Shift-drag on a selected layer moves the selection, kept to one axis (#498); a marquee selects what it touches; from a top-level frame's background, the layers inside it; locked and hidden layers are skipped (`selection_behaviour_tests`) (a locked layer selected from the panel ignores the arrow keys and the nudge commands; its properties still edit; measured in Figma's web app, 2026-11-10) |
|
||||
| Click, Shift-click, marquee, Shift-marquee toggle | done | Shift-drag on a selected layer moves the selection, kept to one axis (#498); a marquee selects the top-level layers it touches, by the box and a rotated shape by its outline (rectangle and ellipse measured, the other shapes by the same code); a partly covered top-level frame or section gives the layers in it the marquee touches, an enclosed one is selected itself, an empty frame on touch; a drag from a frame's background selects that frame only when the marquee also takes another top-level frame (then whole), and also takes what it touches outside it; selecting a frame whole keeps the marquee at the top level (a partly covered frame then comes whole too); locked and hidden layers are skipped (`marquee_behaviour_tests`, `selection_behaviour_tests`; measured in Figma's web app, 2026-11-11; Shift and Cmd/Ctrl marquees not measured) (a locked layer selected from the panel ignores the arrow keys and the nudge commands; its properties still edit; measured in Figma's web app, 2026-11-10) |
|
||||
| Deep select (Cmd/Ctrl-click, Cmd/Ctrl-marquee) | done | |
|
||||
| Select layer menu (every layer under the click) | done | |
|
||||
| Escape with layers selected | done | clears the selection at once, whatever the nesting; it never selects the parent. Measured in the Figma web app (2026-11-08) with a rectangle in a group in a frame, selected from the canvas (a double-click into the group), from the Layers panel, and after Tab moved the selection to its sibling: one Escape selects nothing, a second leaves nothing selected. Escape still leaves text and vector editing, ends gestures and the pen first, and ends the focus view once nothing or the focused frame is selected (`selection_behaviour_tests`, `devfocus_tests`) |
|
||||
@@ -188,6 +188,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-11 | patch | M5, gap 4: marquee selection as measured in Figma's web app (Select layers and objects, Selection marquee): a partly covered top-level frame or section gives the layers in it the marquee touches (it was selected only when enclosed, so nothing came), an empty frame is selected on touch, a rotated shape is touched by its outline and not its box, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole), and also takes the top-level layers it touches outside it, and selecting a frame whole keeps the marquee at the top level (a partly covered frame then comes whole, not its layers). Flipped: `marquee_selects_what_it_touches`, `cmd_marquee_selects_nested_layers`, `a_box_selects_like_a_marquee`. Tests: `marquee_behaviour_tests`. Counts unchanged |
|
||||
| 2026-11-08 | minor | Vector edit mode, #1875, measured in Figma (web, real keys): Copy with segments selected takes only those (with just a point selected, the segments touching it) and a paste outside edit mode is a new vector layer in place; Cut removes them, the path splitting into subpaths in the same layer with edit mode staying on, and a paste outside gives a new layer with the cut segments; Paste while editing a vector adds the clipboard's segments, or a copied layer's outline, to the same network in place, selecting the pasted points, with no new layer and the bounds growing. FigCraft copied the whole layer, Cut left nothing to paste and Paste did nothing. `edit.copy`, `edit.cut` and `edit.paste` act so with no `ids`/`parent` given (Ctrl+X is the real Cut again; Shift+Delete still heals); the copy carries the layer's look (fills, strokes, effects, opacity, blend; the width profile only when every segment is copied); layers with no outline are skipped. Tests: `vector_clipboard_tests` |
|
||||
| 2026-11-08 | patch | Selection: Escape with layers selected clears the selection instead of stepping out to the parent first. Measured in the Figma web app with a rectangle in a group in a frame, selected from the canvas, from the Layers panel and after Tab: one Escape selects nothing. Editing, gestures and the pen still end first |
|
||||
| 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) |
|
||||
|
||||
Reference in new issue
Block a user