mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-22 19:08:51 +00:00
Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b08ea1127 | ||
|
|
61ccfe18a8 | ||
|
|
d1125130f6 | ||
|
|
ddd42b36bd | ||
|
|
6743c68203 | ||
|
|
caa3c5afce | ||
|
|
8d3a3e6171 | ||
|
|
d7a27c593e | ||
|
|
7d51d0515d | ||
|
|
ef535e49b0 | ||
|
|
e35f8eb1b7 | ||
|
|
55fbcd0d7e | ||
|
|
8629a6f586 | ||
|
|
99e5db399b | ||
|
|
247530b60e | ||
|
|
ae89635e00 | ||
|
|
633455c380 | ||
|
|
846a9f7214 | ||
|
|
1c158ed81b | ||
|
|
c3a21a6ab3 | ||
|
|
976bbb84ef | ||
|
|
78fd30ab5f | ||
|
|
4458eb8802 | ||
|
|
d1619f57bd | ||
|
|
eac260f160 | ||
|
|
a819e0a188 | ||
|
|
3a9610d480 | ||
|
|
ac606a2d43 | ||
|
|
f90becadab | ||
|
|
6a334fa354 | ||
|
|
2a3ea27605 | ||
|
|
d654a14720 | ||
|
|
d828e6379e | ||
|
|
e46dda1036 | ||
|
|
1768be2965 | ||
|
|
0f8e29bc9f | ||
|
|
47c2b228a5 | ||
|
|
d712f2ffd1 | ||
|
|
9b489a052e | ||
|
|
c8e93ddaaa | ||
|
|
52e3fe2967 | ||
|
|
78222e35d2 | ||
|
|
e9be17427f | ||
|
|
1fe78aec66 | ||
|
|
1f0d9a9107 | ||
|
|
dd980c884f | ||
|
|
1e7983a550 | ||
|
|
bc5a2b8fa7 | ||
|
|
7b9ebaacbb | ||
|
|
1c16aeae09 | ||
|
|
a672a6650f |
No files matched your search
@@ -107,10 +107,13 @@ impl eframe::App for App {
|
||||
}
|
||||
|
||||
fn on_exit(&mut self) {
|
||||
with_app(|fig| services::save_state(fig));
|
||||
if let Some(r) = recovery::lock(&self.recovery).take() {
|
||||
r.finish();
|
||||
}
|
||||
// With the app out of reach (busy), its recovery copies stay for the next start.
|
||||
let copies = recovery::lock(&self.recovery).take();
|
||||
with_app(|fig| services::shutdown(fig, services::config_dir().as_deref(), copies));
|
||||
// Everything is kept: end before the windows' teardown, where AppKit's Touch Bar support
|
||||
// throws on the content view AccessKit restores (craft-libs#3, #781).
|
||||
#[cfg(target_os = "macos")]
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,8 +120,7 @@ impl Recovery {
|
||||
(0..10).find_map(|n| {
|
||||
let dir = root.join(if n == 0 { pid.to_string() } else { format!("{pid}-{n}") });
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).ok()?;
|
||||
lock.try_lock().ok()?;
|
||||
let lock = lock_folder(&dir).ok()?;
|
||||
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() })
|
||||
})
|
||||
}
|
||||
@@ -250,6 +249,17 @@ impl Recovery {
|
||||
self.written.insert(uid, Copy { done: None, flight: Some((vec![revision], writer)) });
|
||||
}
|
||||
|
||||
/// Quitting: a normal quit ([`finish`](Self::finish)) when the unsaved-changes question was
|
||||
/// `asked` or nothing is unsaved. Otherwise (macOS's Quit menu ends the app without asking) the
|
||||
/// unsaved documents are copied and the copies stay for the next start, as after a crash.
|
||||
pub fn quit(mut self, s: &Session, asked: bool) {
|
||||
if asked || !s.docs.iter().any(|d| needs_copy(s, d)) {
|
||||
return self.finish();
|
||||
}
|
||||
self.write(s, true);
|
||||
self.settle(s);
|
||||
}
|
||||
|
||||
/// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier
|
||||
/// runs' copies still waiting for their outcome stay for the next start.)
|
||||
pub fn finish(self) {
|
||||
@@ -282,6 +292,17 @@ fn remove_kept(w: Waiting) {
|
||||
/// The extension of the note beside the copy of a document that is one of your online files.
|
||||
const ONLINE: &str = "online";
|
||||
|
||||
/// How long the startup waits, once, for folder locks that may free themselves (#334).
|
||||
#[cfg(unix)]
|
||||
const LOCK_WAIT: std::time::Duration = std::time::Duration::from_millis(100);
|
||||
|
||||
/// Lock the folder `dir` (`WouldBlock` while another process holds it).
|
||||
fn lock_folder(dir: &Path) -> Result<File, std::fs::TryLockError> {
|
||||
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).map_err(std::fs::TryLockError::Error)?;
|
||||
lock.try_lock()?;
|
||||
Ok(lock)
|
||||
}
|
||||
|
||||
/// The copy at `file` is of one of your online files: join that file again as its owner, keeping
|
||||
/// the copy to compare with what arrives. The session, or `None` when the copy isn't one (or its
|
||||
/// file isn't recorded any more, or is open already): then it opens as any other copy.
|
||||
@@ -351,6 +372,7 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
|
||||
}
|
||||
}
|
||||
};
|
||||
let (mut free, mut held) = (Vec::new(), Vec::new());
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
if !path.is_dir() {
|
||||
@@ -358,10 +380,22 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
|
||||
continue;
|
||||
}
|
||||
// Still running (another window of the app), or not ours to take.
|
||||
let Ok(lock) = File::options().write(true).create(true).truncate(false).open(path.join(LOCK)) else { continue };
|
||||
if lock.try_lock().is_err() {
|
||||
continue;
|
||||
match lock_folder(&path) {
|
||||
Ok(lock) => free.push((path, lock)),
|
||||
Err(std::fs::TryLockError::WouldBlock) => held.push(path),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
// A child between its `fork` and its `exec` shares the run's open descriptors, and a `flock`
|
||||
// lasts while any of them is open, so an ended run's folder can look held for a moment. Wait
|
||||
// once, however many folders are held, and try those again (#334). Windows doesn't pass the
|
||||
// lock to child processes.
|
||||
#[cfg(unix)]
|
||||
if !held.is_empty() {
|
||||
std::thread::sleep(LOCK_WAIT);
|
||||
free.extend(held.into_iter().filter_map(|path| lock_folder(&path).ok().map(|lock| (path, lock))));
|
||||
}
|
||||
for (path, lock) in free {
|
||||
let lock = Arc::new(lock);
|
||||
let files: Vec<PathBuf> = std::fs::read_dir(&path).into_iter().flatten().flatten().map(|f| f.path()).collect();
|
||||
for f in &files {
|
||||
|
||||
@@ -167,6 +167,23 @@ fn a_normal_quit_removes_the_recovered_copies_it_took_over() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_without_the_unsaved_question_keeps_copies_for_the_next_start() {
|
||||
let root = root("quitunasked");
|
||||
// macOS's Quit menu: nothing asked, so the unsaved document comes back next time.
|
||||
let s = edited();
|
||||
Recovery::start(&root).unwrap().quit(&s, false);
|
||||
let mut next = Session::empty();
|
||||
assert_eq!(recover(&root, &mut next), 1);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
// Asked (saved or discarded on purpose), or nothing unsaved: nothing is left.
|
||||
for (s, asked) in [(edited(), true), (Session::new(), false)] {
|
||||
Recovery::start(&root).unwrap().quit(&s, asked);
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "asked: {asked}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_whose_folder_name_is_still_held_takes_another() {
|
||||
let root = root("pidreuse");
|
||||
@@ -186,6 +203,29 @@ fn a_run_whose_folder_name_is_still_held_takes_another() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// #334: a lock that frees itself is not a run that is still going. A child between its `fork` and
|
||||
/// its `exec` inherits the run's open descriptors and a `flock` lasts while any descriptor for the
|
||||
/// file is open, so a crashed run's copies must still come back once that child has exec'd.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_lock_freed_a_moment_later_is_not_a_run_that_is_still_going() {
|
||||
let root = root("fork-window");
|
||||
crashed(&edited(), &root);
|
||||
// Hold the crashed run's folder lock on a second descriptor, the way a child forked before its
|
||||
// exec holds it, and free it shortly after.
|
||||
let lock = std::fs::File::options().write(true).create(true).truncate(false).open(root.join("crashed").join("lock")).unwrap();
|
||||
lock.try_lock().unwrap();
|
||||
let freed = std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
drop(lock);
|
||||
});
|
||||
// The next start waits for that lock rather than reading the run as still going.
|
||||
let mut next = Session::empty();
|
||||
assert_eq!(recover(&root, &mut next), 1, "the copies come back once the lock frees itself");
|
||||
freed.join().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// The rectangles in `doc`.
|
||||
fn rectangles(doc: &figcraft_engine::doc::Document) -> usize {
|
||||
doc.nodes().filter(|n| matches!(n.kind, figcraft_engine::doc::NodeKind::Rectangle)).count()
|
||||
|
||||
@@ -167,10 +167,18 @@ fn reveal(path: &Path) -> std::io::Result<()> {
|
||||
/// The preferences' file in the config folder.
|
||||
const STATE: &str = "state.json";
|
||||
|
||||
pub fn save_state(app: &FigApp) {
|
||||
if let Some(dir) = config_dir() {
|
||||
write_prefs(&dir, &app.session.prefs);
|
||||
/// Everything quitting keeps, all done when it returns (macOS then ends the process): the
|
||||
/// preferences in `dir`, this run's recovery copies ([`Recovery::quit`](crate::recovery::Recovery::quit))
|
||||
/// and the live sessions' ends (one this app hosts tells everyone and stops; a joined one leaves).
|
||||
pub fn shutdown(app: &mut FigApp, dir: Option<&Path>, recovery: Option<crate::recovery::Recovery>) {
|
||||
if let Some(dir) = dir {
|
||||
write_prefs(dir, &app.session.prefs);
|
||||
}
|
||||
// Before the sessions end: a recovered online file's copy waits for its session's outcome.
|
||||
if let Some(r) = recovery {
|
||||
r.quit(&app.session, app.ui.quit);
|
||||
}
|
||||
drop(std::mem::take(&mut app.session.live));
|
||||
}
|
||||
|
||||
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
|
||||
|
||||
@@ -82,3 +82,38 @@ fn preferences_are_readable_by_their_owner_only() {
|
||||
assert_eq!(mode(&dir.join("state.json")), 0o600);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A live link that only records the sessions it was told to leave.
|
||||
struct Leaves(Arc<Mutex<Vec<u64>>>);
|
||||
|
||||
impl figcraft_engine::live::LiveLink for Leaves {
|
||||
fn host(&mut self, _: u64, _: figcraft_engine::live::HostOptions) -> Result<figcraft_engine::live::SessionInfo, String> {
|
||||
Err("not in this test".into())
|
||||
}
|
||||
fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
fn leave(&mut self, session: u64) {
|
||||
self.0.lock().unwrap().push(session);
|
||||
}
|
||||
fn presence(&mut self, _: u64, _: &figcraft_engine::live::Presence) {}
|
||||
fn send(&mut self, _: u64, _: figcraft_engine::figcraft_collab::message::ClientMsg) {}
|
||||
fn poll(&mut self) -> Vec<(u64, figcraft_engine::live::Event)> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_keeps_the_preferences_and_ends_the_live_sessions() {
|
||||
let dir = root("shutdown");
|
||||
let mut fig = start(&dir);
|
||||
let left = Arc::new(Mutex::new(Vec::new()));
|
||||
fig.session.live.install(Box::new(Leaves(left.clone())));
|
||||
let joined = fig.run("collab.join", json!({"link": "ws://127.0.0.1:9/?file=f1&token=EditSecretEditSecret00"})).unwrap();
|
||||
fig.session.prefs.user_name = "Ada".into();
|
||||
crate::services::shutdown(&mut fig, Some(&dir), None);
|
||||
assert_eq!(*left.lock().unwrap(), [joined["session"].as_u64().unwrap()], "the session was left");
|
||||
assert!(!fig.session.live.available(), "nothing is left to send on");
|
||||
assert_eq!(start(&dir).session.prefs.user_name, "Ada", "the preferences were kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -53,3 +53,6 @@ sync_github_name = false
|
||||
[people.krinry]
|
||||
display_name = "krinry"
|
||||
sync_github_name = true
|
||||
|
||||
[people.pechnikov2026-star]
|
||||
sync_github_name = true
|
||||
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"variable.import",
|
||||
"Import variables",
|
||||
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings}",
|
||||
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings (also when it holds no variables, or fewer than its collections list)}",
|
||||
true,
|
||||
import
|
||||
),
|
||||
|
||||
@@ -1744,6 +1744,8 @@ mod proto_run_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_tail_tests;
|
||||
#[cfg(test)]
|
||||
mod rest_slot_tests;
|
||||
#[cfg(test)]
|
||||
mod retained_walk_tests;
|
||||
#[cfg(test)]
|
||||
mod rotated_guides_tests;
|
||||
@@ -1816,6 +1818,8 @@ mod variables_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_editor_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_import_shapes_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_io_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_behaviour_tests;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Slots opened from REST file JSON: instances keep their own slot content through edits and
|
||||
//! moves of the mains, and through saving.
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{ex, healthy, idv};
|
||||
use crate::components::*;
|
||||
use base64::Engine as _;
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn at(x: f64, y: f64) -> Value {
|
||||
json!([[1, 0, x], [0, 1, y]])
|
||||
}
|
||||
|
||||
fn text(id: &str, s: &str) -> Value {
|
||||
json!({"id": id, "name": s, "type": "TEXT", "characters": s, "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}})
|
||||
}
|
||||
|
||||
/// A Chip main, a Container main with an empty "Slot", and an instance of Container whose slot
|
||||
/// holds a text and a chip.
|
||||
fn file() -> Value {
|
||||
let slot = |id: &str, kids: Value| json!({"id": id, "name": "Slot", "type": "SLOT", "relativeTransform": at(10.0, 40.0), "size": {"x": 280, "y": 100}, "children": kids});
|
||||
let guid = |l: u32| json!({"guid": {"sessionID": 5, "localID": l}});
|
||||
let chip = json!({"id": "2:3", "name": "Chip", "type": "INSTANCE", "componentId": "1:9", "relativeTransform": at(0.0, 30.0), "size": {"x": 40, "y": 20},
|
||||
"children": [{"id": "I2:3;1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}]});
|
||||
json!({"name": "Slots", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [
|
||||
{"id": "1:9", "name": "Chip", "type": "COMPONENT", "relativeTransform": at(0.0, 400.0), "size": {"x": 40, "y": 20}, "children": [
|
||||
{"id": "1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}
|
||||
]},
|
||||
{"id": "1:2", "name": "Container", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 160},
|
||||
"componentPropertyDefinitions": {"Slot#1:3": {"type": "SLOT", "defaultValue": guid(1)}},
|
||||
"children": [text("1:4", "Title"), slot("1:5", json!([]))]},
|
||||
{"id": "2:1", "name": "Container", "type": "INSTANCE", "componentId": "1:2", "relativeTransform": at(400.0, 0.0), "size": {"x": 300, "y": 160},
|
||||
"componentProperties": {"Slot#1:3": {"type": "SLOT", "value": guid(7)}},
|
||||
"children": [text("I2:1;1:4", "Title"), slot("I2:1;1:5", json!([text("2:2", "Reports in progress"), chip]))]}
|
||||
]}]}})
|
||||
}
|
||||
|
||||
fn named(doc: &Document, kind: fn(&NodeKind) -> bool, name: &str) -> NodeId {
|
||||
doc.nodes().find(|n| n.name == name && kind(&n.kind)).map(|n| n.id).unwrap_or_else(|| panic!("no {name}"))
|
||||
}
|
||||
|
||||
/// The instance's slot and the names of what it holds.
|
||||
fn slot_content(s: &Session) -> (NodeId, Vec<String>) {
|
||||
let doc = s.doc().unwrap();
|
||||
let inst = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Container");
|
||||
let (slot, _) = slots_of(doc, inst).into_iter().next().expect("the instance has its slot");
|
||||
let names = doc.node(slot).unwrap().children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect();
|
||||
(slot, names)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_slot_content_survives_main_edits_moves_and_saving() {
|
||||
let mut s = Session::new();
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&file()).unwrap());
|
||||
ex(&mut s, "file.open", json!({"data": data, "name": "slots.json"}));
|
||||
healthy(&s);
|
||||
let want = vec!["Reports in progress".to_string(), "Chip".to_string()];
|
||||
let (slot, names) = slot_content(&s);
|
||||
assert_eq!(names, want);
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(slot_name(doc, slot).as_deref(), Some("Slot"));
|
||||
assert!(slot_edited(doc, slot), "the instance's own content");
|
||||
let chip = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Chip");
|
||||
assert!(!inside_instance(doc, chip), "content stands alone");
|
||||
|
||||
// Edit and move both mains (the container's and the one used in the content).
|
||||
let main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Container");
|
||||
let chip_main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Chip");
|
||||
let title = doc.node(main).unwrap().children[0];
|
||||
ex(&mut s, "node.set", json!({"id": title, "props": {"fill": "#ff0000"}}));
|
||||
ex(&mut s, "node.set", json!({"id": main, "props": {"width": 320}}));
|
||||
let holder = idv(&ex(&mut s, "frame.create", json!({"x": -1000, "y": 0, "width": 900, "height": 900, "name": "Design system"})));
|
||||
ex(&mut s, "node.reparent", json!({"ids": [main, chip_main], "parent": holder}));
|
||||
healthy(&s);
|
||||
assert_eq!(slot_content(&s).1, want, "kept across syncs");
|
||||
|
||||
// Saving and opening again keeps it.
|
||||
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
let mut again = Session::new();
|
||||
ex(&mut again, "file.open", json!({"data": data, "name": "slots.figcraft"}));
|
||||
healthy(&again);
|
||||
let (slot, names) = slot_content(&again);
|
||||
assert_eq!(names, want);
|
||||
assert!(slot_edited(again.doc().unwrap(), slot));
|
||||
|
||||
// Resetting the slot shows the main's (empty) default content.
|
||||
ex(&mut again, "instance.resetSlot", json!({"id": slot}));
|
||||
assert!(slot_content(&again).1.is_empty());
|
||||
}
|
||||
@@ -661,6 +661,9 @@ fn on_corner_square(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> bool
|
||||
/// handles: the canvas draws just its outline (the handles still answer just outside it).
|
||||
pub const SMALL_BOX_PX: f64 = 16.0;
|
||||
|
||||
/// The stroke the Pencil draws: its colour and weight (the canvas previews it the same).
|
||||
pub const PENCIL_STROKE: (figcraft_color::Color, f64) = (figcraft_color::Color::BLACK, 2.0);
|
||||
|
||||
/// The shorter and longer sides, in screen pixels, of the box with `corners` (tl, tr, br, bl).
|
||||
pub fn box_sides_px(corners: &[Point; 4], zoom: f64) -> (f64, f64) {
|
||||
let [a, b, _, d] = corners;
|
||||
@@ -2241,8 +2244,8 @@ impl Session {
|
||||
let mut local = fitted.clone();
|
||||
local.apply_affine(pw.inverse());
|
||||
let _ = d;
|
||||
let mut props =
|
||||
json!({"fills": [], "strokes": ["#000000"], "strokeWeight": 2, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"});
|
||||
let (color, weight) = PENCIL_STROKE;
|
||||
let mut props = json!({"fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"});
|
||||
// Pen pressure becomes the stroke's width profile (brushes follow it too).
|
||||
if let Some(points) = pressure_profile(points, pressure) {
|
||||
props["widthProfile"] = json!(points);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! `variable.import` reads `variables` next to `variableCollections`, under `meta` or at the top
|
||||
//! level, and never imports nothing without saying so (#790).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn collections() -> Value {
|
||||
json!({"VC:1": {"id": "VC:1", "name": "Theme", "modes": [{"modeId": "1:0", "name": "Light"}], "defaultModeId": "1:0",
|
||||
"variableIds": ["VariableID:1"]}})
|
||||
}
|
||||
|
||||
fn variables() -> Value {
|
||||
json!({"VariableID:1": {"id": "VariableID:1", "name": "bg", "variableCollectionId": "VC:1", "resolvedType": "COLOR",
|
||||
"valuesByMode": {"1:0": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0}}}})
|
||||
}
|
||||
|
||||
fn import(json: Value) -> Value {
|
||||
let mut s = Session::new();
|
||||
match s.execute("variable.import", &json!({"variables": json})) {
|
||||
Ok(r) => r,
|
||||
Err(e) => panic!("{e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_import_reads_every_shape() {
|
||||
for shape in [
|
||||
json!({"meta": {"variableCollections": collections(), "variables": variables()}}),
|
||||
json!({"variableCollections": collections(), "variables": variables()}),
|
||||
json!({"meta": {"variableCollections": collections()}, "variables": variables()}),
|
||||
json!({"variableCollections": collections(), "meta": {"variables": variables()}}),
|
||||
] {
|
||||
let r = import(shape.clone());
|
||||
assert_eq!((r["collections"].as_u64(), r["variables"].as_u64()), (Some(1), Some(1)), "{shape}: {r}");
|
||||
assert_eq!(r["warnings"], json!([]), "{shape}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_import_never_imports_nothing_silently() {
|
||||
// Collections without variables: they are added, with a warning saying where variables go.
|
||||
let r = import(json!({"meta": {"variableCollections": collections()}}));
|
||||
assert_eq!((r["collections"].as_u64(), r["variables"].as_u64()), (Some(1), Some(0)), "{r}");
|
||||
assert!(r["warnings"].to_string().contains("no variables found: expected `meta.variables`"), "{r}");
|
||||
// An empty file's export reads back with the same warning, not an error.
|
||||
let mut s = Session::new();
|
||||
let text = s.execute("variable.export", &json!({})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
let r = s.execute("variable.import", &json!({"variables": text})).unwrap();
|
||||
assert!(r["warnings"].to_string().contains("no variables found"), "{r}");
|
||||
// Collections listing variables that aren't there.
|
||||
let mut colls = collections();
|
||||
colls["VC:1"]["variableIds"] = json!(["VariableID:1", "VariableID:2"]);
|
||||
let r = import(json!({"meta": {"variableCollections": colls, "variables": variables()}}));
|
||||
assert_eq!(r["variables"].as_u64(), Some(1), "{r}");
|
||||
assert!(r["warnings"].to_string().contains("1 of the 2 variables the collections list in `variableIds` weren't imported"), "{r}");
|
||||
// No collections anywhere: an error naming the expected shape.
|
||||
let e = Session::new().execute("variable.import", &json!({"variables": {"variables": variables()}})).unwrap_err().to_string();
|
||||
assert!(e.contains("(`meta.variableCollections` and `meta.variables`)"), "{e}");
|
||||
}
|
||||
+122
-13
@@ -6,8 +6,8 @@
|
||||
//! (`relativeTransform`, else `absoluteBoundingBox` + `rotation`), paints, strokes, effects,
|
||||
//! corners, constraints, auto layout, layout grids, export settings, text with styled runs,
|
||||
//! vectors from `fillGeometry` / `strokeGeometry`, components, component sets, instances and
|
||||
//! component properties are imported. Image fills keep their `imageRef` as the image key and
|
||||
//! show a grey placeholder unless an [`ImageResolver`] supplies the bytes (nothing is fetched).
|
||||
//! component properties (slots included) are imported. Image fills keep their `imageRef` as the
|
||||
//! image key and show a grey placeholder unless an [`ImageResolver`] supplies the bytes (nothing is fetched).
|
||||
//! Prototyping comes along: interactions (triggers, actions, transitions, easing), flows,
|
||||
//! scrolling and the prototype device. Variables come from the local variables response
|
||||
//! (`GET /v1/files/:key/variables/local`), embedded next to the file JSON or passed separately
|
||||
@@ -37,6 +37,8 @@ pub(crate) mod proto;
|
||||
#[cfg(test)]
|
||||
mod proto_variable_tests;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod slot_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod text;
|
||||
mod variables;
|
||||
@@ -106,12 +108,25 @@ pub fn is_variables_json(v: &Value) -> bool {
|
||||
}
|
||||
|
||||
/// The collections and variables of a local variables response (or its `meta`), checked like a
|
||||
/// file's, with the warnings about what was skipped; None when `v` isn't one.
|
||||
/// file's, with the warnings about what was skipped (and when it holds no variables at all, or
|
||||
/// fewer than its collections list); None when `v` isn't one.
|
||||
pub fn read_variables(v: &Value) -> Option<(figcraft_doc::Variables, Vec<String>)> {
|
||||
let src = variables::source(v)?;
|
||||
variables::source(v)?;
|
||||
let mut cx = Ctx::new(Document::new("Variables"), &no_images);
|
||||
cx.variables(src);
|
||||
Some((std::mem::take(&mut cx.doc.variables), cx.warnings.finish()))
|
||||
cx.variables(v);
|
||||
let vars = std::mem::take(&mut cx.doc.variables);
|
||||
let mut warnings = cx.warnings.finish();
|
||||
if vars.variables.is_empty() {
|
||||
warnings.push("no variables found: expected `meta.variables` (an object of variables by id, next to `meta.variableCollections`)".into());
|
||||
} else {
|
||||
let listed = variables::listed_ids(v);
|
||||
let read: BTreeSet<&str> = vars.variables.iter().map(|x| x.id.as_str()).collect();
|
||||
let missing = listed.difference(&read).count();
|
||||
if missing > 0 {
|
||||
warnings.push(format!("{missing} of the {} variables the collections list in `variableIds` weren't imported", listed.len()));
|
||||
}
|
||||
}
|
||||
Some((vars, warnings))
|
||||
}
|
||||
|
||||
/// Import `bytes` when they are REST API file JSON; `None` when they are something else (the
|
||||
@@ -191,7 +206,7 @@ fn import_parts(
|
||||
cx.collect_meta(e);
|
||||
}
|
||||
// Variables first: layers link to them as they are built.
|
||||
for src in [variables::source(v), variables.and_then(variables::source)].into_iter().flatten() {
|
||||
for src in [Some(v), variables].into_iter().flatten() {
|
||||
cx.variables(src);
|
||||
}
|
||||
if variables.is_some_and(|x| variables::source(x).is_none()) {
|
||||
@@ -368,6 +383,10 @@ pub(crate) struct Ctx<'a> {
|
||||
style_meta: BTreeMap<&'a str, &'a Value>,
|
||||
/// Instances (imported as frames until their main is resolved) with their JSON.
|
||||
instances: Vec<(NodeId, &'a Value)>,
|
||||
/// `SLOT` layers (imported as frames until their slot property is resolved) with their JSON.
|
||||
slots: Vec<(NodeId, &'a Value)>,
|
||||
/// Slot property defaults (the main's slot content id): (component or set, property) → value.
|
||||
slot_defaults: BTreeMap<(NodeId, String), &'a Value>,
|
||||
/// Instance-swap property defaults: (component or set, property, REST id of the default).
|
||||
swap_defaults: Vec<(NodeId, String, String)>,
|
||||
/// Property definitions of component sets, shared by their variants.
|
||||
@@ -402,6 +421,8 @@ impl<'a> Ctx<'a> {
|
||||
set_meta: BTreeMap::new(),
|
||||
style_meta: BTreeMap::new(),
|
||||
instances: Vec::new(),
|
||||
slots: Vec::new(),
|
||||
slot_defaults: BTreeMap::new(),
|
||||
swap_defaults: Vec::new(),
|
||||
set_defs: BTreeMap::new(),
|
||||
flows: Vec::new(),
|
||||
@@ -502,8 +523,10 @@ impl<'a> Ctx<'a> {
|
||||
sources.record(id, j, primary);
|
||||
}
|
||||
}
|
||||
if ty == "INSTANCE" {
|
||||
self.instances.push((id, j));
|
||||
match ty {
|
||||
"INSTANCE" => self.instances.push((id, j)),
|
||||
"SLOT" => self.slots.push((id, j)),
|
||||
_ => {}
|
||||
}
|
||||
let kids = children(j);
|
||||
if kids.is_empty() {
|
||||
@@ -526,8 +549,8 @@ impl<'a> Ctx<'a> {
|
||||
let point_count = || num(j, "pointCount").filter(|c| *c >= 3.0).map(|c| c.min(1000.0) as u32);
|
||||
let has_geometry = !list(j, "fillGeometry").is_empty() || !list(j, "strokeGeometry").is_empty();
|
||||
Some(match ty {
|
||||
// Instances start as frames; `finish` links them to their main.
|
||||
"FRAME" | "INSTANCE" => NodeKind::Frame,
|
||||
// Instances and slots start as frames; `finish` links them to their main and property.
|
||||
"FRAME" | "INSTANCE" | "SLOT" => NodeKind::Frame,
|
||||
"GROUP" => NodeKind::Group,
|
||||
"SECTION" => NodeKind::Section { contents_hidden: flag(j, "sectionContentsHidden").unwrap_or(false) },
|
||||
"RECTANGLE" => NodeKind::Rectangle,
|
||||
@@ -738,7 +761,7 @@ impl<'a> Ctx<'a> {
|
||||
}
|
||||
|
||||
/// Component property definitions (variant ones are implied by the variant names).
|
||||
fn prop_defs(&mut self, owner: NodeId, j: &Value) -> BTreeMap<String, PropDef> {
|
||||
fn prop_defs(&mut self, owner: NodeId, j: &'a Value) -> BTreeMap<String, PropDef> {
|
||||
let mut out = BTreeMap::new();
|
||||
for (name, d) in j.get("componentPropertyDefinitions").and_then(Value::as_object).into_iter().flatten().take(256) {
|
||||
let default = d.get("defaultValue");
|
||||
@@ -752,7 +775,13 @@ impl<'a> Ctx<'a> {
|
||||
continue;
|
||||
}
|
||||
Some("VARIANT") => continue,
|
||||
Some("SLOT") if self.fig_sources.is_some() => PropDef::Slot { preferred: Vec::new() },
|
||||
Some("SLOT") => {
|
||||
if let Some(d) = default {
|
||||
self.slot_defaults.insert((owner, name.clone()), d);
|
||||
}
|
||||
self.slot_notes(d);
|
||||
PropDef::Slot { preferred: Vec::new() }
|
||||
}
|
||||
_ => {
|
||||
self.warn("unknown component property types were skipped");
|
||||
continue;
|
||||
@@ -763,6 +792,20 @@ impl<'a> Ctx<'a> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Notes on what a slot property sets that the editor doesn't keep: preferred components (keys
|
||||
/// of library components, not layers of the file) and limits on its content.
|
||||
fn slot_notes(&mut self, d: &Value) {
|
||||
if !list(d, "preferredValues").is_empty() {
|
||||
self.warn("preferred slot component keys cannot be resolved to file nodes and were skipped");
|
||||
}
|
||||
// Insertion and empty-slot display only change editing aids, not what the file shows.
|
||||
let s = d.get("slotSettings");
|
||||
let limit = |k: &str| s.and_then(|s| num(s, k)).is_some_and(|n| n != 0.0);
|
||||
if limit("minChildren") || limit("maxChildren") || s.and_then(|s| flag(s, "allowPreferredValuesOnly")) == Some(true) {
|
||||
self.warn("non-default slot settings are not represented and were skipped");
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared styles with their values (`styleDefinitions`, written by the `.fig` import: a list of
|
||||
/// `{id, name, description, styleType, node}`, the node being a layer that shows the style), in
|
||||
/// the file's order. Layers then link to them; styles that no layer uses come across too.
|
||||
@@ -971,6 +1014,7 @@ impl<'a> Ctx<'a> {
|
||||
/// outside components, and instances that would nest a component inside itself (directly or
|
||||
/// through other components), stay frames.
|
||||
fn resolve_instances(&mut self) {
|
||||
let frames: BTreeSet<NodeId> = self.instances.iter().map(|(i, _)| *i).collect();
|
||||
let mut links: Vec<(NodeId, NodeId, &'a Value)> = Vec::new();
|
||||
for (inst, j) in std::mem::take(&mut self.instances) {
|
||||
match text(j, "componentId").and_then(|r| self.component(r)) {
|
||||
@@ -1000,6 +1044,7 @@ impl<'a> Ctx<'a> {
|
||||
linked.push((inst, j));
|
||||
}
|
||||
}
|
||||
self.resolve_slots(&frames, &linked);
|
||||
// Overridden fields the file lists, on the instance and its layers.
|
||||
for (inst, j) in linked {
|
||||
for o in list(j, "overrides") {
|
||||
@@ -1015,6 +1060,70 @@ impl<'a> Ctx<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SLOT` layers. In a main component a slot is bound to its slot property: the one the layer
|
||||
/// references, else the one named like it, else the main's only slot property left. In an
|
||||
/// instance it follows its main's slot, and holds the instance's own content (kept across syncs)
|
||||
/// unless the instance's value of the property is the main's default content (without the
|
||||
/// values to compare, a slot holding layers keeps them as its own). A slot without a property
|
||||
/// stays a frame. `frames`: every instance
|
||||
/// layer of the file, `linked`: those that became instances.
|
||||
fn resolve_slots(&mut self, frames: &BTreeSet<NodeId>, linked: &[(NodeId, &'a Value)]) {
|
||||
let linked: BTreeMap<NodeId, &Value> = linked.iter().copied().collect();
|
||||
let mut bound: BTreeSet<(NodeId, String)> = BTreeSet::new();
|
||||
for (id, j) in std::mem::take(&mut self.slots) {
|
||||
// The nearest main component or instance above the slot owns it; in an instance whose
|
||||
// main isn't in the file (a frame), the slot stays a frame too.
|
||||
let owner = self.doc.ancestors(id).into_iter().find_map(|a| match self.doc.node(a).map(|n| &n.kind) {
|
||||
Some(NodeKind::Instance { component, .. }) => Some(linked.get(&a).map(|j| (*component, Some(*j)))),
|
||||
Some(NodeKind::Component { .. }) => Some(Some((a, None))),
|
||||
_ => frames.contains(&a).then_some(None),
|
||||
});
|
||||
let Some(owner) = owner else {
|
||||
self.warn("slot layers without a matching slot property were imported as frames");
|
||||
continue;
|
||||
};
|
||||
let Some((main, inst)) = owner else { continue };
|
||||
let Some(NodeKind::Component { props, .. }) = self.doc.node(main).map(|n| &n.kind) else { continue };
|
||||
let defs: Vec<&String> = props.iter().filter(|(_, d)| matches!(d, PropDef::Slot { .. })).map(|(k, _)| k).collect();
|
||||
let layer = text(j, "name").unwrap_or_default();
|
||||
let refs: Vec<&str> =
|
||||
j.get("componentPropertyReferences").and_then(Value::as_object).into_iter().flatten().filter_map(|(_, v)| v.as_str()).collect();
|
||||
// An instance's slot is matched against all of its main's slot properties.
|
||||
let free: Vec<&&String> = defs.iter().filter(|d| inst.is_some() || !bound.contains(&(main, (**d).clone()))).collect();
|
||||
let name = defs
|
||||
.iter()
|
||||
.find(|d| refs.contains(&d.as_str()))
|
||||
.or_else(|| defs.iter().find(|d| short_prop_name(d) == layer))
|
||||
.or_else(|| if free.len() == 1 { free.first().copied() } else { None })
|
||||
.map(|d| (*d).clone());
|
||||
let Some(n) = self.doc.node(id) else { continue };
|
||||
let has_content = !n.children.is_empty();
|
||||
let Some(inst) = inst else {
|
||||
match name.filter(|p| bound.insert((main, p.clone()))) {
|
||||
Some(p) => {
|
||||
if let Some(n) = self.doc.node_mut(id) {
|
||||
n.prop_refs.insert("slot".into(), p);
|
||||
}
|
||||
}
|
||||
None => self.warn("slot layers without a matching slot property were imported as frames"),
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let set = self.doc.node(main).and_then(|n| n.parent);
|
||||
let default =
|
||||
name.as_ref().and_then(|p| [Some(main), set].into_iter().flatten().find_map(|o| self.slot_defaults.get(&(o, p.clone())).copied()));
|
||||
let value = name.as_ref().and_then(|p| inst.get("componentProperties")?.get(p)?.get("value"));
|
||||
let own = name.is_some()
|
||||
&& match (value, default) {
|
||||
(Some(v), Some(d)) => v != d,
|
||||
_ => has_content,
|
||||
};
|
||||
if own && let Some(n) = self.doc.node_mut(id) {
|
||||
n.overrides.insert("slot".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An instance's property values (variant values are implied by the main it shows).
|
||||
fn instance_props(&self, j: &Value) -> BTreeMap<String, Value> {
|
||||
let mut out = BTreeMap::new();
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! `SLOT` layers of the REST file JSON: bound to their slot property in a main component, and
|
||||
//! holding each instance's own content (synthetic files).
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn at(x: f64, y: f64) -> Value {
|
||||
json!([[1, 0, x], [0, 1, y]])
|
||||
}
|
||||
|
||||
pub(crate) fn file(children: Value) -> Value {
|
||||
json!({"name": "Slots", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": [
|
||||
{"id": "0:1", "name": "Page", "type": "CANVAS", "children": children}
|
||||
]}})
|
||||
}
|
||||
|
||||
fn content_id(local: u32) -> Value {
|
||||
json!({"guid": {"sessionID": 5, "localID": local}})
|
||||
}
|
||||
|
||||
fn slot(id: &str, name: &str, children: Value) -> Value {
|
||||
json!({"id": id, "name": name, "type": "SLOT", "relativeTransform": at(10.0, 40.0), "size": {"x": 280, "y": 100}, "children": children})
|
||||
}
|
||||
|
||||
fn label(id: &str, text: &str) -> Value {
|
||||
json!({"id": id, "name": text, "type": "TEXT", "characters": text, "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}})
|
||||
}
|
||||
|
||||
/// A "Chip" component (`1:9`), a "Container" component (`1:2`) with a "Title" text and an empty
|
||||
/// "Slot" (property `Slot#1:3`, default content 5:1), and the given layers after them.
|
||||
pub(crate) fn container_file(more: Vec<Value>) -> Value {
|
||||
let mut layers = vec![
|
||||
json!({"id": "1:9", "name": "Chip", "type": "COMPONENT", "relativeTransform": at(0.0, 400.0), "size": {"x": 40, "y": 20}, "children": [
|
||||
{"id": "1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}
|
||||
]}),
|
||||
json!({"id": "1:2", "name": "Container", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 160},
|
||||
"componentPropertyDefinitions": {"Slot#1:3": {"type": "SLOT", "defaultValue": content_id(1)}},
|
||||
"children": [label("1:4", "Title"), slot("1:5", "Slot", json!([]))]}),
|
||||
];
|
||||
layers.extend(more);
|
||||
file(Value::Array(layers))
|
||||
}
|
||||
|
||||
/// An instance `id` of Container at `x` whose slot holds `content` (property value `value`).
|
||||
pub(crate) fn container(id: &str, x: f64, value: Value, content: Value) -> Value {
|
||||
json!({"id": id, "name": "Container", "type": "INSTANCE", "componentId": "1:2", "relativeTransform": at(x, 0.0), "size": {"x": 300, "y": 160},
|
||||
"componentProperties": {"Slot#1:3": {"type": "SLOT", "value": value}},
|
||||
"children": [label(&format!("I{id};1:4"), "Title"), slot(&format!("I{id};1:5"), "Slot", content)]})
|
||||
}
|
||||
|
||||
pub(crate) fn chip(id: &str) -> Value {
|
||||
json!({"id": id, "name": "Chip", "type": "INSTANCE", "componentId": "1:9", "relativeTransform": at(0.0, 30.0), "size": {"x": 40, "y": 20}, "children": [
|
||||
{"id": format!("I{id};1:10"), "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}
|
||||
]})
|
||||
}
|
||||
|
||||
fn by_rest<'d>(doc: &'d Document, v: &Value, rest: &str) -> &'d Node {
|
||||
// Names repeat across copies: find by position in the file's tree instead.
|
||||
fn path(v: &Value, rest: &str, out: &mut Vec<usize>) -> bool {
|
||||
if v.get("id").and_then(Value::as_str) == Some(rest) {
|
||||
return true;
|
||||
}
|
||||
for (i, c) in v.get("children").and_then(Value::as_array).into_iter().flatten().enumerate() {
|
||||
out.push(i);
|
||||
if path(c, rest, out) {
|
||||
return true;
|
||||
}
|
||||
out.pop();
|
||||
}
|
||||
false
|
||||
}
|
||||
let mut p = Vec::new();
|
||||
assert!(path(&v["document"]["children"][0], rest, &mut p), "no {rest} in the file");
|
||||
let mut id = doc.pages[0];
|
||||
for i in p {
|
||||
id = doc.node(id).unwrap().children[i];
|
||||
}
|
||||
doc.node(id).unwrap()
|
||||
}
|
||||
|
||||
fn names(doc: &Document, n: &Node) -> Vec<String> {
|
||||
n.children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_layers_bind_to_their_property_and_keep_instance_content() {
|
||||
let v = container_file(vec![
|
||||
container("2:1", 400.0, content_id(7), json!([label("2:2", "Reports in progress"), chip("2:3")])),
|
||||
container("3:1", 800.0, content_id(1), json!([])),
|
||||
]);
|
||||
let (doc, warnings) = import(&v, &no_images).unwrap();
|
||||
assert!(warnings.iter().all(|w| !w.contains("SLOT") && !w.contains("slot")), "{warnings:?}");
|
||||
|
||||
let main = by_rest(&doc, &v, "1:2");
|
||||
let NodeKind::Component { props, .. } = &main.kind else { panic!("{:?}", main.kind) };
|
||||
assert_eq!(props.get("Slot"), Some(&PropDef::Slot { preferred: Vec::new() }), "named without its #id suffix");
|
||||
let main_slot = by_rest(&doc, &v, "1:5");
|
||||
assert!(matches!(main_slot.kind, NodeKind::Frame));
|
||||
assert_eq!(main_slot.prop_refs.get("slot").map(String::as_str), Some("Slot"));
|
||||
assert!(main_slot.children.is_empty(), "the main's slot stays empty");
|
||||
|
||||
let filled = by_rest(&doc, &v, "I2:1;1:5");
|
||||
assert!(filled.overrides.contains("slot"), "the instance's own content");
|
||||
assert!(filled.prop_refs.is_empty(), "an instance's slot follows its main's");
|
||||
assert_eq!(names(&doc, filled), ["Reports in progress", "Chip"]);
|
||||
let chip = by_rest(&doc, &v, "2:3");
|
||||
assert!(matches!(chip.kind, NodeKind::Instance { .. }), "instances in slot content link to their own main");
|
||||
|
||||
let default = by_rest(&doc, &v, "I3:1;1:5");
|
||||
assert!(!default.overrides.contains("slot"), "the main's default content follows the main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_ownership_without_values_follows_the_content() {
|
||||
let mut owned = container("2:1", 400.0, json!(null), json!([label("2:2", "Own")]));
|
||||
owned["componentProperties"] = json!({});
|
||||
let mut empty = container("3:1", 800.0, json!(null), json!([]));
|
||||
empty["componentProperties"] = json!({});
|
||||
let v = container_file(vec![owned, empty]);
|
||||
let (doc, _) = import(&v, &no_images).unwrap();
|
||||
assert!(by_rest(&doc, &v, "I2:1;1:5").overrides.contains("slot"));
|
||||
assert!(!by_rest(&doc, &v, "I3:1;1:5").overrides.contains("slot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_match_by_reference_then_name_then_the_one_left() {
|
||||
let comp = json!({"id": "1:2", "name": "Card", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 300},
|
||||
"componentPropertyDefinitions": {
|
||||
"Header#1:3": {"type": "SLOT", "defaultValue": content_id(1)},
|
||||
"Body#1:4": {"type": "SLOT", "defaultValue": content_id(2)},
|
||||
"Footer#1:5": {"type": "SLOT", "defaultValue": content_id(3)},
|
||||
"Visible#1:6": {"type": "BOOLEAN", "defaultValue": true}
|
||||
},
|
||||
"children": [
|
||||
{"id": "1:7", "name": "Top", "type": "SLOT", "componentPropertyReferences": {"visible": "Visible#1:6", "slot": "Header#1:3"},
|
||||
"relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 50}},
|
||||
{"id": "1:8", "name": "Body", "type": "SLOT", "relativeTransform": at(0.0, 50.0), "size": {"x": 300, "y": 50}},
|
||||
{"id": "1:9", "name": "Bottom", "type": "SLOT", "relativeTransform": at(0.0, 100.0), "size": {"x": 300, "y": 50}}
|
||||
]});
|
||||
let v = file(json!([comp]));
|
||||
let (doc, warnings) = import(&v, &no_images).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let bound = |rest: &str| by_rest(&doc, &v, rest).prop_refs.get("slot").cloned();
|
||||
assert_eq!(bound("1:7").as_deref(), Some("Header"));
|
||||
assert_eq!(by_rest(&doc, &v, "1:7").prop_refs.get("visible").map(String::as_str), Some("Visible"));
|
||||
assert_eq!(bound("1:8").as_deref(), Some("Body"));
|
||||
assert_eq!(bound("1:9").as_deref(), Some("Footer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variants_share_their_set_slot_property() {
|
||||
let variant = |id: &str, name: &str, x: f64| {
|
||||
json!({"id": id, "name": name, "type": "COMPONENT", "relativeTransform": at(x, 0.0), "size": {"x": 100, "y": 100},
|
||||
"children": [slot(&format!("{id}s"), "Content", json!([]))]})
|
||||
};
|
||||
let set = json!({"id": "1:1", "name": "Panel", "type": "COMPONENT_SET", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 100},
|
||||
"componentPropertyDefinitions": {"Size": {"type": "VARIANT", "defaultValue": "Small"}, "Content#2:1": {"type": "SLOT", "defaultValue": content_id(1)}},
|
||||
"children": [variant("1:2", "Size=Small", 0.0), variant("1:3", "Size=Large", 150.0)]});
|
||||
let inst = json!({"id": "4:1", "name": "Panel", "type": "INSTANCE", "componentId": "1:3", "relativeTransform": at(0.0, 300.0), "size": {"x": 100, "y": 100},
|
||||
"componentProperties": {"Content#2:1": {"type": "SLOT", "value": content_id(9)}},
|
||||
"children": [slot("I4:1;1:3s", "Content", json!([label("4:2", "Mine")]))]});
|
||||
let v = file(json!([set, inst]));
|
||||
let (doc, warnings) = import(&v, &no_images).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
for rest in ["1:2s", "1:3s"] {
|
||||
assert_eq!(by_rest(&doc, &v, rest).prop_refs.get("slot").map(String::as_str), Some("Content"), "{rest}");
|
||||
}
|
||||
assert!(by_rest(&doc, &v, "I4:1;1:3s").overrides.contains("slot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_instances_own_the_slots_of_their_main() {
|
||||
// A "Screen" main places a Container whose slot holds a chip; an instance of Screen places
|
||||
// other content in that nested slot.
|
||||
let screen = json!({"id": "6:1", "name": "Screen", "type": "COMPONENT", "relativeTransform": at(0.0, 600.0), "size": {"x": 300, "y": 160},
|
||||
"children": [container("6:2", 0.0, content_id(7), json!([chip("6:3")]))]});
|
||||
let nested = container("I7:1;6:2", 0.0, content_id(8), json!([label("7:2", "Other")]));
|
||||
let outer = json!({"id": "7:1", "name": "Screen", "type": "INSTANCE", "componentId": "6:1", "relativeTransform": at(400.0, 600.0), "size": {"x": 300, "y": 160},
|
||||
"children": [nested]});
|
||||
let v = container_file(vec![screen, outer]);
|
||||
let (doc, _) = import(&v, &no_images).unwrap();
|
||||
let in_main = by_rest(&doc, &v, "I6:2;1:5");
|
||||
assert!(in_main.overrides.contains("slot") && in_main.prop_refs.is_empty());
|
||||
assert!(matches!(by_rest(&doc, &v, "6:3").kind, NodeKind::Instance { .. }));
|
||||
let in_outer = by_rest(&doc, &v, "II7:1;6:2;1:5");
|
||||
assert!(in_outer.overrides.contains("slot"));
|
||||
assert_eq!(names(&doc, in_outer), ["Other"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_outside_components_or_without_a_property_stay_frames() {
|
||||
let mut no_def = container_file(Vec::new());
|
||||
no_def["document"]["children"][0]["children"][1].as_object_mut().unwrap().remove("componentPropertyDefinitions");
|
||||
let loose = slot("8:1", "Loose", json!([label("8:2", "Kept")]));
|
||||
no_def["document"]["children"][0]["children"].as_array_mut().unwrap().push(loose);
|
||||
let (doc, warnings) = import(&no_def, &no_images).unwrap();
|
||||
assert!(warnings.iter().any(|w| w.starts_with("slot layers without a matching slot property were imported as frames (×2)")), "{warnings:?}");
|
||||
let kept = by_rest(&doc, &no_def, "8:1");
|
||||
assert!(matches!(kept.kind, NodeKind::Frame) && kept.prop_refs.is_empty());
|
||||
assert_eq!(names(&doc, kept), ["Kept"]);
|
||||
assert!(by_rest(&doc, &no_def, "1:5").prop_refs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_settings_the_editor_cannot_keep_are_noted() {
|
||||
let mut v = container_file(Vec::new());
|
||||
v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["slotSettings"] =
|
||||
json!({"stretchChildOnInsert": true, "displayEmptyByDefault": true, "minChildren": null, "maxChildren": 3});
|
||||
v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["preferredValues"] =
|
||||
json!([{"type": "COMPONENT", "key": "abc"}]);
|
||||
let (_, warnings) = import(&v, &no_images).unwrap();
|
||||
assert!(warnings.iter().any(|w| w == "non-default slot settings are not represented and were skipped"), "{warnings:?}");
|
||||
assert!(warnings.iter().any(|w| w == "preferred slot component keys cannot be resolved to file nodes and were skipped"), "{warnings:?}");
|
||||
let mut plain = container_file(Vec::new());
|
||||
plain["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["slotSettings"] =
|
||||
json!({"stretchChildOnInsert": true, "minChildren": null, "maxChildren": null, "allowPreferredValuesOnly": false});
|
||||
assert!(import(&plain, &no_images).unwrap().1.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_slot_json_never_panics() {
|
||||
for junk in [json!(null), json!(1), json!("x"), json!([]), json!({"guid": null}), json!({"type": "SLOT"})] {
|
||||
let mut v = container_file(vec![container("2:1", 400.0, junk.clone(), json!([label("2:2", "A")]))]);
|
||||
v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"] =
|
||||
json!({"type": "SLOT", "defaultValue": junk.clone(), "slotSettings": junk.clone(), "preferredValues": junk.clone()});
|
||||
v["document"]["children"][0]["children"][1]["children"][1]["componentPropertyReferences"] = json!({"slot": junk});
|
||||
assert!(import(&v, &no_images).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,16 @@ pub fn source(v: &Value) -> Option<&Value> {
|
||||
[v.get("meta"), Some(v)].into_iter().flatten().find(|m| m.get("variableCollections").is_some_and(Value::is_object))
|
||||
}
|
||||
|
||||
/// The variable ids the collections of a local variables response list in `variableIds`.
|
||||
pub(super) fn listed_ids(v: &Value) -> BTreeSet<&str> {
|
||||
source(v)
|
||||
.into_iter()
|
||||
.flat_map(|src| objects(src, "variableCollections", MAX_COLLECTIONS))
|
||||
.flat_map(|(_, c)| list(c, "variableIds").iter().filter_map(Value::as_str).map(clip_str))
|
||||
.take(MAX_VARIABLES)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn kind_of(t: &str) -> Option<VariableType> {
|
||||
Some(match t {
|
||||
"COLOR" => VariableType::Color,
|
||||
@@ -118,14 +128,18 @@ fn objects<'v>(v: &'v Value, key: &str, max: usize) -> impl Iterator<Item = (&'v
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
/// Add the collections and variables of a local variables response (or its `meta`).
|
||||
pub(super) fn variables(&mut self, src: &Value) {
|
||||
/// Add the collections and variables of a local variables response (or its `meta`); nothing
|
||||
/// when `v` isn't one. The `variables` map is read next to `variableCollections`, or from
|
||||
/// `meta` or the top level when it sits on the other side.
|
||||
pub(super) fn variables(&mut self, v: &Value) {
|
||||
let Some(src) = source(v) else { return };
|
||||
for (key, c) in objects(src, "variableCollections", MAX_COLLECTIONS) {
|
||||
self.collection(key, c);
|
||||
}
|
||||
let holder = [Some(src), v.get("meta"), Some(v)].into_iter().flatten().find(|m| m.get("variables").is_some_and(Value::is_object));
|
||||
let mut found: Vec<Variable> = Vec::new();
|
||||
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||||
for (key, v) in objects(src, "variables", MAX_VARIABLES) {
|
||||
for (key, v) in holder.into_iter().flat_map(|h| objects(h, "variables", MAX_VARIABLES)) {
|
||||
if let Some(var) = self.variable(key, v)
|
||||
&& seen.insert(var.id.clone())
|
||||
{
|
||||
|
||||
@@ -634,6 +634,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "Výměna instance (žádná jiná komponenta)"
|
||||
"Instances" = "Instance"
|
||||
"Instant" = "Okamžitě"
|
||||
"Intensity" = "Intenzita"
|
||||
"Interaction" = "Interakce"
|
||||
"Interactions" = "Interakce"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Návrh rozhraní a prototypování. Svobodný nástroj s otevřeným zdrojovým kódem, napsaný od základu v jazyce Rust nezávisle na kódu jiných produktů. Součást sady ArtCraft Crafting Apps."
|
||||
|
||||
@@ -619,6 +619,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "Instanztausch (keine andere Komponente)"
|
||||
"Instances" = "Instanzen"
|
||||
"Instant" = "Sofort"
|
||||
"Intensity" = "Intensität"
|
||||
"Interaction" = "Interaktion"
|
||||
"Interactions" = "Interaktionen"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface-Design und Prototyping. Ein kostenloses Open-Source-Designwerkzeug, als Clean-Room-Entwicklung von Grund auf in Rust geschrieben, Teil der ArtCraft Crafting Apps."
|
||||
|
||||
@@ -649,6 +649,7 @@ name = "English"
|
||||
"Instance swap (no other component)" = "Instance swap (no other component)"
|
||||
"Instances" = "Instances"
|
||||
"Instant" = "Instant"
|
||||
"Intensity" = "Intensity"
|
||||
"Interaction" = "Interaction"
|
||||
"Interactions" = "Interactions"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps."
|
||||
@@ -833,6 +834,7 @@ name = "English"
|
||||
"No contributor data was built into this copy." = "No contributor data was built into this copy."
|
||||
"No device" = "No device"
|
||||
"No fonts match" = "No fonts match"
|
||||
"No files or templates match your search" = "No files or templates match your search"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead."
|
||||
"No layer changes to show." = "No layer changes to show."
|
||||
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link."
|
||||
@@ -1178,6 +1180,8 @@ name = "English"
|
||||
"Search actions, layers and tools" = "Search actions, layers and tools"
|
||||
"Search assets" = "Search assets"
|
||||
"Search comments" = "Search comments"
|
||||
"Search files and templates" = "Search files and templates"
|
||||
"Search results" = "Search results"
|
||||
"Search fonts" = "Search fonts"
|
||||
"Search layers" = "Search layers"
|
||||
"Search shortcuts" = "Search shortcuts"
|
||||
@@ -1407,6 +1411,7 @@ name = "English"
|
||||
"Truncate with an ellipsis" = "Truncate with an ellipsis"
|
||||
"Try again" = "Try again"
|
||||
"Try another search or filter." = "Try another search or filter."
|
||||
"Try another search, or browse templates." = "Try another search, or browse templates."
|
||||
"Try another word, or look at every kind." = "Try another word, or look at every kind."
|
||||
"Turn a layer into a component, reuse it, and change every copy at once" = "Turn a layer into a component, reuse it, and change every copy at once"
|
||||
"Two digits" = "Two digits"
|
||||
@@ -1620,6 +1625,7 @@ name = "English"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped"
|
||||
"slot content assignment target is missing or is not slot content" = "slot content assignment target is missing or is not slot content"
|
||||
"slot content assignment uses the empty GUID sentinel and was skipped" = "slot content assignment uses the empty GUID sentinel and was skipped"
|
||||
"slot layers without a matching slot property were imported as frames" = "slot layers without a matching slot property were imported as frames"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners"
|
||||
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "some vector segments refer to style settings missing from the file; they use the layer's stroke"
|
||||
"stale or invalid grid track sizing entries were ignored" = "stale or invalid grid track sizing entries were ignored"
|
||||
|
||||
@@ -650,6 +650,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "Intercambio de instancia (no hay otro componente)"
|
||||
"Instances" = "Instancias"
|
||||
"Instant" = "Instantánea"
|
||||
"Intensity" = "Intensidad"
|
||||
"Interaction" = "Interacción"
|
||||
"Interactions" = "Interacciones"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Diseño de interfaces y prototipos. Una herramienta de diseño gratuita y de código abierto, de implementación independiente (clean-room), escrita desde cero en Rust, parte de las Crafting Apps de ArtCraft."
|
||||
@@ -834,6 +835,7 @@ status = "draft — needs native review"
|
||||
"No contributor data was built into this copy." = "Esta copia no incluye datos de colaboradores."
|
||||
"No device" = "Sin dispositivo"
|
||||
"No fonts match" = "Ninguna fuente coincide"
|
||||
"No files or templates match your search" = "Ningún archivo ni plantilla coincide con tu búsqueda"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Aquí no hay código para unirse (el servidor es anterior a los códigos, o esta dirección no cabe en uno): comparte el enlace."
|
||||
"No layer changes to show." = "No hay cambios de capas que mostrar."
|
||||
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Aún no hay bibliotecas. Añade un archivo .figcraft publicado como biblioteca o pega el enlace de una biblioteca de equipo."
|
||||
@@ -1179,6 +1181,8 @@ status = "draft — needs native review"
|
||||
"Search actions, layers and tools" = "Buscar acciones, capas y herramientas"
|
||||
"Search assets" = "Buscar recursos"
|
||||
"Search comments" = "Buscar comentarios"
|
||||
"Search files and templates" = "Buscar archivos y plantillas"
|
||||
"Search results" = "Resultados de búsqueda"
|
||||
"Search fonts" = "Buscar fuentes"
|
||||
"Search layers" = "Buscar capas"
|
||||
"Search shortcuts" = "Buscar atajos"
|
||||
@@ -1408,6 +1412,7 @@ status = "draft — needs native review"
|
||||
"Truncate with an ellipsis" = "Truncar con puntos suspensivos"
|
||||
"Try again" = "Reintentar"
|
||||
"Try another search or filter." = "Prueba otra búsqueda u otro filtro."
|
||||
"Try another search, or browse templates." = "Prueba otra búsqueda o explora las plantillas."
|
||||
"Try another word, or look at every kind." = "Prueba con otra palabra o mira todos los tipos."
|
||||
"Turn a layer into a component, reuse it, and change every copy at once" = "Convierte una capa en componente, reutilízalo y cambia todas las copias a la vez"
|
||||
"Two digits" = "Dos dígitos"
|
||||
@@ -1621,6 +1626,7 @@ status = "draft — needs native review"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "se omitieron las acciones «Establecer modo de variable» sobre colecciones o modos que no están en el JSON de variables"
|
||||
"slot content assignment target is missing or is not slot content" = "el destino de la asignación de contenido de ranura falta o no es contenido de ranura"
|
||||
"slot content assignment uses the empty GUID sentinel and was skipped" = "la asignación de contenido de ranura usa el GUID vacío como centinela y se omitió"
|
||||
"slot layers without a matching slot property were imported as frames" = "las capas de ranura sin una propiedad de ranura correspondiente se importaron como marcos"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "algunos puntos vectoriales hacen referencia a ajustes de punto que faltan en el archivo; usan las uniones de la capa y esquinas simples"
|
||||
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "algunos segmentos vectoriales hacen referencia a ajustes de estilo que faltan en el archivo; usan el trazo de la capa"
|
||||
"stale or invalid grid track sizing entries were ignored" = "se ignoraron las entradas de tamaño de pista de cuadrícula obsoletas o no válidas"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -19,7 +19,10 @@ status = "draft — needs native review"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Une collection de ce fichier avec ces modes et ces variables : remplacez leurs valeurs par mode et ajoutez vos propres variables"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Un fichier .figcraft portant ce nom existe déjà. Choisissez son nom complet pour confirmer le remplacement."
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Un calque seul s’aligne sur son cadre : sélectionnez d’autres calques, ou un calque dans un cadre"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Une page produit responsive : héros, fonctionnalités, chiffres, une citation et un pied de page"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Un ressort joue jusqu’à ce qu’il se stabilise : sa masse, sa rigidité et son amortissement en fixent la durée"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "Un titre, un sommaire, un grand chiffre, un graphique et une diapositive de clôture"
|
||||
"A to Z" = "A à Z"
|
||||
"About" = "À propos"
|
||||
"About FigCraft" = "À propos de FigCraft"
|
||||
"About this server" = "À propos de ce serveur"
|
||||
@@ -65,6 +68,7 @@ status = "draft — needs native review"
|
||||
"Add variant with a new value" = "Ajouter une variante avec une nouvelle valeur"
|
||||
"Added" = "Ajouté"
|
||||
"Added the library \"{name}\"" = "Bibliothèque « {name} » ajoutée"
|
||||
"Adding to {name}" = "Ajout dans {name}"
|
||||
"Adjust" = "Réglages"
|
||||
"Adjustment presets" = "Préréglages"
|
||||
"Advanced" = "Avancé"
|
||||
@@ -90,7 +94,9 @@ status = "draft — needs native review"
|
||||
"All supported properties" = "Toutes les propriétés prises en charge"
|
||||
"All variables" = "Toutes les variables"
|
||||
"Alpha" = "Alpha"
|
||||
"Alphabetical" = "Alphabétique"
|
||||
"Alt+drag" = "Alt+glisser"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Un écran d’analyse sur une grille : chiffres, graphiques tracés en vectoriel et un tableau"
|
||||
"An invite link that opens FigCraft and joins" = "Un lien d’invitation qui ouvre FigCraft et rejoint la session"
|
||||
"Anchor" = "Ancre"
|
||||
"Android" = "Android"
|
||||
@@ -109,6 +115,8 @@ status = "draft — needs native review"
|
||||
"Apply variable" = "Appliquer une variable"
|
||||
"Apply variable to text" = "Appliquer une variable au texte"
|
||||
"Apply variable to visibility" = "Appliquer une variable à la visibilité"
|
||||
"Apps" = "Applications"
|
||||
"Apr" = "avr."
|
||||
"Arc" = "Arc"
|
||||
"Arc ratio" = "Rapport de l’arc"
|
||||
"Arc start" = "Début de l’arc"
|
||||
@@ -120,6 +128,7 @@ status = "draft — needs native review"
|
||||
"As typed" = "Tel que saisi"
|
||||
"Asking the server…" = "Interrogation du serveur…"
|
||||
"Assets" = "Ressources"
|
||||
"Aug" = "août"
|
||||
"Auto" = "Auto"
|
||||
"Auto (space around)" = "Auto (espace autour)"
|
||||
"Auto (space between)" = "Auto (espace entre)"
|
||||
@@ -164,6 +173,7 @@ status = "draft — needs native review"
|
||||
"Bottom" = "Bas"
|
||||
"Bottom left radius" = "Rayon inférieur gauche"
|
||||
"Bottom right radius" = "Rayon inférieur droit"
|
||||
"Bound to {name}. Click to detach the variable." = "Lié à {name}. Cliquez pour détacher la variable."
|
||||
"Branch" = "Branche"
|
||||
"Branch archived" = "Branche archivée"
|
||||
"Branch merged" = "Branche fusionnée"
|
||||
@@ -250,6 +260,7 @@ status = "draft — needs native review"
|
||||
"Color values in this file are in this color space." = "Les valeurs de couleur de ce fichier sont dans cet espace colorimétrique."
|
||||
"Color variables" = "Variables de couleur"
|
||||
"Colour" = "Couleur"
|
||||
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Styles de couleur et de texte, variables Clair et Sombre, boutons, champs et cartes"
|
||||
"Column" = "Colonne"
|
||||
"Column gap" = "Espacement des colonnes"
|
||||
"Column sizes" = "Tailles des colonnes"
|
||||
@@ -286,6 +297,8 @@ status = "draft — needs native review"
|
||||
"Contributors" = "Contributeurs"
|
||||
"Contributors: {people} · Commits: {commits}" = "Contributeurs : {people} · Commits : {commits}"
|
||||
"Controller" = "Manette"
|
||||
"Convert to frame" = "Convertir en cadre"
|
||||
"Convert to section" = "Convertir en section"
|
||||
"Cool" = "Froid"
|
||||
"Copied as CSS" = "Copié en CSS"
|
||||
"Copied as PNG" = "Copié en PNG"
|
||||
@@ -319,6 +332,7 @@ status = "draft — needs native review"
|
||||
"Create property…" = "Créer une propriété…"
|
||||
"Create style" = "Créer un style"
|
||||
"Create style from the selected text" = "Créer un style à partir du texte sélectionné"
|
||||
"Created" = "Création"
|
||||
"Crop" = "Recadrer"
|
||||
"Crop marks" = "Traits de coupe"
|
||||
"Ctrl+arrows" = "Ctrl+flèches"
|
||||
@@ -333,6 +347,9 @@ status = "draft — needs native review"
|
||||
"Dark" = "Sombre"
|
||||
"Darken" = "Assombrir"
|
||||
"Dash" = "Tirets"
|
||||
"Dashboard" = "Tableau de bord"
|
||||
"Date created" = "Date de création"
|
||||
"Dec" = "déc."
|
||||
"Decoration style" = "Style de décoration"
|
||||
"Decoration" = "Décoration"
|
||||
"Deep select" = "Sélection en profondeur"
|
||||
@@ -359,6 +376,7 @@ status = "draft — needs native review"
|
||||
"Description" = "Description"
|
||||
"Description (optional)" = "Description (facultative)"
|
||||
"Design" = "Design"
|
||||
"Design system starter" = "Base de système de design"
|
||||
"Desktop" = "Ordinateur"
|
||||
"Destination" = "Destination"
|
||||
"Detach alias" = "Détacher l’alias"
|
||||
@@ -394,8 +412,12 @@ status = "draft — needs native review"
|
||||
"Drafts" = "Brouillons"
|
||||
"Drag the stroke to add a width point" = "Faites glisser le contour pour ajouter un point de largeur"
|
||||
"Draw" = "Dessin"
|
||||
"Draw a frame" = "Tracer un cadre"
|
||||
"Drawing tools" = "Outils de dessin"
|
||||
"Drop a .figcraft, .fig, .json, .svg or image file." = "Déposez un fichier .figcraft, .fig, .json, .svg ou une image."
|
||||
"Drop shadow" = "Ombre portée"
|
||||
"Drop to import" = "Déposer pour importer"
|
||||
"Drop to open" = "Déposer pour ouvrir"
|
||||
"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Double licence MIT OR Apache-2.0. Réalisé par l’équipe et la communauté ArtCraft."
|
||||
"Duo" = "Duo"
|
||||
"Duplicate" = "Dupliquer"
|
||||
@@ -412,6 +434,7 @@ status = "draft — needs native review"
|
||||
"Edit style" = "Modifier le style"
|
||||
"Edit variable" = "Modifier la variable"
|
||||
"Edit with the builder" = "Modifier avec l’assistant"
|
||||
"Edited {when}" = "Modifié {when}"
|
||||
"Effect settings" = "Réglages de l’effet"
|
||||
"Effect styles" = "Styles d’effet"
|
||||
"Effects" = "Effets"
|
||||
@@ -442,6 +465,7 @@ status = "draft — needs native review"
|
||||
"Expand" = "Développer"
|
||||
"Export" = "Exporter"
|
||||
"Export all" = "Tout exporter"
|
||||
"Export frames and layers as PNG, JPG, SVG or PDF" = "Exporter les cadres et les calques en PNG, JPG, SVG ou PDF"
|
||||
"Export page as PNG" = "Exporter la page en PNG"
|
||||
"Export settings" = "Réglages d’exportation"
|
||||
"Export variables" = "Exporter les variables"
|
||||
@@ -461,11 +485,13 @@ status = "draft — needs native review"
|
||||
"F" = "F"
|
||||
"Fade" = "Fondu"
|
||||
"False" = "Faux"
|
||||
"Feb" = "févr."
|
||||
"Fetching from the server…" = "Récupération depuis le serveur…"
|
||||
"FigCraft couldn't open its window: {error}" = "FigCraft n’a pas pu ouvrir sa fenêtre : {error}"
|
||||
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft ne prend pas encore en charge toutes les fonctionnalités de ces fichiers. Les notes indiquent ce qui a été omis ou modifié dans la copie importée ; le fichier d’origine reste inchangé."
|
||||
"File" = "Fichier"
|
||||
"File name" = "Nom du fichier"
|
||||
"File not found" = "Fichier introuvable"
|
||||
"File published" = "Fichier publié"
|
||||
"Files aren't end-to-end encrypted: the server's operator can read them." = "Les fichiers ne sont pas chiffrés de bout en bout : l’opérateur du serveur peut les lire."
|
||||
"Files nobody opens for {n} days are deleted." = { one = "Les fichiers que personne n’ouvre pendant {n} jour sont supprimés.", other = "Les fichiers que personne n’ouvre pendant {n} jours sont supprimés." }
|
||||
@@ -512,6 +538,7 @@ status = "draft — needs native review"
|
||||
"Font size" = "Taille de police"
|
||||
"Font style" = "Style de police"
|
||||
"Forget on this computer" = "Oublier sur cet ordinateur"
|
||||
"Four screens of a trail app with components and a prototype flow" = "Quatre écrans d’une application de randonnée avec des composants et un flux de prototype"
|
||||
"Fractions" = "Fractions"
|
||||
"Frame" = "Cadre"
|
||||
"Frame selection" = "Encadrer la sélection"
|
||||
@@ -525,6 +552,7 @@ status = "draft — needs native review"
|
||||
"Gap between columns" = "Espacement entre les colonnes"
|
||||
"Gap between items" = "Espacement entre les éléments"
|
||||
"Gap between rows" = "Espacement entre les lignes"
|
||||
"Getting started" = "Premiers pas"
|
||||
"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Indiquez l’adresse d’un serveur ouvert au public, ou l’adresse d’un serveur avec sa clé (`figcraft-server key` l’affiche)."
|
||||
"Glass" = "Verre"
|
||||
"Go to main component" = "Aller au composant principal"
|
||||
@@ -533,6 +561,7 @@ status = "draft — needs native review"
|
||||
"Gradient" = "Dégradé"
|
||||
"Gray" = "Gris"
|
||||
"Green" = "Vert"
|
||||
"Greyscale components and two example screens to sketch a flow with" = "Composants en niveaux de gris et deux écrans d’exemple pour esquisser un flux"
|
||||
"Grid" = "Grille"
|
||||
"Grid ({size})" = "Grille ({size})"
|
||||
"Grid styles" = "Styles de grille"
|
||||
@@ -554,6 +583,8 @@ status = "draft — needs native review"
|
||||
"Hide contents" = "Masquer le contenu"
|
||||
"Hide from publishing" = "Exclure de la publication"
|
||||
"Hide section contents" = "Masquer le contenu de la section"
|
||||
"Hide these tips (Getting started in the Help menu shows them again)" = "Masquer ces astuces (Premiers pas dans le menu Aide les affiche de nouveau)"
|
||||
"Hide when publishing" = "Masquer à la publication"
|
||||
"Highlight layers on hover" = "Mettre en évidence les calques au survol"
|
||||
"Highlights" = "Hautes lumières"
|
||||
"Hold Alt" = "Maintenir Alt"
|
||||
@@ -575,6 +606,8 @@ status = "draft — needs native review"
|
||||
"Hue" = "Teinte"
|
||||
"Hug" = "Ajuster"
|
||||
"Hug contents" = "Ajuster au contenu"
|
||||
"Icon components on a 24 px grid with keylines and a size preview" = "Composants d’icônes sur une grille de 24 px avec repères et aperçu de la taille"
|
||||
"Icon set starter" = "Base de jeu d’icônes"
|
||||
"If it keeps happening, please report it at {link} with this message." = "Si cela se reproduit, signalez-le sur {link} avec ce message."
|
||||
"If" = "Si"
|
||||
"If {condition}" = "Si {condition}"
|
||||
@@ -615,6 +648,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "Permutation d’instance (aucun autre composant)"
|
||||
"Instances" = "Instances"
|
||||
"Instant" = "Instantané"
|
||||
"Intensity" = "Intensité"
|
||||
"Interaction" = "Interaction"
|
||||
"Interactions" = "Interactions"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Design d’interfaces et prototypage. Un outil de design libre et gratuit, développé en salle blanche (clean-room) et écrit de zéro en Rust, membre des Crafting Apps d’ArtCraft."
|
||||
@@ -624,6 +658,7 @@ status = "draft — needs native review"
|
||||
"Invite link" = "Lien d’invitation"
|
||||
"Invite link copied" = "Lien d’invitation copié"
|
||||
"It asks permission to:" = "Il demande l’autorisation de :"
|
||||
"Jan" = "janv."
|
||||
"Join" = "Rejoindre"
|
||||
"Join code copied" = "Code d’accès copié"
|
||||
"Join code or invite link" = "Code d’accès ou lien d’invitation"
|
||||
@@ -634,6 +669,8 @@ status = "draft — needs native review"
|
||||
"Joined {file}" = "Connecté à {file}"
|
||||
"Joining…" = "Connexion…"
|
||||
"Join|stroke" = "Jointure"
|
||||
"Jul" = "juil."
|
||||
"Jun" = "juin"
|
||||
"Just now" = "À l’instant"
|
||||
"Justify" = "Justifier"
|
||||
"Keep appearance" = "Conserver l’apparence"
|
||||
@@ -643,11 +680,14 @@ status = "draft — needs native review"
|
||||
"Key/Gamepad" = "Touche/manette"
|
||||
"Key/Gamepad {key}" = "Touche/manette {key}"
|
||||
"Keyboard shortcuts" = "Raccourcis clavier"
|
||||
"Kits" = "Kits"
|
||||
"Landing page" = "Page de destination"
|
||||
"Language" = "Langue"
|
||||
"Lasso" = "Lasso"
|
||||
"Last" = "Dernier"
|
||||
"Last change: {note}" = "Dernière modification : {note}"
|
||||
"Last commit" = "Dernier commit"
|
||||
"Last modified" = "Dernière modification"
|
||||
"Last on top" = "Dernier au-dessus"
|
||||
"Layer blur" = "Flou de calque"
|
||||
"Layer name" = "Nom du calque"
|
||||
@@ -686,6 +726,7 @@ status = "draft — needs native review"
|
||||
"Link" = "Lien"
|
||||
"Link copied" = "Lien copié"
|
||||
"Link docs, code or tickets for developers." = "Liez de la documentation, du code ou des tickets pour les développeurs."
|
||||
"Link frames on the Prototype tab, then present the flow" = "Reliez les cadres dans l’onglet Prototype, puis présentez le flux"
|
||||
"Link this component to its code component: instances then show your real snippet with their values." = "Reliez ce composant à son composant de code : les instances affichent alors votre véritable extrait avec leurs valeurs."
|
||||
"List spacing" = "Espacement de liste"
|
||||
"List style" = "Style de liste"
|
||||
@@ -703,7 +744,9 @@ status = "draft — needs native review"
|
||||
"Main" = "Principal"
|
||||
"Main component deleted" = "Composant principal supprimé"
|
||||
"Main menu" = "Menu principal"
|
||||
"Make a component" = "Créer un composant"
|
||||
"Make slot" = "Créer un emplacement"
|
||||
"Mar" = "mars"
|
||||
"Margin" = "Marge"
|
||||
"Mark all as read" = "Tout marquer comme lu"
|
||||
"Mark as resolved" = "Marquer comme résolu"
|
||||
@@ -716,6 +759,7 @@ status = "draft — needs native review"
|
||||
"Max W" = "L max."
|
||||
"Max lines" = "Lignes max."
|
||||
"Maximize" = "Agrandir"
|
||||
"May" = "mai"
|
||||
"Measure to the selection" = "Mesurer jusqu’à la sélection"
|
||||
"Merge into main" = "Fusionner dans le principal"
|
||||
"Merge regions" = "Fusionner les régions"
|
||||
@@ -737,6 +781,7 @@ status = "draft — needs native review"
|
||||
"Mixed" = "Mixte"
|
||||
"Mixed fonts" = "Polices mixtes"
|
||||
"Mixed interactions: click + to replace them" = "Interactions mixtes : cliquez sur + pour les remplacer"
|
||||
"Mobile app" = "Application mobile"
|
||||
"Mode" = "Mode"
|
||||
"Model" = "Modèle"
|
||||
"Models" = "Modèles"
|
||||
@@ -766,6 +811,7 @@ status = "draft — needs native review"
|
||||
"Navigate to" = "Naviguer vers"
|
||||
"Nested instance" = "Instance imbriquée"
|
||||
"New design file" = "Nouveau fichier de design"
|
||||
"Newest first" = "Plus récent d’abord"
|
||||
"Next frame" = "Cadre suivant"
|
||||
"Next or previous control in the focused region" = "Contrôle suivant ou précédent dans la région active"
|
||||
"Next ready for dev" = "Prêt pour le dev suivant"
|
||||
@@ -799,6 +845,7 @@ status = "draft — needs native review"
|
||||
"No model credits were built into this copy." = "Cette copie ne contient aucun crédit de modèle."
|
||||
"No number variables fit here." = "Aucune variable numérique ne convient ici."
|
||||
"No number variables yet. Create them in Local variables." = "Aucune variable numérique pour l’instant. Créez-en dans Variables locales."
|
||||
"No other pages" = "Aucune autre page"
|
||||
"No paints" = "Aucune peinture"
|
||||
"No plugins on {server}." = "Aucun plugin sur {server}."
|
||||
"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Aucun plugin pour l’instant. Importez un plugin en développement depuis son figcraft-plugin.json."
|
||||
@@ -812,6 +859,7 @@ status = "draft — needs native review"
|
||||
"No string variables yet. Create them in Local variables." = "Aucune variable de chaîne pour l’instant. Créez-en dans Variables locales."
|
||||
"No styles yet." = "Aucun style pour l’instant."
|
||||
"No styles yet. Click + to create one from the selection." = "Aucun style pour l’instant. Cliquez sur + pour en créer un à partir de la sélection."
|
||||
"No templates match your search" = "Aucun modèle ne correspond à votre recherche"
|
||||
"No text styles yet. Click + to create one from the selected text." = "Aucun style de texte pour l’instant. Cliquez sur + pour en créer un à partir du texte sélectionné."
|
||||
"No variables fit here." = "Aucune variable ne convient ici."
|
||||
"No variables yet. Create them in Local variables." = "Aucune variable pour l’instant. Créez-en dans Variables locales."
|
||||
@@ -831,6 +879,7 @@ status = "draft — needs native review"
|
||||
"Nothing here yet. Set a frame's status to list it." = "Rien pour l’instant. Attribuez un statut à un cadre pour le lister ici."
|
||||
"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Rien en ligne pour l’instant. Utilisez Partager sur un fichier pour le mettre en ligne et il apparaîtra ici."
|
||||
"Nothing to export" = "Rien à exporter"
|
||||
"Nov" = "nov."
|
||||
"Now" = "Maintenant"
|
||||
"Nudge (Shift: big nudge)" = "Décaler (Maj : grand décalage)"
|
||||
"Nudge amount" = "Pas de décalage"
|
||||
@@ -839,13 +888,16 @@ status = "draft — needs native review"
|
||||
"Number ↓" = "Numéro ↓"
|
||||
"Numbered list" = "Liste numérotée"
|
||||
"Object" = "Objet"
|
||||
"Oct" = "oct."
|
||||
"Off" = "Non"
|
||||
"Offered first when filling the slot (any content is allowed)" = "Proposés en premier pour remplir l’emplacement (tout contenu est autorisé)"
|
||||
"Offered first when swapping the instance (any component is allowed)" = "Proposés en premier pour permuter l’instance (tout composant est autorisé)"
|
||||
"Offset" = "Décalage"
|
||||
"Oldest first" = "Plus ancien d’abord"
|
||||
"On click" = "Au clic"
|
||||
"On drag" = "Au glissement"
|
||||
"On this page" = "Sur cette page"
|
||||
"One announcement in five common sizes: square, portrait, story, link and banner" = "Une annonce en cinq formats courants : carré, portrait, story, lien et bannière"
|
||||
"Online" = "En ligne"
|
||||
"Online files" = "Fichiers en ligne"
|
||||
"Online sharing is off: files are shared on the local network only." = "Le partage en ligne est désactivé : les fichiers ne sont partagés que sur le réseau local."
|
||||
@@ -870,9 +922,13 @@ status = "draft — needs native review"
|
||||
"Open variables ({count})" = "Ouvrir les variables ({count})"
|
||||
"Opened as a new document" = "Ouvert comme nouveau document"
|
||||
"Opened “{name}” in a new tab" = "« {name} » ouvert dans un nouvel onglet"
|
||||
"Opening the file…" = "Ouverture du fichier…"
|
||||
"Opening {n} files as new files" = { one = "Ouverture comme nouveau fichier", other = "Ouverture de {n} fichiers comme nouveaux fichiers" }
|
||||
"Open|comments" = "Ouverts"
|
||||
"Options" = "Options"
|
||||
"Orange" = "Orange"
|
||||
"Order" = "Ordre"
|
||||
"Other" = "Autre"
|
||||
"Other apps may not open every .fig file exported here." = "D’autres applications risquent de ne pas ouvrir tous les fichiers .fig exportés ici."
|
||||
"Others lose access and keep their copy; the file stays on the server for you" = "Les autres perdent l’accès et conservent leur copie ; le fichier reste sur le serveur pour vous"
|
||||
"Others type this under Share ▸ Join with a code" = "Les autres le saisissent dans Partager ▸ Rejoindre avec un code"
|
||||
@@ -893,6 +949,7 @@ status = "draft — needs native review"
|
||||
"Page" = "Page"
|
||||
"Pages" = "Pages"
|
||||
"Pages added to the document" = "Pages ajoutées au document"
|
||||
"Pick the Frame tool, then drag on the canvas or choose a device size" = "Choisissez l’outil Cadre, puis faites glisser sur le canevas ou choisissez une taille d’appareil"
|
||||
"Placed on the current page" = "Placé sur la page actuelle"
|
||||
"Pages: {pages}" = "Pages : {pages}"
|
||||
"Paint bucket" = "Pot de peinture"
|
||||
@@ -930,7 +987,10 @@ status = "draft — needs native review"
|
||||
"Place a shape or frame (its tool chosen)" = "Placer une forme ou un cadre (outil choisi)"
|
||||
"Place automatically" = "Placer automatiquement"
|
||||
"Place image" = "Placer une image"
|
||||
"Playground" = "Bac à sable"
|
||||
"Plugin" = "Plugin"
|
||||
"Plugins" = "Plugins"
|
||||
"Plugins & widgets" = "Plugins et widgets"
|
||||
"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Les plugins s’exécutent dans le bac à sable de FigCraft, sans fichiers, réseau ni horloge. N’installez que des plugins de confiance."
|
||||
"Plus darker" = "Plus foncé"
|
||||
"Plus lighter" = "Plus clair"
|
||||
@@ -947,6 +1007,7 @@ status = "draft — needs native review"
|
||||
"Present or preview" = "Présenter ou prévisualiser"
|
||||
"Present this flow" = "Présenter ce flux"
|
||||
"Presentation" = "Présentation"
|
||||
"Presentation slides" = "Diapositives de présentation"
|
||||
"Presenting: comments" = "Présentation : commentaires"
|
||||
"Presenting: cycle the size options" = "Présentation : parcourir les options de taille"
|
||||
"Presenting: full screen" = "Présentation : plein écran"
|
||||
@@ -1003,6 +1064,7 @@ status = "draft — needs native review"
|
||||
"Remove auto layout" = "Retirer la mise en page auto"
|
||||
"Remove dev resource" = "Retirer la ressource de développement"
|
||||
"Remove export setting" = "Retirer le réglage d’exportation"
|
||||
"Remove from recents" = "Retirer des récents"
|
||||
"Remove from server" = "Retirer du serveur"
|
||||
"Remove from server…" = "Retirer du serveur…"
|
||||
"Remove guide" = "Retirer le repère"
|
||||
@@ -1115,15 +1177,18 @@ status = "draft — needs native review"
|
||||
"Search layers" = "Rechercher des calques"
|
||||
"Search plugins" = "Rechercher des plugins"
|
||||
"Search shortcuts" = "Rechercher des raccourcis"
|
||||
"Search templates" = "Rechercher des modèles"
|
||||
"Section" = "Section"
|
||||
"Section color" = "Couleur de la section"
|
||||
"Sections" = "Sections"
|
||||
"See all templates" = "Voir tous les modèles"
|
||||
"Select a frame and add a starting point to present it as a flow." = "Sélectionnez un cadre et ajoutez un point de départ pour le présenter comme un flux."
|
||||
"Select a frame to preview it" = "Sélectionnez un cadre pour le prévisualiser"
|
||||
"Select a layer to see its properties and code" = "Sélectionnez un calque pour voir ses propriétés et son code"
|
||||
"Select all" = "Tout sélectionner"
|
||||
"Select all with same" = "Sélectionner tout ce qui a le même"
|
||||
"Select layer" = "Sélectionner le calque"
|
||||
"Select layers and they space themselves as their content changes" = "Sélectionnez des calques et leur espacement s’ajuste quand leur contenu change"
|
||||
"Select layers to apply the style to" = "Sélectionnez les calques auxquels appliquer le style"
|
||||
"Select layers with this color" = "Sélectionner les calques de cette couleur"
|
||||
"Select overlapping shapes" = "Sélectionnez des formes qui se chevauchent"
|
||||
@@ -1136,6 +1201,7 @@ status = "draft — needs native review"
|
||||
"Send backward" = "Reculer"
|
||||
"Send report" = "Envoyer le signalement"
|
||||
"Send to back" = "Mettre à l’arrière-plan"
|
||||
"Sep" = "sept."
|
||||
"Server address" = "Adresse du serveur"
|
||||
"Server address (ws://host:47900/)" = "Adresse du serveur (ws://host:47900/)"
|
||||
"Server for online sharing" = "Serveur pour le partage en ligne"
|
||||
@@ -1150,6 +1216,7 @@ status = "draft — needs native review"
|
||||
"Shape tools" = "Outils de forme"
|
||||
"Share" = "Partager"
|
||||
"Share this file" = "Partager ce fichier"
|
||||
"Share with a code" = "Partager avec un code"
|
||||
"Share: live session, export" = "Partager : session en direct, exportation"
|
||||
"Shared" = "Partagé"
|
||||
"Shared: copy its code from the file's menu" = "Partagé : copiez son code depuis le menu du fichier"
|
||||
@@ -1159,6 +1226,7 @@ status = "draft — needs native review"
|
||||
"Sharing online · {people}" = "Partage en ligne · {people}"
|
||||
"Sharing stopped: the file stays on the server, open only to you" = "Partage arrêté : le fichier reste sur le serveur, accessible à vous seul"
|
||||
"Show" = "Afficher"
|
||||
"Show all templates" = "Afficher tous les modèles"
|
||||
"Show as grid" = "Afficher en grille"
|
||||
"Show as list" = "Afficher en liste"
|
||||
"Show autosaves" = "Afficher les sauvegardes auto"
|
||||
@@ -1167,6 +1235,9 @@ status = "draft — needs native review"
|
||||
"Show device frame" = "Afficher le cadre de l’appareil"
|
||||
"Show dimensions on objects" = "Afficher les dimensions sur les objets"
|
||||
"Show hotspot hints" = "Afficher les zones interactives"
|
||||
"Show in Explorer" = "Afficher dans l’Explorateur"
|
||||
"Show in Finder" = "Afficher dans le Finder"
|
||||
"Show in folder" = "Afficher dans le dossier"
|
||||
"Show only this on the canvas" = "Afficher uniquement ceci sur le canevas"
|
||||
"Show open" = "Afficher les ouverts"
|
||||
"Show section contents" = "Afficher le contenu de la section"
|
||||
@@ -1191,13 +1262,17 @@ status = "draft — needs native review"
|
||||
"Small caps" = "Petites capitales"
|
||||
"Smart animate" = "Animation intelligente"
|
||||
"Snap to pixel grid" = "Aimanter à la grille de pixels"
|
||||
"Social and slides" = "Réseaux sociaux et diapositives"
|
||||
"Social media" = "Réseaux sociaux"
|
||||
"Social post sizes" = "Formats de publications sociales"
|
||||
"Soft light" = "Lumière douce"
|
||||
"Solid" = "Continu"
|
||||
"Some variants have the same property values: give each one a unique combination" = "Certaines variantes ont les mêmes valeurs de propriété : donnez à chacune une combinaison unique"
|
||||
"Sort" = "Trier"
|
||||
"Sort by" = "Trier par"
|
||||
"Sort by date" = "Trier par date"
|
||||
"Sort by page" = "Trier par page"
|
||||
"Sort by {column}" = "Trier par {column}"
|
||||
"Space around" = "Espace autour"
|
||||
"Space between" = "Espace entre"
|
||||
"Space drag" = "Espace et glisser"
|
||||
@@ -1216,9 +1291,11 @@ status = "draft — needs native review"
|
||||
"Start" = "Début"
|
||||
"Start ascending from / stop descending at" = "Compter vers le haut depuis / vers le bas jusqu’à"
|
||||
"Start from a blank canvas" = "Commencer avec un canevas vide"
|
||||
"Start from a template" = "Commencer avec un modèle"
|
||||
"Start on path" = "Début sur le tracé"
|
||||
"Start point" = "Point de départ"
|
||||
"Start sharing" = "Commencer le partage"
|
||||
"Start sharing with the Share button, then pass on the code it gives" = "Commencez à partager avec le bouton Partager, puis communiquez le code qu’il donne"
|
||||
"Starting to share…" = "Démarrage du partage…"
|
||||
"Status" = "Statut"
|
||||
"Sticky" = "Collant"
|
||||
@@ -1267,6 +1344,7 @@ status = "draft — needs native review"
|
||||
"Tell the server's operator what is wrong with it" = "Indiquez à l’opérateur du serveur ce qui ne va pas"
|
||||
"Temperature" = "Température"
|
||||
"Template" = "Modèle"
|
||||
"Templates" = "Modèles"
|
||||
"Terms" = "Conditions"
|
||||
"Text" = "Texte"
|
||||
"Text align" = "Alignement du texte"
|
||||
@@ -1276,6 +1354,9 @@ status = "draft — needs native review"
|
||||
"Texts" = "Textes"
|
||||
"Texture" = "Texture"
|
||||
"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Le canevas affiche les couleurs en sRGB ; les fichiers PNG, JPG et WebP exportés sont balisés Display P3."
|
||||
"The destination document is unavailable. Nothing was imported." = "Le document de destination est indisponible. Rien n’a été importé."
|
||||
"The destination document was closed. Nothing was imported." = "Le document de destination a été fermé. Rien n’a été importé."
|
||||
"The destination page was removed. Nothing was imported." = "La page de destination a été supprimée. Rien n’a été importé."
|
||||
"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Le fichier est supprimé du serveur : plus personne ne pourra l’ouvrir ni le rejoindre depuis celui-ci, et c’est irréversible. Les copies ouvertes restent à ceux qui les ont."
|
||||
"The file now holds another library" = "Le fichier contient désormais une autre bibliothèque"
|
||||
"The file now holds another library (\"{name}\")" = "Le fichier contient désormais une autre bibliothèque (« {name} »)"
|
||||
@@ -1324,6 +1405,8 @@ status = "draft — needs native review"
|
||||
"Truncate with an ellipsis" = "Tronquer avec des points de suspension"
|
||||
"Try again" = "Réessayer"
|
||||
"Try another search or filter." = "Essayez une autre recherche ou un autre filtre."
|
||||
"Try another word, or look at every kind." = "Essayez un autre mot, ou regardez chaque type."
|
||||
"Turn a layer into a component, reuse it, and change every copy at once" = "Transformez un calque en composant, réutilisez-le et modifiez toutes les copies d’un coup"
|
||||
"Two digits" = "Deux chiffres"
|
||||
"Type" = "Type"
|
||||
"Type settings" = "Réglages typographiques"
|
||||
@@ -1362,6 +1445,7 @@ status = "draft — needs native review"
|
||||
"Use the layer's fill" = "Utiliser le remplissage du calque"
|
||||
"Used in {files} · {instances}" = "Utilisé dans {files} · {instances}"
|
||||
"Username" = "Nom d’utilisateur"
|
||||
"Uses variables: each layer shows them in its own mode" = "Utilise des variables : chaque calque les affiche dans son propre mode"
|
||||
"Value" = "Valeur"
|
||||
"Variable" = "Variable"
|
||||
"Variable collections" = "Collections de variables"
|
||||
@@ -1400,21 +1484,25 @@ status = "draft — needs native review"
|
||||
"Web" = "Web"
|
||||
"WebSocket URL" = "URL WebSocket"
|
||||
"WebSocket URL copied" = "URL WebSocket copiée"
|
||||
"Websites" = "Sites web"
|
||||
"Wedge" = "Coin"
|
||||
"Weight" = "Graisse"
|
||||
"What changed since it was marked ready for dev" = "Ce qui a changé depuis le marquage prêt pour le dev"
|
||||
"What is wrong with this file? The server's operator gets your report." = "Qu’est-ce qui ne va pas avec ce fichier ? L’opérateur du serveur reçoit votre signalement."
|
||||
"What it's for" = "À quoi il sert"
|
||||
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "L’état d’origine de cette modification est inconnu : elle date d’avant que FigCraft ne conserve une copie des cadres prêts pour le dev. Résolvez la modification (ou marquez-la à nouveau comme prête) pour comparer à partir de maintenant."
|
||||
"What you try here changes this preview and the code below, not the file" = "Ce que vous essayez ici change cet aperçu et le code ci-dessous, pas le fichier"
|
||||
"While hovering" = "Pendant le survol"
|
||||
"While pressing" = "Pendant l’appui"
|
||||
"White" = "Blanc"
|
||||
"Who runs it, its terms and how long it keeps files" = "Qui le gère, ses conditions et combien de temps il conserve les fichiers"
|
||||
"Why it changed" = "Pourquoi cela a changé"
|
||||
"Widget" = "Widget"
|
||||
"Widgets" = "Widgets"
|
||||
"Width" = "Largeur"
|
||||
"Width point" = "Point de largeur"
|
||||
"Width profile" = "Profil de largeur"
|
||||
"Wireframe kit" = "Kit de wireframes"
|
||||
"With the layers' export settings" = "Avec les réglages d’exportation des calques"
|
||||
"Wrap" = "Renvoi à la ligne"
|
||||
"Wrap in new section" = "Envelopper dans une nouvelle section"
|
||||
@@ -1429,6 +1517,7 @@ status = "draft — needs native review"
|
||||
"Your name in comments" = "Votre nom dans les commentaires"
|
||||
"Your system asks for reduced motion: animations stay off." = "Votre système demande de réduire les animations : elles restent désactivées."
|
||||
"Z" = "Z"
|
||||
"Z to A" = "Z à A"
|
||||
"Zoom" = "Zoom"
|
||||
"Zoom around the pointer" = "Zoomer autour du pointeur"
|
||||
"Zoom device to fill window" = "Zoomer l’appareil pour remplir la fenêtre"
|
||||
@@ -1492,6 +1581,7 @@ status = "draft — needs native review"
|
||||
"invalid internal .fig text path data was ignored" = "les données internes non valides de texte sur tracé du .fig ont été ignorées"
|
||||
"invalid or oversized vector networks import as outlines" = "les réseaux vectoriels non valides ou trop grands sont importés comme tracés"
|
||||
"invalid or oversized vector path data was skipped" = "les données de tracé vectoriel non valides ou trop grandes ont été ignorées"
|
||||
"just now" = "à l’instant"
|
||||
"key triggers without a supported key were skipped" = "les déclencheurs de touche sans touche prise en charge ont été ignorés"
|
||||
"key up triggers import as key presses" = "les déclencheurs au relâchement d’une touche sont importés comme des appuis sur la touche"
|
||||
"layers without a position were placed at their parent's origin" = "les calques sans position ont été placés à l’origine de leur parent"
|
||||
@@ -1559,9 +1649,11 @@ status = "draft — needs native review"
|
||||
"very long vector paths were skipped" = "les tracés vectoriels très longs ont été ignorés"
|
||||
"video actions are not supported and were skipped" = "les actions vidéo ne sont pas prises en charge et ont été ignorées"
|
||||
"video triggers are not supported and were skipped" = "les déclencheurs vidéo ne sont pas pris en charge et ont été ignorés"
|
||||
"yesterday" = "hier"
|
||||
"{instances} in {files}" = "{instances} dans {files}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language} : traduction provisoire, en attente de relecture par un locuteur natif."
|
||||
"{length} bleed" = "fond perdu de {length}"
|
||||
"{month} {day}, {year}" = "{day} {month} {year}"
|
||||
"{name}\nClick to apply to the selection" = "{name}\nCliquez pour l’appliquer à la sélection"
|
||||
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nFaites glisser sur le canevas, ou cliquez pour le placer au centre de la vue.\nMaintenez Alt (Option) en le déposant sur une instance pour la permuter."
|
||||
"{name} (from {library})" = "{name} (de {library})"
|
||||
@@ -1726,8 +1818,10 @@ status = "draft — needs native review"
|
||||
"component.editProperty" = "Modifier la propriété de composant"
|
||||
"component.exposeInstance" = "Exposer l’instance imbriquée"
|
||||
"component.goToMain" = "Aller au composant principal"
|
||||
"component.hideWhenPublishing" = "Masquer à la publication"
|
||||
"component.list" = "Lister les composants"
|
||||
"component.makeSlot" = "Créer un emplacement"
|
||||
"component.playground" = "Bac à sable de composant"
|
||||
"component.properties" = "Propriétés du composant"
|
||||
"component.pushChanges" = "Transmettre les modifications au composant principal"
|
||||
"component.removeSlot" = "Retirer l’emplacement"
|
||||
@@ -1786,6 +1880,7 @@ status = "draft — needs native review"
|
||||
"file.exportSvg" = "Exporter la sélection en SVG"
|
||||
"file.import" = "Importer"
|
||||
"file.new" = "Nouveau fichier de design"
|
||||
"file.newFromTemplate" = "Nouveau à partir d’un modèle"
|
||||
"file.open" = "Ouvrir"
|
||||
"file.openRecent" = "Ouvrir un fichier récent"
|
||||
"file.placeImage" = "Placer une image"
|
||||
@@ -1813,6 +1908,7 @@ status = "draft — needs native review"
|
||||
"guide.remove" = "Retirer le repère"
|
||||
"guide.select" = "Sélectionner le repère"
|
||||
"help.about" = "À propos de FigCraft"
|
||||
"help.gettingStarted" = "Premiers pas"
|
||||
"help.page" = "Page d’aide"
|
||||
"help.shortcuts" = "Raccourcis clavier"
|
||||
"image.adjust" = "Régler l’image"
|
||||
@@ -1866,6 +1962,9 @@ status = "draft — needs native review"
|
||||
"node.reparent" = "Déplacer le calque dans"
|
||||
"node.set" = "Définir les propriétés"
|
||||
"object.batchRename" = "Renommer par lot"
|
||||
"object.convertToFrame" = "Convertir en cadre"
|
||||
"object.convertToGroup" = "Convertir en groupe"
|
||||
"object.convertToSection" = "Convertir en section"
|
||||
"object.flipH" = "Retourner horizontalement"
|
||||
"object.flipV" = "Retourner verticalement"
|
||||
"object.frameSelection" = "Encadrer la sélection"
|
||||
@@ -1986,6 +2085,7 @@ status = "draft — needs native review"
|
||||
"stroke.set" = "Définir le contour"
|
||||
"stroke.widthProfile" = "Profil de largeur"
|
||||
"style.apply" = "Appliquer le style"
|
||||
"style.bind" = "Lier une variable dans un style"
|
||||
"style.create" = "Créer un style"
|
||||
"style.delete" = "Supprimer le style"
|
||||
"style.detach" = "Détacher le style"
|
||||
@@ -1994,6 +2094,7 @@ status = "draft — needs native review"
|
||||
"style.move" = "Déplacer les styles"
|
||||
"style.renameFolder" = "Renommer le dossier de styles"
|
||||
"style.update" = "Modifier le style"
|
||||
"template.list" = "Lister les modèles"
|
||||
"text.alignCenter" = "Centrer"
|
||||
"text.alignJustify" = "Justifier"
|
||||
"text.alignLeft" = "Aligner à gauche"
|
||||
@@ -2159,8 +2260,11 @@ status = "draft — needs native review"
|
||||
"view.nextPage" = "Page suivante"
|
||||
"view.nextRegion" = "Activer la région suivante"
|
||||
"view.outlines" = "Contours"
|
||||
"view.outlinesHiddenLayers" = "Inclure les calques masqués dans les contours"
|
||||
"view.outlinesObjectBounds" = "Inclure les limites des objets dans les contours"
|
||||
"view.outlinesSelection" = "Contours de la sélection"
|
||||
"view.pan" = "Faire défiler la vue"
|
||||
"view.panelWidths" = "Réinitialiser la largeur des panneaux"
|
||||
"view.pixelGrid" = "Grille de pixels"
|
||||
"view.pixelPreview" = "Aperçu des pixels"
|
||||
"view.prevFrame" = "Zoomer sur le cadre précédent"
|
||||
@@ -2218,6 +2322,8 @@ status = "draft — needs native review"
|
||||
"view.design" = "Design"
|
||||
"view.layers" = "Calques"
|
||||
"view.outlines" = "Afficher les contours"
|
||||
"view.outlinesHiddenLayers" = "Inclure les calques masqués"
|
||||
"view.outlinesObjectBounds" = "Inclure les limites des objets"
|
||||
"view.outlinesSelection" = "Afficher les contours de la sélection"
|
||||
"view.prototype" = "Prototype"
|
||||
"view.themeDark" = "Sombre"
|
||||
|
||||
@@ -638,6 +638,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "Tukar instance (tidak ada component lain)"
|
||||
"Instances" = "Instance"
|
||||
"Instant" = "Instan"
|
||||
"Intensity" = "Intensitas"
|
||||
"Interaction" = "Interaksi"
|
||||
"Interactions" = "Interaksi"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Desain antarmuka dan prototyping. Alat desain clean-room gratis dan open-source yang ditulis dari nol dengan Rust, bagian dari ArtCraft Crafting Apps."
|
||||
|
||||
@@ -643,6 +643,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "インスタンスの入れ替え(他のコンポーネントなし)"
|
||||
"Instances" = "インスタンス"
|
||||
"Instant" = "即時"
|
||||
"Intensity" = "強さ"
|
||||
"Interaction" = "インタラクション"
|
||||
"Interactions" = "インタラクション"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "インターフェースデザインとプロトタイピング。Rust でゼロから書かれた、無料でオープンソースのクリーンルーム設計のデザインツール。ArtCraft Crafting Apps の一つ。"
|
||||
|
||||
@@ -615,6 +615,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "인스턴스 교체(다른 컴포넌트 없음)"
|
||||
"Instances" = "인스턴스"
|
||||
"Instant" = "즉시"
|
||||
"Intensity" = "강도"
|
||||
"Interaction" = "인터랙션"
|
||||
"Interactions" = "인터랙션"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "인터페이스 디자인과 프로토타이핑. Rust로 처음부터 작성한 무료 오픈 소스 클린룸 디자인 도구이며 ArtCraft Crafting Apps의 하나."
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -614,6 +614,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "Troca de instância (nenhum outro componente)"
|
||||
"Instances" = "Instâncias"
|
||||
"Instant" = "Instantâneo"
|
||||
"Intensity" = "Brilho"
|
||||
"Interaction" = "Interação"
|
||||
"Interactions" = "Interações"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Design de interfaces e prototipagem. Uma ferramenta de design gratuita e de código aberto, com implementação independente (clean-room), escrita do zero em Rust, parte dos Crafting Apps da ArtCraft."
|
||||
|
||||
@@ -635,6 +635,7 @@ name = "Русский"
|
||||
"Instance swap (no other component)" = "Замена экземпляра (других компонентов нет)"
|
||||
"Instances" = "Экземпляры"
|
||||
"Instant" = "Мгновенно"
|
||||
"Intensity" = "Яркость"
|
||||
"Interaction" = "Взаимодействие"
|
||||
"Interactions" = "Взаимодействия"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Дизайн интерфейсов и прототипирование. Бесплатный инструмент с открытым исходным кодом, независимо созданный с нуля на Rust, из семейства ArtCraft Crafting Apps."
|
||||
@@ -818,6 +819,7 @@ name = "Русский"
|
||||
"No contributor data was built into this copy." = "В этой сборке нет данных об участниках."
|
||||
"No device" = "Без устройства"
|
||||
"No fonts match" = "Подходящих шрифтов нет"
|
||||
"No files or templates match your search" = "Файлы и шаблоны по запросу не найдены"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Код подключения недоступен (сервер не поддерживает коды или адрес нельзя включить в код): поделитесь ссылкой."
|
||||
"No layer changes to show." = "Нет изменений слоёв для показа."
|
||||
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Библиотек пока нет. Добавьте файл .figcraft, опубликованный как библиотека, или вставьте ссылку на командную библиотеку."
|
||||
@@ -1156,6 +1158,8 @@ name = "Русский"
|
||||
"Search actions, layers and tools" = "Поиск действий, слоёв и инструментов"
|
||||
"Search assets" = "Поиск ресурсов"
|
||||
"Search comments" = "Поиск комментариев"
|
||||
"Search files and templates" = "Поиск файлов и шаблонов"
|
||||
"Search results" = "Результаты поиска"
|
||||
"Search fonts" = "Поиск шрифтов"
|
||||
"Search layers" = "Поиск слоёв"
|
||||
"Search shortcuts" = "Поиск сочетаний клавиш"
|
||||
@@ -1381,6 +1385,7 @@ name = "Русский"
|
||||
"Truncate with an ellipsis" = "Обрезать с многоточием"
|
||||
"Try again" = "Повторить"
|
||||
"Try another search or filter." = "Попробуйте другой запрос или фильтр."
|
||||
"Try another search, or browse templates." = "Попробуйте другой запрос или просмотрите шаблоны."
|
||||
"Try another word, or look at every kind." = "Попробуйте другое слово или посмотрите все категории."
|
||||
"Turn a layer into a component, reuse it, and change every copy at once" = "Превратите слой в компонент, используйте повторно и изменяйте все копии сразу"
|
||||
"Two digits" = "Две цифры"
|
||||
|
||||
@@ -645,6 +645,7 @@ name = "Svenska"
|
||||
"Instance swap (no other component)" = "Instansbyte (ingen annan komponent)"
|
||||
"Instances" = "Instanser"
|
||||
"Instant" = "Direkt"
|
||||
"Intensity" = "Intensitet"
|
||||
"Interaction" = "Interaktion"
|
||||
"Interactions" = "Interaktioner"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Gränssnittsdesign och prototyper. Ett fritt clean-room-designverktyg med öppen källkod, skrivet från grunden i Rust och en del av ArtCraft Crafting Apps."
|
||||
|
||||
@@ -638,6 +638,7 @@ name = "Türkçe"
|
||||
"Instance swap (no other component)" = "Örnek değiştirme (başka bileşen yok)"
|
||||
"Instances" = "Örnekler"
|
||||
"Instant" = "Anında"
|
||||
"Intensity" = "Yoğunluk"
|
||||
"Interaction" = "Etkileşim"
|
||||
"Interactions" = "Etkileşimler"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Arayüz tasarımı ve prototipleme. Rust ile sıfırdan yazılmış, ücretsiz, açık kaynaklı, temiz oda yöntemiyle geliştirilmiş bir tasarım aracı; ArtCraft Crafting Apps ailesinin bir parçası."
|
||||
|
||||
@@ -616,6 +616,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "实例替换(无其他组件)"
|
||||
"Instances" = "实例"
|
||||
"Instant" = "即时"
|
||||
"Intensity" = "强度"
|
||||
"Interaction" = "交互"
|
||||
"Interactions" = "交互"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "界面设计与原型制作。一款以 Rust 从零编写的免费、开源、净室设计工具,属于 ArtCraft Crafting Apps 系列。"
|
||||
|
||||
@@ -1129,7 +1129,9 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
|
||||
if !ov.pen.is_empty() {
|
||||
let pts: Vec<Pos2> = ov.pen.iter().map(|[x, y]| app.to_screen(*x, *y)).collect();
|
||||
if app.session.tool.tool == ToolId::Pencil {
|
||||
painter.add(Shape::line(pts, stroke(t.text, 2.0)));
|
||||
// The stroke it will make, in its colour and weight (at least a point when zoomed out).
|
||||
let (color, weight) = figcraft_engine::tools::PENCIL_STROKE;
|
||||
painter.add(Shape::line(pts, stroke(doc_c32(painter.ctx(), color), ((weight * zoom) as f32).max(1.0))));
|
||||
} else {
|
||||
for [x0, y0, x1, y1] in &ov.pen_handles {
|
||||
bezier_handle(painter, app.to_screen(*x0, *y0), app.to_screen(*x1, *y1), 3.5, Color32::WHITE);
|
||||
|
||||
@@ -128,3 +128,36 @@ fn the_pen_draws_the_handles_of_the_point_being_dragged_and_a_curved_band() {
|
||||
let band = shapes.iter().any(|s| matches!(s, Shape::CubicBezier(c) if c.points[0].distance(a) < 0.5 && c.points[1].distance(at(&u, 140.0, 100.0)) < 0.5 && c.stroke.color == accent));
|
||||
assert!(band, "a curve leaving along the out handle");
|
||||
}
|
||||
|
||||
/// While drawing, the Pencil shows the stroke it will make, in its colour and weight (#256): in a
|
||||
/// dark theme the theme's text colour was white, so over a white shape nothing showed until the
|
||||
/// stroke was let go.
|
||||
#[test]
|
||||
fn the_pencil_previews_its_stroke_in_its_colour_and_weight() {
|
||||
let mut u = Ui::new(Session::new());
|
||||
u.app.run("tool.set", json!({"tool": "pencil"}));
|
||||
let (color, weight) = figcraft_engine::tools::PENCIL_STROKE;
|
||||
let preview = |shapes: &[Shape]| {
|
||||
shapes.iter().find_map(|s| match s {
|
||||
Shape::Path(p) if !p.closed && p.points.len() >= 3 && p.stroke.color == egui::epaint::ColorMode::Solid(crate::theme::c32(color)) => {
|
||||
Some(p.stroke.width)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let at = |u: &Ui, x: f64, y: f64| u.app.to_screen(x, y);
|
||||
let start = at(&u, 10.0, 10.0);
|
||||
u.button(start, true);
|
||||
for i in 1..=6 {
|
||||
let p = at(&u, 10.0 + 12.0 * f64::from(i), 10.0 + 6.0 * f64::from(i % 2));
|
||||
u.frame(vec![egui::Event::PointerMoved(p)]);
|
||||
}
|
||||
let zoom = u.app.session.ds().unwrap().view.zoom;
|
||||
let w = preview(&flat(&u.frame_output(vec![]).shapes)).expect("a preview while drawing");
|
||||
assert!((w - (weight * zoom) as f32).abs() < 0.01, "{w} for {weight} at {zoom}");
|
||||
// Zoomed far out it stays visible.
|
||||
u.app.session.ds_mut().unwrap().view.zoom = 0.05;
|
||||
let p = at(&u, 120.0, 40.0);
|
||||
let w = preview(&flat(&u.frame_output(vec![egui::Event::PointerMoved(p)]).shapes)).expect("still previewing");
|
||||
assert!((w - 1.0).abs() < 0.01, "at least a point: {w}");
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
//! for the frames that follow (all false: released); `gamepad` presses a controller button in the
|
||||
//! prototype playing (presenter or preview pane)
|
||||
//! - `ui.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog)
|
||||
//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, focusRegion?, preview?, presentScroll?, …}`
|
||||
//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeSearch?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, focusRegion?, preview?, presentScroll?, …}`
|
||||
//! - `ui.screenshot {path?}` — PNG of the window (needs a visible window), written to `path` or
|
||||
//! returned as base64 `data`
|
||||
//! - `ui.render {path?, scale?, ids?}` — PNG of the document (no UI chrome; works without a
|
||||
@@ -429,6 +429,9 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
|
||||
if let Some(section) = p.get("homeSection").and_then(Value::as_str).and_then(crate::home::Section::parse) {
|
||||
app.ui.home_view.section = section;
|
||||
}
|
||||
if let Some(query) = p.get("homeSearch").and_then(Value::as_str) {
|
||||
app.ui.home_view.file_query = query.chars().take(256).collect();
|
||||
}
|
||||
if let Some(view) = p.get("homeRecentsView").and_then(Value::as_str).and_then(crate::home_recents::View::parse) {
|
||||
view.store(&mut app.session.prefs);
|
||||
}
|
||||
|
||||
@@ -473,3 +473,79 @@ fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() {
|
||||
let (_, _, w, h) = geom(&u, a);
|
||||
assert_eq!((w, h, undo_steps(&u)), (60.0, 45.0, steps + 1), "40×30 scaled to a width of 60");
|
||||
}
|
||||
|
||||
/// The texts painted in the last frame: what they say, whether they end in an ellipsis, and where
|
||||
/// they show (within their clip).
|
||||
fn painted_texts(shapes: &[egui::epaint::ClippedShape]) -> Vec<(String, bool, egui::Rect)> {
|
||||
fn walk(shape: &egui::Shape, clip: egui::Rect, out: &mut Vec<(String, bool, egui::Rect)>) {
|
||||
match shape {
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, clip, out)),
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.galley.elided, t.visual_bounding_rect().intersect(clip))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
shapes.iter().for_each(|c| walk(&c.shape, c.clip_rect, &mut out));
|
||||
out
|
||||
}
|
||||
|
||||
/// The glass effect's light intensity field is labelled (#256): drawn in the Design panel with the
|
||||
/// effect's settings open.
|
||||
#[test]
|
||||
fn the_glass_light_intensity_field_has_a_label() {
|
||||
let mut s = Session::new();
|
||||
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("effect.add", &json!({"type": "glass"})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.ctx.data_mut(|d| d.insert_temp(Id::new(("fx_open", 0usize)), true));
|
||||
u.frame(vec![]);
|
||||
let shapes = u.frame_output(vec![]).shapes;
|
||||
let lead = u.ctx.read_response(Id::new(("fx_gi", 0usize)).with("lead")).expect("the intensity field is shown").rect;
|
||||
let labels: Vec<(String, bool)> =
|
||||
painted_texts(&shapes).into_iter().filter(|(_, _, at)| lead.contains(at.center())).map(|(text, cut, _)| (text, cut)).collect();
|
||||
assert_eq!(labels, [("Intensity".to_string(), false)], "labelled, and not cut short");
|
||||
let field = u.ctx.read_response(Id::new(("fx_gi", 0usize)).with("field")).expect("its value").rect;
|
||||
assert_eq!(field.max.x - lead.min.x, GLASS_FIELD_W, "the width the next test measures against");
|
||||
}
|
||||
|
||||
/// A glass settings field's width in the Design panel at its default width.
|
||||
const GLASS_FIELD_W: f32 = 98.5;
|
||||
|
||||
/// The light intensity label this fix adds fits whole beside its value in every language (#256).
|
||||
/// Other glass labels may end in "…" in some languages (es "Profundidad"); a longer
|
||||
/// "Intensity" translation fails here.
|
||||
#[test]
|
||||
fn the_intensity_label_fits_whole_in_every_language() {
|
||||
use crate::i18n::{self, Text};
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
let width = |s: &str| ui.painter().layout_no_wrap(s.to_string(), crate::theme::regular(11.0), egui::Color32::WHITE).size().x;
|
||||
for lang in i18n::languages() {
|
||||
let label = lang.ui.iter().find(|(k, _)| *k == "Intensity").map_or("Intensity", |(_, t)| match t {
|
||||
Text::Plain(s) | Text::Plural { other: s, .. } => s,
|
||||
});
|
||||
let lead = crate::widgets::text_lead_width(ui, label, GLASS_FIELD_W);
|
||||
assert!(lead >= 12.0 + width(label), "{}: {label:?} would end in \"…\"", lang.code);
|
||||
}
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_too_long_for_its_field_ends_in_an_ellipsis_before_the_value() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut field = egui::Rect::NOTHING;
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
let lead = crate::widgets::Lead::Text("A label much wider than its field");
|
||||
let _ = crate::widgets::number_field(ui, Id::new("long"), lead, Some(80.0), "%", 60.0, 1.0);
|
||||
field = ui.min_rect();
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let texts = painted_texts(&out.shapes);
|
||||
let [(_, label_cut, l), (value, value_cut, v)] = texts.as_slice() else { panic!("the label and the value: {texts:?}") };
|
||||
assert!(*label_cut, "the label ends in an ellipsis");
|
||||
assert_eq!((value.as_str(), *value_cut), ("80%", false), "the value shows whole");
|
||||
assert!(field.contains_rect(*l) && field.contains_rect(*v), "both inside the field {field:?}: {l:?} {v:?}");
|
||||
assert!(l.max.x < v.min.x, "the label stops before the value: {l:?} {v:?}");
|
||||
}
|
||||
+103
-22
@@ -51,6 +51,8 @@ impl Section {
|
||||
#[derive(Default)]
|
||||
pub struct Home {
|
||||
pub section: Section,
|
||||
/// Searches known files and starter templates together from the home sidebar.
|
||||
pub file_query: String,
|
||||
/// The Templates search text and kind filter.
|
||||
pub query: String,
|
||||
pub kind: Option<Kind>,
|
||||
@@ -143,7 +145,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
||||
let left = Rect::from_min_size(rect.min, vec2(SIDEBAR, rect.height()));
|
||||
ui.painter().line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.border));
|
||||
sidebar(app, ui, &t, left);
|
||||
if app.ui.home_view.section == Section::Online {
|
||||
if app.ui.home_view.file_query.trim().is_empty() && app.ui.home_view.section == Section::Online {
|
||||
// Your online files lay themselves out in the area (and scroll their own list).
|
||||
let area = Rect::from_min_max(pos2(left.max.x + 40.0, rect.min.y + 24.0), rect.max - vec2(40.0, 24.0));
|
||||
crate::online::section(app, ui, area, &t);
|
||||
@@ -155,9 +157,14 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
||||
egui::ScrollArea::vertical().id_salt("home").auto_shrink([false, false]).show(ui, |ui| {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin::symmetric(40, 24)).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(GAP, 0.0);
|
||||
match app.ui.home_view.section {
|
||||
Section::Templates => templates(app, ui, &t),
|
||||
_ => recents(app, ui, &t),
|
||||
if app.ui.home_view.file_query.trim().is_empty() {
|
||||
match app.ui.home_view.section {
|
||||
Section::Templates => templates(app, ui, &t),
|
||||
_ => recents(app, ui, &t),
|
||||
}
|
||||
} else {
|
||||
search_results(app, ui, &t);
|
||||
opened_join(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -198,10 +205,20 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) {
|
||||
icons::paint(ui.painter(), Rect::from_min_size(pos2(left.min.x + 16.0, y), vec2(20.0, 20.0)), Icon::Logo, ACCENT);
|
||||
ui.painter().text(pos2(left.min.x + 44.0, y + 10.0), Align2::LEFT_CENTER, "FigCraft", semibold(14.0), t.text);
|
||||
y += 48.0;
|
||||
let search = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, widgets::FIELD_H));
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(search), |ui| {
|
||||
let response =
|
||||
widgets::text_input(ui, Id::new("home_file_search"), &mut app.ui.home_view.file_query, search.width(), tr("Search files and templates"));
|
||||
a11y::text(&response, tr("Search files and templates"), &app.ui.home_view.file_query);
|
||||
if (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.home_view.file_query.clear();
|
||||
}
|
||||
});
|
||||
y += widgets::FIELD_H + 12.0;
|
||||
for (section, icon, label) in Section::ALL {
|
||||
let r = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, 32.0));
|
||||
let resp = ui.interact(r, ui.id().with(("home_section", label)), Sense::click());
|
||||
let current = app.ui.home_view.section == section;
|
||||
let current = app.ui.home_view.file_query.trim().is_empty() && app.ui.home_view.section == section;
|
||||
a11y::tab(&resp, tr(label), current);
|
||||
if current || resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), if current { t.selected_row } else { t.hover });
|
||||
@@ -211,11 +228,65 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) {
|
||||
ui.painter().text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, tr(label), regular(12.0), t.text);
|
||||
if resp.clicked() {
|
||||
app.ui.home_view.section = section;
|
||||
app.ui.home_view.file_query.clear();
|
||||
}
|
||||
y += 34.0;
|
||||
}
|
||||
}
|
||||
|
||||
fn file_matches(name: &str, location: &str, query: &str) -> bool {
|
||||
name.to_lowercase().contains(query) || location.to_lowercase().contains(query)
|
||||
}
|
||||
|
||||
/// One search over known files and starter templates. It never sends a query or owner token to a
|
||||
/// server; online files are opened through their existing owner-token command only on click.
|
||||
fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
heading(ui, t, tr("Search results"));
|
||||
let query = app.ui.home_view.file_query.trim().to_lowercase();
|
||||
let mut count = open_files(app, ui, t, &query);
|
||||
count += crate::home_recents::section(app, ui, t, &query);
|
||||
|
||||
let online: Vec<_> = app.session.prefs.online_files.iter().filter(|f| file_matches(&f.name, &f.server, &query)).cloned().collect();
|
||||
let mut open_online = None;
|
||||
if !online.is_empty() {
|
||||
subheading(ui, t, tr("Online files"));
|
||||
for f in &online {
|
||||
count += 1;
|
||||
let host = figcraft_engine::online::server_host(&f.server);
|
||||
let icon = crate::online::mark(f.shared.is_some());
|
||||
let row = FileRow {
|
||||
mark: Some((icon, if f.shared.is_some() { t.success } else { t.icon_secondary })),
|
||||
name: &f.name,
|
||||
ink: t.text,
|
||||
detail: host,
|
||||
detail_size: 11.0,
|
||||
detail_ink: t.text_secondary,
|
||||
};
|
||||
if listed(ui, t, ui.id().with(("search_online", &f.server, &f.file)), 36.0, &row) {
|
||||
open_online = Some(f.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let templates: Vec<&Template> = TEMPLATES.iter().filter(|template| matches(template, &query, None)).collect();
|
||||
if !templates.is_empty() {
|
||||
subheading(ui, t, tr("Templates"));
|
||||
template_grid(app, ui, t, &templates);
|
||||
}
|
||||
if count == 0 && templates.is_empty() {
|
||||
ui.label(egui::RichText::new(tr("No files or templates match your search")).font(semibold(13.0)).color(t.text));
|
||||
ui.add_space(6.0);
|
||||
widgets::label(ui, tr("Try another search, or browse templates."), t.text_secondary);
|
||||
ui.add_space(16.0);
|
||||
if widgets::button(ui, tr("Show all templates"), false, true).clicked() {
|
||||
app.ui.home_view.file_query.clear();
|
||||
app.ui.home_view.section = Section::Templates;
|
||||
}
|
||||
}
|
||||
if let Some(file) = open_online {
|
||||
crate::online::open(app, &file);
|
||||
}
|
||||
}
|
||||
|
||||
/// A ring around a card or row that has the keyboard.
|
||||
fn focus_ring(ui: &Ui, t: &Tokens, rect: Rect, resp: &Response, radius: u8) {
|
||||
if resp.has_focus() {
|
||||
@@ -290,22 +361,7 @@ fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
ui.add_space(28.0);
|
||||
getting_started(app, ui, t);
|
||||
}
|
||||
if !app.session.docs.is_empty() {
|
||||
subheading(ui, t, tr("Open files"));
|
||||
let mut switch = None;
|
||||
for (i, d) in app.session.docs.iter().enumerate() {
|
||||
let detail = d.path.as_deref().unwrap_or_default();
|
||||
let row =
|
||||
FileRow { mark: Some((Icon::Logo, ACCENT)), name: &d.doc.name, ink: t.text, detail, detail_size: 11.0, detail_ink: t.text_secondary };
|
||||
if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) {
|
||||
switch = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(i) = switch {
|
||||
app.run("doc.switch", json!({"index": i}));
|
||||
app.ui.home = false;
|
||||
}
|
||||
}
|
||||
open_files(app, ui, t, "");
|
||||
if crate::home_recents::listed(&app.session.prefs.recent_files).next().is_none() {
|
||||
// A first run: nothing to reopen yet, so offer something to start from.
|
||||
subheading(ui, t, tr("Start from a template"));
|
||||
@@ -317,7 +373,32 @@ fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
crate::home_recents::section(app, ui, t);
|
||||
crate::home_recents::section(app, ui, t, "");
|
||||
}
|
||||
|
||||
/// The Open files list: the documents whose name or path has `query` (lower case; all for ""), a
|
||||
/// row each; choosing one shows its tab. How many are listed.
|
||||
fn open_files(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize {
|
||||
let hit = |d: &&figcraft_engine::DocState| file_matches(&d.doc.name, d.path.as_deref().unwrap_or_default(), query);
|
||||
let count = app.session.docs.iter().filter(hit).count();
|
||||
if count == 0 {
|
||||
return 0;
|
||||
}
|
||||
subheading(ui, t, tr("Open files"));
|
||||
let mut switch = None;
|
||||
for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| hit(d)) {
|
||||
let detail = d.path.as_deref().unwrap_or_default();
|
||||
let row =
|
||||
FileRow { mark: Some((Icon::Logo, ACCENT)), name: &d.doc.name, ink: t.text, detail, detail_size: 11.0, detail_ink: t.text_secondary };
|
||||
if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) {
|
||||
switch = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(i) = switch {
|
||||
app.run("doc.switch", json!({"index": i}));
|
||||
app.ui.home = false;
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// The file joined from the home screen (a code, or one of your online files) opened in a tab of
|
||||
|
||||
@@ -554,16 +554,27 @@ enum Action {
|
||||
View(View),
|
||||
}
|
||||
|
||||
/// The Recent files heading, its toolbar and the files as cards or a table.
|
||||
pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
let paths: Vec<String> = listed(&app.session.prefs.recent_files).take(SHOWN).cloned().collect();
|
||||
/// The Recent files heading, its toolbar and the files as cards or a table. With a `query` (lower
|
||||
/// case, from the Home search) only the files not open whose path has it, from every recent file;
|
||||
/// nothing at all when none has it. How many files are shown.
|
||||
pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize {
|
||||
let searching = !query.is_empty();
|
||||
// A search reaches every recent file; the files shown without one stay known through it.
|
||||
let paths: Vec<String> = listed(&app.session.prefs.recent_files).take(if searching { usize::MAX } else { SHOWN }).cloned().collect();
|
||||
let time = ui.input(|i| i.time);
|
||||
let now = figcraft_engine::cmd::comment::now_secs();
|
||||
let reduce_motion = app.reduce_motion();
|
||||
let (view, sort) = (View::of(&app.session.prefs), Sort::of(&app.session.prefs));
|
||||
let state = &mut app.ui.home_view.recents;
|
||||
state.refresh(ui.ctx(), &paths, time);
|
||||
let files = sorted(state.shown(&paths), sort);
|
||||
let mut files = sorted(state.shown(&paths), sort);
|
||||
if searching {
|
||||
let docs = &app.session.docs;
|
||||
files.retain(|f| f.path.to_lowercase().contains(query) && !docs.iter().any(|d| d.path.as_deref() == Some(f.path.as_str())));
|
||||
if files.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
let with_created = files.iter().any(|f| f.stat.is_some_and(|s| s.created.is_some()));
|
||||
let mut action = toolbar(ui, t, view, sort, with_created);
|
||||
ui.add_space(16.0);
|
||||
@@ -593,6 +604,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
files.len()
|
||||
}
|
||||
|
||||
/// "Recent files", and at the right the sort menu and the grid and list buttons.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, Shape};
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::online::OnlineFile;
|
||||
use figcraft_engine::templates::TEMPLATES;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -184,6 +185,161 @@ fn typing_in_the_search_field_filters() {
|
||||
assert!(u.app.session.docs.is_empty(), "typing opens nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_search_finds_known_files_and_opens_an_existing_tab() {
|
||||
let mut session = Session::new();
|
||||
session.execute("file.rename", &json!({"name": "North Trail"})).unwrap();
|
||||
session.docs[0].path = Some("C:/designs/North Trail.figcraft".into());
|
||||
session.prefs.recent_files =
|
||||
vec!["C:/designs/North Trail.figcraft".into(), "C:/designs/South Trail.figcraft".into(), "C:/designs/Other.figcraft".into()];
|
||||
session.prefs.online_files.push(OnlineFile {
|
||||
server: "wss://share.example/".into(),
|
||||
file: "trail".into(),
|
||||
name: "Trail Live".into(),
|
||||
owner: "test-only-secret".into(),
|
||||
..OnlineFile::default()
|
||||
});
|
||||
let mut u = Ui::new(session);
|
||||
u.app.ui.home = true;
|
||||
u.app.ui.home_view.file_query = " TRAIL ".into();
|
||||
let s = settle(&mut u);
|
||||
assert!(has_text(&s, "Search results"));
|
||||
assert!(has_text(&s, "North Trail") && has_text(&s, "South Trail") && has_text(&s, "Trail Live"));
|
||||
assert!(!has_text(&s, "Other") && !has_text(&s, "test-only-secret"));
|
||||
assert_eq!(texts(&s).iter().filter(|(name, pos, _)| name == "North Trail" && pos.y > 90.0).count(), 1, "an open file is not repeated in recents");
|
||||
assert_eq!(u.app.inspect()["homeSearch"], " TRAIL ");
|
||||
|
||||
u.app.ui.home_view.file_query = "nothing matches".into();
|
||||
let s = shapes(&mut u);
|
||||
assert!(has_text(&s, "No files or templates match your search"));
|
||||
assert!(!has_text(&s, "Trail Live") && !has_text(&s, "South Trail"));
|
||||
assert!(!texts(&s).iter().any(|(name, pos, _)| name == "North Trail" && pos.y > 90.0));
|
||||
|
||||
u.app.ui.home_view.file_query = "north".into();
|
||||
let s = shapes(&mut u);
|
||||
click(&mut u, text_center(&s, "North Trail"));
|
||||
assert!(!u.app.ui.home, "choosing an open result shows its editor tab");
|
||||
assert_eq!(u.app.session.docs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_search_shows_a_saved_files_preview() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-home-search-preview-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Search preview.figcraft");
|
||||
let image = image::RgbaImage::from_pixel(64, 32, image::Rgba([30, 140, 210, 255]));
|
||||
let mut png = Vec::new();
|
||||
image.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let doc = figcraft_doc::Document::new("Search preview");
|
||||
std::fs::write(&path, figcraft_format::save_bytes(&doc, Some(&png)).unwrap()).unwrap();
|
||||
|
||||
let mut session = Session::empty();
|
||||
session.prefs.recent_files.push(path.to_string_lossy().into_owned());
|
||||
let mut u = Ui::new(session);
|
||||
u.app.ui.home_view.file_query = "Search preview".into();
|
||||
for _ in 0..400 {
|
||||
u.frame(vec![]);
|
||||
if u.app.inspect()["homeRecents"]["files"][0]["picture"] == "picture" {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
let s = shapes(&mut u);
|
||||
assert!(has_text(&s, "Search preview"));
|
||||
assert!(s.iter().any(|s| matches!(s, Shape::Mesh(m) if m.texture_id != egui::TextureId::default())), "the saved preview is drawn");
|
||||
let _ = std::fs::remove_file(path);
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_search_field_filters_as_typed_and_a_section_click_clears_it() {
|
||||
let mut u = first_run();
|
||||
u.app.session.prefs.recent_files = vec!["C:/designs/Glacier.figcraft".into(), "C:/designs/Forest.figcraft".into()];
|
||||
let s = shapes(&mut u);
|
||||
click(&mut u, text_center(&s, "Search files and templates"));
|
||||
u.frame(vec![Event::Text("glacier".into())]);
|
||||
let s = settle(&mut u);
|
||||
assert_eq!(u.app.ui.home_view.file_query, "glacier");
|
||||
assert!(has_text(&s, "Glacier") && !has_text(&s, "Forest"));
|
||||
click(&mut u, text_center(&s, "Recents"));
|
||||
assert!(u.app.ui.home_view.file_query.is_empty());
|
||||
let s = shapes(&mut u);
|
||||
assert!(has_text(&s, "Forest"));
|
||||
// Escape in the field clears the search too.
|
||||
click(&mut u, text_center(&s, "Search files and templates"));
|
||||
u.frame(vec![Event::Text("forest".into())]);
|
||||
assert_eq!(u.app.ui.home_view.file_query, "forest");
|
||||
u.frame(vec![Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
|
||||
assert!(u.app.ui.home_view.file_query.is_empty(), "Escape clears the search");
|
||||
}
|
||||
|
||||
/// A search keeps what the recent files it doesn't match know: back from it, their pictures and
|
||||
/// the selection are still there (nothing is read again).
|
||||
#[test]
|
||||
fn a_search_keeps_the_other_recent_files_pictures_and_the_selection() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-home-search-cache-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(8, 8, image::Rgba([30, 140, 210, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let paths: Vec<String> = ["Alpine", "Basin"]
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let path = dir.join(format!("{name}.figcraft"));
|
||||
std::fs::write(&path, figcraft_format::save_bytes(&figcraft_doc::Document::new(name), Some(&png)).unwrap()).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
})
|
||||
.collect();
|
||||
let mut session = Session::empty();
|
||||
session.prefs.recent_files = paths.clone();
|
||||
let mut u = Ui::new(session);
|
||||
let pictures = |u: &mut Ui| -> Vec<serde_json::Value> {
|
||||
u.app.inspect()["homeRecents"]["files"].as_array().unwrap().iter().map(|f| f["picture"].clone()).collect()
|
||||
};
|
||||
for _ in 0..400 {
|
||||
u.frame(vec![]);
|
||||
if pictures(&mut u).iter().all(|p| p == "picture") {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert!(pictures(&mut u).iter().all(|p| p == "picture"), "both pictures are in");
|
||||
u.app.ui.home_view.recents.selected = Some(paths[1].clone());
|
||||
u.app.ui.home_view.file_query = "alpine".into();
|
||||
let s = shapes(&mut u);
|
||||
assert!(has_text(&s, "Alpine") && !has_text(&s, "Basin"));
|
||||
u.app.ui.home_view.file_query.clear();
|
||||
u.frame(vec![]);
|
||||
assert!(pictures(&mut u).iter().all(|p| p == "picture"), "no picture is read again");
|
||||
assert_eq!(u.app.ui.home_view.recents.selected.as_deref(), Some(paths[1].as_str()), "the selection stays");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_search_includes_templates_and_opens_a_matching_card() {
|
||||
let mut u = first_run();
|
||||
u.app.ui.home_view.file_query = "MOBILE".into();
|
||||
let s = settle(&mut u);
|
||||
assert!(has_text(&s, "Templates") && has_text(&s, "Mobile app"));
|
||||
assert!(!has_text(&s, "Dashboard"));
|
||||
click(&mut u, text_center(&s, "Mobile app"));
|
||||
assert_eq!(u.app.session.doc().unwrap().name, "Mobile app");
|
||||
assert!(!u.app.ui.home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_home_search_can_browse_all_templates() {
|
||||
let mut u = first_run();
|
||||
u.app.ui.home_view.file_query = "no such design".into();
|
||||
let s = shapes(&mut u);
|
||||
assert!(has_text(&s, "No files or templates match your search"));
|
||||
click(&mut u, text_center(&s, "Show all templates"));
|
||||
assert!(u.app.ui.home_view.file_query.is_empty());
|
||||
assert_eq!(u.app.ui.home_view.section, Section::Templates);
|
||||
assert!(has_text(&settle(&mut u), "Dashboard"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnails_are_cached_per_theme() {
|
||||
let mut u = first_run();
|
||||
|
||||
@@ -343,11 +343,12 @@ fn system_languages_pick_the_first_one_with_a_catalog() {
|
||||
// Portugal's Portuguese and a bare "pt" read the Brazilian catalog.
|
||||
assert_eq!(code("system", Some("pt-PT")), "pt-BR");
|
||||
assert_eq!(code("pt", None), "pt-BR");
|
||||
// An OS list: the first language there is a catalog for (English counts).
|
||||
assert_eq!(code("system", Some("nl-NL,de-DE,en-US")), "de");
|
||||
assert_eq!(code("system", Some("nl-NL, en-US,fr-FR")), "en");
|
||||
assert_eq!(code("system", Some("nl,da")), "en");
|
||||
assert_eq!(code("system", Some("nl,sv")), "sv");
|
||||
// An OS list: the first language there is a catalog for (English counts). Hindi has no
|
||||
// catalog (Dutch gained one; `hi` keeps exercising the no-catalog path).
|
||||
assert_eq!(code("system", Some("hi-IN,de-DE,en-US")), "de");
|
||||
assert_eq!(code("system", Some("hi-IN, en-US,fr-FR")), "en");
|
||||
assert_eq!(code("system", Some("hi,da")), "en");
|
||||
assert_eq!(code("system", Some("hi,sv")), "sv");
|
||||
assert_eq!(code("fr", Some("de-DE")), "fr", "a chosen language beats the system's");
|
||||
}
|
||||
|
||||
@@ -472,7 +473,7 @@ fn russian_language_tags_and_integer_plurals() {
|
||||
assert_eq!(resolve(tag, None), ru, "{tag}");
|
||||
assert_eq!(resolve("system", Some(tag)), ru, "{tag}");
|
||||
}
|
||||
assert_eq!(resolve("system", Some("nl,ru-RU,en-US")), ru);
|
||||
assert_eq!(resolve("system", Some("hi,ru-RU,en-US")), ru);
|
||||
for n in [1, 21, 31, 101, 1_001] {
|
||||
assert_eq!(plural("ru", n), Plural::One, "{n}");
|
||||
assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слой"));
|
||||
|
||||
@@ -1678,6 +1678,7 @@ impl FigApp {
|
||||
"lastError": self.ui.last_error,
|
||||
"importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect),
|
||||
});
|
||||
state["homeSearch"] = json!(self.ui.home_view.file_query);
|
||||
state["pendingOpens"] = json!(self.ui.pending_opens.len());
|
||||
state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect));
|
||||
state
|
||||
|
||||
@@ -114,6 +114,9 @@ pub fn record_recent(app: &mut FigApp, key: &str) {
|
||||
|
||||
pub fn invoke(app: &mut FigApp, id: &str) {
|
||||
match id {
|
||||
// Edit ▸ Delete (and the Delete binding) with an interaction open in the Prototype tab
|
||||
// removes that interaction, not the layer (#767).
|
||||
"edit.delete" if app.ui_context.clone().is_some_and(|ctx| crate::proto_panel::delete_selected_interaction(app, &ctx)) => {}
|
||||
"file.open" => app.open_dialog(),
|
||||
"plugin.import" => crate::plugins_ui::import_dialog(app),
|
||||
"file.import" => app.import_dialog(false),
|
||||
|
||||
@@ -72,7 +72,7 @@ fn manage(app: &mut FigApp, ctx: &egui::Context, f: (&str, &str), action: &str,
|
||||
}
|
||||
|
||||
/// Open recorded file `f` as its owner; its tab shows once the file arrives.
|
||||
fn open(app: &mut FigApp, f: &OnlineFile) {
|
||||
pub(crate) fn open(app: &mut FigApp, f: &OnlineFile) {
|
||||
let Some(r) = app.run("collab.openFile", json!({"server": f.server, "file": f.file})) else { return };
|
||||
if r["alreadyOpen"] == true && !r["document"].is_null() {
|
||||
app.ui.home = false;
|
||||
|
||||
@@ -1788,8 +1788,7 @@ fn navigation_bar(p: &mut Presenter, ctx: &egui::Context, now: f64) -> Option<St
|
||||
let scale_menu = ui.menu_button(format!("{} ", tr(p.view.scale.label(settings.device().is_some()))), |ui| {
|
||||
navigation_menu(ui, |ui| {
|
||||
for scale in PresentScale::options(settings.device().is_some()) {
|
||||
let label = egui::RichText::new(tr(scale.label(settings.device().is_some()))).color(t.menu_text);
|
||||
if ui.selectable_label(p.view.scale == *scale, label).clicked() {
|
||||
if ui.selectable_label(p.view.scale == *scale, tr(scale.label(settings.device().is_some()))).clicked() {
|
||||
p.set_scale(*scale);
|
||||
ui.close();
|
||||
}
|
||||
@@ -1812,15 +1811,8 @@ fn paint_menu_chevron(painter: &egui::Painter, button: Rect, color: Color32) {
|
||||
}
|
||||
|
||||
fn navigation_menu(ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.visuals_mut().override_text_color = Some(t.menu_text);
|
||||
egui::Frame::NONE.fill(t.menu).stroke(Stroke::new(1.0, t.border)).corner_radius(CornerRadius::same(7)).inner_margin(egui::Margin::same(6)).show(
|
||||
ui,
|
||||
|ui| {
|
||||
ui.set_min_width(212.0);
|
||||
add(ui);
|
||||
},
|
||||
);
|
||||
ui.set_min_width(212.0);
|
||||
add(ui);
|
||||
}
|
||||
|
||||
/// How often the player polls the controllers while its prototype has gamepad triggers (seconds).
|
||||
|
||||
@@ -272,6 +272,9 @@ pub fn input(app: &mut FigApp, ui: &egui::Ui, resp: &egui::Response) -> bool {
|
||||
crate::proto_panel::open_editor(&ctx, Some((n.source, n.index)));
|
||||
return true;
|
||||
}
|
||||
if pressed && crate::proto_panel::editor(&ctx).is_some() {
|
||||
crate::proto_panel::open_editor(&ctx, None);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
|
||||
@@ -226,3 +226,81 @@ fn the_presenter_inspects_the_layer_under_the_pointer() {
|
||||
assert_eq!(presenter(&u)["inspected"], json!(a));
|
||||
assert_eq!(u.app.inspect()["presentOptions"]["inspect"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_prototype_interaction_removes_the_interaction_not_the_component() {
|
||||
let (s, _a, b, btn) = setup();
|
||||
let mut u = Ui::new(s);
|
||||
u.app.ui.right_tab = crate::RightTab::Prototype;
|
||||
u.run("select.set", json!({"ids": [btn]}));
|
||||
crate::proto_panel::open_editor(&u.ctx, Some((btn, 0)));
|
||||
u.frames(1, Vec::new);
|
||||
|
||||
assert!(u.app.session.doc().unwrap().node(btn).is_some());
|
||||
assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 1);
|
||||
assert_eq!(crate::proto_panel::editor(&u.ctx), Some((btn, 0)));
|
||||
|
||||
// Pressing Delete when an interaction is selected should delete the interaction, NOT the component.
|
||||
u.key(pos2(100.0, 100.0), egui::Key::Delete);
|
||||
|
||||
assert!(u.app.session.doc().unwrap().node(btn).is_some(), "Bug #767 regression: component was deleted when deleting prototype interaction!");
|
||||
assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 0, "the interaction should be removed");
|
||||
assert_eq!(crate::proto_panel::editor(&u.ctx), None);
|
||||
|
||||
// Re-add interaction and test Backspace as well.
|
||||
u.run("proto.interaction.add", json!({"id": btn, "dest": b}));
|
||||
assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 1);
|
||||
crate::proto_panel::open_editor(&u.ctx, Some((btn, 0)));
|
||||
u.frames(1, Vec::new);
|
||||
u.key(pos2(100.0, 100.0), egui::Key::Backspace);
|
||||
assert!(u.app.session.doc().unwrap().node(btn).is_some());
|
||||
assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 0);
|
||||
assert_eq!(crate::proto_panel::editor(&u.ctx), None);
|
||||
|
||||
// When NO interaction is active, Delete deletes the component as normal.
|
||||
u.key(pos2(100.0, 100.0), egui::Key::Delete);
|
||||
assert!(u.app.session.doc().unwrap().node(btn).is_none(), "when no interaction is selected, Delete removes the layer");
|
||||
}
|
||||
|
||||
/// Edit ▸ Delete from the menu bar does the same as the Delete key (#767).
|
||||
#[test]
|
||||
fn edit_delete_from_the_menu_removes_the_open_interaction_not_the_layer() {
|
||||
let (s, _a, _b, btn) = setup();
|
||||
let mut u = Ui::new(s);
|
||||
u.app.ui.right_tab = crate::RightTab::Prototype;
|
||||
u.run("select.set", json!({"ids": [btn]}));
|
||||
crate::proto_panel::open_editor(&u.ctx, Some((btn, 0)));
|
||||
u.frames(1, Vec::new);
|
||||
crate::menus::invoke(&mut u.app, "edit.delete");
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
assert!(doc.node(btn).is_some_and(|n| n.interactions.is_empty()), "the interaction goes, the layer stays");
|
||||
assert_eq!(crate::proto_panel::editor(&u.ctx), None);
|
||||
crate::menus::invoke(&mut u.app, "edit.delete");
|
||||
assert!(u.app.session.doc().unwrap().node(btn).is_none(), "with no interaction open, Edit ▸ Delete deletes the layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_closes_prototype_editor_without_deselecting_layer() {
|
||||
let (s, a, _b, btn) = setup();
|
||||
let mut u = Ui::new(s);
|
||||
u.app.ui.right_tab = crate::RightTab::Prototype;
|
||||
u.run("select.set", json!({"ids": [btn]}));
|
||||
crate::proto_panel::open_editor(&u.ctx, Some((btn, 0)));
|
||||
u.frames(1, Vec::new);
|
||||
|
||||
assert_eq!(crate::proto_panel::editor(&u.ctx), Some((btn, 0)));
|
||||
assert_eq!(u.app.session.selection(), vec![btn]);
|
||||
|
||||
// First Escape closes the interaction editor; layer remains selected.
|
||||
u.key(pos2(100.0, 100.0), egui::Key::Escape);
|
||||
assert_eq!(crate::proto_panel::editor(&u.ctx), None);
|
||||
assert_eq!(u.app.session.selection(), vec![btn], "layer remains selected");
|
||||
|
||||
// Second Escape steps out to parent frame.
|
||||
u.key(pos2(100.0, 100.0), egui::Key::Escape);
|
||||
assert_eq!(u.app.session.selection(), vec![a], "steps out to parent frame");
|
||||
|
||||
// Third Escape deselects all.
|
||||
u.key(pos2(100.0, 100.0), egui::Key::Escape);
|
||||
assert!(u.app.session.selection().is_empty(), "selection cleared on third escape");
|
||||
}
|
||||
@@ -40,6 +40,37 @@ pub fn editor(ctx: &egui::Context) -> Option<(NodeId, usize)> {
|
||||
ctx.data(|d| d.get_temp(editor_id()))
|
||||
}
|
||||
|
||||
/// Delete the selected interaction (when one is active in the Prototype tab).
|
||||
/// Returns true if an interaction was deleted.
|
||||
pub fn delete_selected_interaction(app: &mut FigApp, ctx: &egui::Context) -> bool {
|
||||
if app.ui.right_tab != crate::RightTab::Prototype {
|
||||
return false;
|
||||
}
|
||||
let Some((node, index)) = editor(ctx) else { return false };
|
||||
let Ok(ds) = app.session.ds() else { return false };
|
||||
if ds.doc.node(node).and_then(|n| n.interactions.get(index)).is_none() {
|
||||
open_editor(ctx, None);
|
||||
return false;
|
||||
}
|
||||
open_editor(ctx, None);
|
||||
let ids = targets_for(app, node);
|
||||
app.run("proto.interaction.remove", json!({"ids": ids, "index": index}));
|
||||
true
|
||||
}
|
||||
|
||||
/// Close the interaction editor if open. Returns true if it was open.
|
||||
pub fn close_editor(ctx: &egui::Context) -> bool {
|
||||
if capturing(ctx) || crate::variable_ui::picker_open(ctx) {
|
||||
return false;
|
||||
}
|
||||
if editor(ctx).is_some() {
|
||||
open_editor(ctx, None);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
const TRIGGERS: [&str; 10] = [
|
||||
tr_noop("On click"),
|
||||
tr_noop("On drag"),
|
||||
@@ -607,8 +638,6 @@ fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, nod
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: NodeId, node: NodeId, k: usize, it: &Interaction, at: Pos2) {
|
||||
let t = Tokens::get(ctx);
|
||||
// Escape in a field (it cancels the edit) doesn't close the editor.
|
||||
let typing = ctx.egui_wants_keyboard_input();
|
||||
let width = 280.0;
|
||||
let screen = ctx.content_rect();
|
||||
let pos = pos2(at.x - width, at.y.min(screen.max.y - 420.0).max(screen.min.y + 48.0));
|
||||
@@ -652,8 +681,7 @@ fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: N
|
||||
});
|
||||
});
|
||||
});
|
||||
let escape = ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !typing && !capturing(ctx) && !crate::variable_ui::picker_open(ctx);
|
||||
if close || escape {
|
||||
if close {
|
||||
open_editor(ctx, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,9 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.painter().galley(pos2(r.min.x + 8.0, r.center().y - galley.size().y / 2.0), galley, if active { t.text } else { t.text_secondary });
|
||||
if resp.clicked() {
|
||||
if app.ui.right_tab != tab {
|
||||
crate::proto_panel::open_editor(ui.ctx(), None);
|
||||
}
|
||||
app.ui.right_tab = tab;
|
||||
}
|
||||
x = r.max.x + 2.0;
|
||||
@@ -1887,7 +1890,7 @@ fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &
|
||||
row(ui, 32.0, |ui| {
|
||||
let ed = widgets::number_field(ui, Id::new(("fx_gl", i)), Lead::Text(tr("Light|glass")), Some(g.light_angle.round()), "°", half, 1.0);
|
||||
edit(app, ed, &|v| with(&|gl| gl.light_angle = v.clamp(-180.0, 180.0)));
|
||||
let ed = widgets::number_field(ui, Id::new(("fx_gi", i)), Lead::None, pct(g.light_intensity), "%", half, 1.0);
|
||||
let ed = widgets::number_field(ui, Id::new(("fx_gi", i)), Lead::Text(tr("Intensity")), pct(g.light_intensity), "%", half, 1.0);
|
||||
edit(app, ed, &|v| with(&|gl| gl.light_intensity = unit(v)));
|
||||
});
|
||||
row(ui, 32.0, |ui| {
|
||||
|
||||
@@ -212,6 +212,11 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> Swallowed {
|
||||
pub fn run_binding(app: &mut FigApp, id: &str) {
|
||||
match id {
|
||||
"select.none" => {
|
||||
if let Some(ctx) = &app.ui_context
|
||||
&& crate::proto_panel::close_editor(ctx)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Err(e) = app.session.escape() {
|
||||
app.toast(e.to_string());
|
||||
}
|
||||
@@ -230,6 +235,9 @@ pub fn run_binding(app: &mut FigApp, id: &str) {
|
||||
}
|
||||
|
||||
fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, repeat: bool, now: f64, ctx: &Context) -> bool {
|
||||
if key == Key::Escape && crate::proto_panel::close_editor(ctx) {
|
||||
return true;
|
||||
}
|
||||
if key == Key::Escape && crate::selection_spacing::cancel(app, ctx) {
|
||||
return true;
|
||||
}
|
||||
@@ -240,6 +248,10 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, re
|
||||
if matches!(key, Key::Backspace | Key::Delete) && m.is_none() && app.ui.picker.is_some() && crate::handles::remove_selected_stop(app, ctx) {
|
||||
return true;
|
||||
}
|
||||
// An interaction selected in the Prototype tab: Delete removes that interaction, not the component.
|
||||
if matches!(key, Key::Backspace | Key::Delete) && m.is_none() && crate::proto_panel::delete_selected_interaction(app, ctx) {
|
||||
return true;
|
||||
}
|
||||
// Vector edit mode and the shape builder have their own keys (tools, nudging points, delete,
|
||||
// join, Enter to finish); the rest fall through.
|
||||
match app.session.tool_key(key.name(), m.shift, m.alt, m.command) {
|
||||
|
||||
@@ -202,7 +202,7 @@ fn number_field_impl(
|
||||
let mut out: Option<Edit> = None;
|
||||
let lead_w = match lead {
|
||||
Lead::None | Lead::Named(_) => 6.0,
|
||||
Lead::Text(s) => (12.0 + s.chars().count() as f32 * 7.0).max(24.0),
|
||||
Lead::Text(s) => text_lead_width(ui, s, width),
|
||||
Lead::Icon(..) => 24.0,
|
||||
};
|
||||
let lead_rect = Rect::from_min_size(rect.min, vec2(lead_w, FIELD_H));
|
||||
@@ -223,7 +223,12 @@ fn number_field_impl(
|
||||
}
|
||||
match lead {
|
||||
Lead::Text(s) => {
|
||||
ui.painter().text(pos2(lead_rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), t.text_secondary);
|
||||
let galley = crate::bidi::name_galley(ui.painter(), s, regular(11.0), t.text_secondary, lead_w - 12.0);
|
||||
// A label cut short shows whole on hover.
|
||||
if galley.elided {
|
||||
lead_resp.clone().on_hover_text(s);
|
||||
}
|
||||
ui.painter().galley(pos2(lead_rect.min.x + 8.0, rect.center().y - galley.size().y / 2.0), galley, t.text_secondary);
|
||||
}
|
||||
Lead::Icon(i, _) => {
|
||||
icons::paint(ui.painter(), Rect::from_center_size(lead_rect.center() + vec2(1.0, 0.0), vec2(14.0, 14.0)), i, t.icon_secondary)
|
||||
@@ -347,7 +352,8 @@ fn number_field_impl(
|
||||
None => empty.to_string(),
|
||||
};
|
||||
let col = if value.is_some() { t.text } else { t.text_secondary };
|
||||
ui.painter().text(pos2(edit_rect.min.x, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), col);
|
||||
// A value too long for its room ends in "…", so a cut number never reads as another.
|
||||
crate::bidi::paint_name(ui.painter(), pos2(edit_rect.min.x, rect.center().y), &s, regular(11.0), col, edit_rect.width());
|
||||
}
|
||||
}
|
||||
// The variable button shows while the pointer is over a bindable field.
|
||||
@@ -369,6 +375,13 @@ fn number_field_impl(
|
||||
(out, bind)
|
||||
}
|
||||
|
||||
/// The width a text label takes at the start of a field `field_w` wide: the label as drawn plus
|
||||
/// its padding, leaving the value room for a few characters (a longer label ends in an ellipsis).
|
||||
pub(crate) fn text_lead_width(ui: &Ui, label: &str, field_w: f32) -> f32 {
|
||||
let text = ui.painter().layout_no_wrap(label.to_string(), regular(11.0), Color32::PLACEHOLDER).size().x;
|
||||
(12.0 + text).min(field_w - 32.0).max(24.0)
|
||||
}
|
||||
|
||||
/// A field bound to a variable: the lead, then the variable's name in a chip; the chip opens the
|
||||
/// picker and the detach button unbinds.
|
||||
/// A field showing the variable `name` bound to it in `rect`: click to change, detach to unbind.
|
||||
@@ -383,8 +396,9 @@ pub fn bound_field(ui: &mut Ui, id: Id, lead: Lead, rect: Rect, name: &str) -> O
|
||||
let lead_w = match lead {
|
||||
Lead::None | Lead::Named(_) => 4.0,
|
||||
Lead::Text(s) => {
|
||||
ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), t.text_secondary);
|
||||
(12.0 + s.chars().count() as f32 * 7.0).max(24.0)
|
||||
let w = text_lead_width(ui, s, rect.width());
|
||||
crate::bidi::paint_name(ui.painter(), pos2(rect.min.x + 8.0, rect.center().y), s, regular(11.0), t.text_secondary, w - 12.0);
|
||||
w
|
||||
}
|
||||
Lead::Icon(i, _) => {
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.min.x + 13.0, rect.center().y), vec2(14.0, 14.0)), i, t.icon_secondary);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (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-10-18 · **Change:** minor (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
|
||||
@@ -69,13 +69,14 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
| Explicit modes per frame, library collections' modes | done | |
|
||||
| Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed |
|
||||
| Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) |
|
||||
| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name); CSS custom properties export; in the browser the export arrives as a download (import there still needs a file picker) |
|
||||
| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name; `variableCollections` and `variables` read under `meta` or at the top level, a warning when no variables are found or fewer than the collections list); CSS custom properties export; in the browser the export arrives as a download (import there still needs a file picker) |
|
||||
| Expressions in prototype set-variable actions | done | see [prototyping-parity](prototyping-parity.md) |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-18 | minor | Variables import (#790): a `variables` map outside `meta` (or `meta.variables` next to top-level collections) is read, and an import that finds no variables, or fewer than the collections' `variableIds` list, says so in `warnings` instead of succeeding silently |
|
||||
| 2026-10-17 | minor | Variant properties reorder by dragging them in the Properties section, one undo step (#679; was "no drag in the panel yet"); a library file saved again right after it was read is read again, so a quick republish isn't missed (#652); the Hidden from publishing row no longer says the Hide when publishing item is missing (it landed 2026-10-12). Numbers unchanged |
|
||||
| 2026-10-17 | minor | Size and resizing overrides of layers inside instances (#731): a layer in an instance can hug, fill or keep a size of its own (group `resizing`: hug/fill/fixed with min and max; `size`), from the Layout panel, the canvas handles and `node.set`; they were refused, and imported ones were dropped at the first relayout. Kept through main edits of other properties, rebuilds, undo, save, `.fig` and REST import (sizes a `.fig` instance gives its layers and the REST `overrides` fields), `.fig` export and detach; reset per group and with Reset all changes (`instance_resizing_tests`). The row that listed them as not overridable was wrong. Numbers unchanged. |
|
||||
| 2026-10-14 | minor | Variables export in the browser: Export… ▸ Variables JSON / CSS custom properties did nothing in the web build, which has no save dialogs; it now hands the file over as a download (`Services::downloads` / `download`, `variables_export_ui_tests`). Import in the browser still needs a file picker, as do the other exports there. Numbers unchanged. |
|
||||
|
||||
@@ -248,12 +248,13 @@ separate calls. Returns `{queued}`.
|
||||
|
||||
Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`.
|
||||
|
||||
### `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginsTab?, pluginConfirm?, pluginForm?}`
|
||||
### `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeSearch?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginsTab?, pluginConfirm?, pluginForm?}`
|
||||
|
||||
Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`,
|
||||
`home` (bool), `homeSection` (`recents`, `online` or `templates`: which section the home screen shows, as `ui.inspect` reports it; opening
|
||||
`online` asks each server once what became of your online files), `homeRecentsView` (`grid|list`), `homeRecentsSort` (`modified|name|created`) and `homeRecentsOrder` (`desc|asc`): how the home screen shows its recent files (kept in the preferences; `ui.inspect` reports them under `homeRecents` with each file and whether its picture is in), `palette` (bool: the Actions palette), `mainMenu` (bool, or a top-level menu such
|
||||
as `"Plugins"` to open the main menu with it open), `pluginsWindow` (bool: Plugins ▸ Manage
|
||||
as `"Plugins"` to open the main menu with it open), `homeSearch` (text, at most 256 characters: filters locally known open, recent and online files plus starter templates in Home; an empty string returns to the selected section),
|
||||
`pluginsWindow` (bool: Plugins ▸ Manage
|
||||
plugins…), `pluginsTab` (`installed|browse`: that window's tab), `pluginConfirm` (a plugin id of the
|
||||
directory listed last: its install confirmation shows, as its Install button would; `null` closes
|
||||
it), `pluginForm` (`{id, command?}`: start that plugin command like its menu item, opening
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
|
||||
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
|
||||
@@ -19,7 +19,7 @@ and prototype interactions are still unchecked there.
|
||||
|---|---|---|---|---|---|
|
||||
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 |
|
||||
| `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — |
|
||||
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
|
||||
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
|
||||
| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — |
|
||||
| Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) |
|
||||
|
||||
@@ -165,6 +165,7 @@ each.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged |
|
||||
| 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged |
|
||||
| 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged |
|
||||
| 2026-10-17 | minor | Hug on import (#732): auto layout frames a `.fig` records no primary-axis sizing for hug on that axis (auto layout sized that axis by its content before it could be fixed) instead of turning fixed, instances that record no auto layout take their main's (they came in without auto layout and fixed, so longer text overflowed them), REST frames whose sizing modes are left out hug (the documented default), and text hugs by its resize mode (auto width both axes, auto height its height; it came in fixed). Numbers unchanged |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hardware and platform parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (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; 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) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (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; 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) · **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
|
||||
@@ -39,7 +39,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
|
||||
|
||||
| Platform | Figma | FigCraft | Notes |
|
||||
|---|---|---|---|
|
||||
| macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens |
|
||||
| macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens. Quitting ends the process once the preferences, recovery copies and live sessions are taken care of: the window's teardown crashed on Macs with a Touch Bar (#781); Quit from the menu bar, which asks nothing, keeps unsaved files' recovery copies for the next start |
|
||||
| Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR |
|
||||
| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) |
|
||||
| FreeBSD | none | tarball | |
|
||||
@@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-18 | minor | macOS quit crash (#781): on Macs with a Touch Bar the app could crash as it quit (an exception in AppKit's Touch Bar support while the window's content view, which the accessibility adapter subclasses, was torn down). Quitting now keeps everything (preferences, this run's recovery copies, live sessions ended) and then ends the process before that teardown. Quit from the menu bar ends the app without the unsaved-changes question, so unsaved files' recovery copies now stay and reopen at the next start instead of being removed. Numbers unchanged |
|
||||
| 2026-10-17 | minor | Linux row: clipboard reads on Linux and the BSDs time out after 2 s on a helper thread, images copied in other apps paste under Wayland (arboard's data-control feature, #693), a minimised window can't be detected under Wayland, so frame-only control requests may still wait there (#720), and Wayland compositors without server-side decorations give the window Adwaita controls (#592, checked by its author on GNOME, not by the maintainers; KDE not run); compiled in CI, not run on a Linux desktop. HiDPI row: native-density canvas images placed on whole device pixels (#359, geometry tests; not judged by eye). Numbers unchanged |
|
||||
| 2026-10-14 | minor | Copy and paste in the browser: the web build never wrote to the system clipboard, so Cmd+V after Cmd+C pasted whatever text the clipboard held before (as a text layer) instead of the copied layers. Copies (layers, Copy as SVG/CSS/text/PNG, links) now go out through egui's output, which eframe writes during the copy event (`web_clipboard_tests`). Numbers unchanged. |
|
||||
| 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 through the desktop app and MCP; fixed: unsaved changes lost when the app was killed or crashed (recovery copies every few seconds, reopened at the next start), Ctrl+arrows/Backspace/Delete acting on the whole line in text (now by word; Ctrl+Home/End to the text's ends), scrubbed fields snapping back on release, typing on a field Tab reached, Escape not closing dialogs, skewed or vanishing linear gradients, hints cut off at the panel edge, the window-close question in English in the Spanish interface. Percentages and hours unchanged |
|
||||
|
||||
+5
-2
@@ -883,10 +883,13 @@ path?}` writes this file's collections (default: all but library copies) as a lo
|
||||
response (`format: "json"`, the default) or as CSS custom properties (`format: "css"`: the default
|
||||
mode on `:root`, each other mode on `[data-<collection>="<mode>"]`, aliases as `var(--name)`), and
|
||||
returns `{text}` (or writes `path` and returns `{path}`). `variable.import {variables}` (an object,
|
||||
JSON text or a path) reads one back: a collection joins this file's collection of the same name
|
||||
JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level) reads
|
||||
one back: a collection joins this file's collection of the same name
|
||||
(values matched by mode name, new modes and variables added, a same-named variable of another type
|
||||
skipped with a warning; library copies and extended collections are never targets), others are
|
||||
added; aliases are checked for loops; it returns `{collections, variables, updated, warnings}`. The
|
||||
added; aliases are checked for loops; it returns `{collections, variables, updated, warnings}`,
|
||||
with a warning when the JSON holds no variables (`no variables found: expected meta.variables …`)
|
||||
or fewer than its collections list in `variableIds`. The
|
||||
Variables editor's Import… and Export… buttons do the same through file dialogs.
|
||||
|
||||
Strokes take colour styles and variables like fills: `style.apply {style, target: "stroke"}` links
|
||||
|
||||
+6
-8
@@ -15,14 +15,12 @@ Simplified.
|
||||
`pref.language {language}` does the same from the CLI, MCP and the control channel.
|
||||
|
||||
The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay
|
||||
`es-419`), French (`fr`), German (`de`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), Korean (`ko`),
|
||||
Simplified Chinese (`zh-Hans`), Czech (`cs`) and Indonesian (`id`), machine-drafted and waiting for
|
||||
native speakers' review. Russian (`ru`, Русский) was reviewed and approved by VanTan; its catalog
|
||||
keeps Frame, Dev Mode and Smart animate as familiar tool names and uses « » quotes. Turkish (`tr`)
|
||||
was machine-drafted and reviewed by a native speaker.
|
||||
`es-419`), French (`fr`), German (`de`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), Korean (`ko`)
|
||||
and Simplified Chinese (`zh-Hans`), machine-drafted and waiting for native speakers' review, and
|
||||
Swedish (`sv`), which translates every UI string, command label and menu wording.
|
||||
`es-419`), French (`fr`), German (`de`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`), Japanese
|
||||
(`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`) and Finnish
|
||||
(`fi`), machine-drafted and waiting for native speakers' review. Russian (`ru`, Русский) was
|
||||
reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and Smart animate as familiar
|
||||
tool names and uses « » quotes. Turkish (`tr`) was machine-drafted and reviewed by a native speaker,
|
||||
and Swedish (`sv`) translates every UI string, command label and menu wording.
|
||||
|
||||
## The catalogs
|
||||
|
||||
|
||||
Reference in new issue
Block a user