From 94ce2a3611b5d1c5f79f51a4fd5664d6cb886eee Mon Sep 17 00:00:00 2001 From: Justin John Date: Wed, 14 Oct 2026 11:04:30 +0530 Subject: [PATCH 001/120] M13: make native file dialogs asynchronous --- apps/figcraft/src/services.rs | 29 +++++-- contributors/people.toml | 3 + crates/ui-egui/src/assets_panel.rs | 11 +-- crates/ui-egui/src/async_dialog_tests.rs | 34 +++++++++ crates/ui-egui/src/colorpicker.rs | 30 ++++---- crates/ui-egui/src/dialogs.rs | 20 ++--- crates/ui-egui/src/export_panel.rs | 24 +++--- crates/ui-egui/src/lib.rs | 97 +++++++++++++++++++----- crates/ui-egui/src/plugins_ui.rs | 9 ++- crates/ui-egui/src/variables_panel.rs | 32 ++++---- 10 files changed, 206 insertions(+), 83 deletions(-) create mode 100644 crates/ui-egui/src/async_dialog_tests.rs diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index f4aeb63d7..4cfacad5d 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use figcraft_engine::{Prefs, Session}; -use figcraft_ui_egui::{FigApp, Services}; +use figcraft_ui_egui::{DialogFuture, FigApp, Services}; use serde_json::{Value, json}; pub struct Desktop { @@ -195,14 +195,29 @@ fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> { } impl Services for Desktop { - fn pick_open(&mut self, title: &str, exts: &[&str]) -> Option { - rfd::FileDialog::new().set_title(title).add_filter("Supported files", exts).pick_file() + fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture { + let title = title.to_owned(); + let exts: Vec = exts.iter().map(|ext| (*ext).to_owned()).collect(); + Box::pin(async move { + rfd::AsyncFileDialog::new().set_title(title).add_filter("Supported files", &exts).pick_file().await.map(|file| file.path().to_path_buf()) + }) } - fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> Option { - rfd::FileDialog::new().set_title(title).set_file_name(name).add_filter("File", exts).save_file() + fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture { + let (title, name) = (title.to_owned(), name.to_owned()); + let exts: Vec = exts.iter().map(|ext| (*ext).to_owned()).collect(); + Box::pin(async move { + rfd::AsyncFileDialog::new() + .set_title(title) + .set_file_name(name) + .add_filter("File", &exts) + .save_file() + .await + .map(|file| file.path().to_path_buf()) + }) } - fn pick_folder(&mut self, title: &str) -> Option { - rfd::FileDialog::new().set_title(title).pick_folder() + fn pick_folder(&mut self, title: &str) -> DialogFuture { + let title = title.to_owned(); + Box::pin(async move { rfd::AsyncFileDialog::new().set_title(title).pick_folder().await.map(|file| file.path().to_path_buf()) }) } fn clipboard_set_text(&mut self, s: &str) { if let Some(c) = self.clipboard.as_mut() { diff --git a/contributors/people.toml b/contributors/people.toml index e61cfd31a..fcdf0cff2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -16,3 +16,6 @@ # # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. + +[people.justinjohn0306] +real_name = "Justin John" diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index 374855682..96e153128 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -1088,11 +1088,12 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { trn("Swapped {n} assets; {missed} had no match and still use the old library", moved as usize, &[("missed", &missed)]) }); } - if add - && let Some(p) = app.services.pick_open(tr("Add library"), &["figcraft"]) - && let Some(r) = app.run(ADD, json!({"path": p.to_string_lossy()})) - { - added(app, &r); + if add { + app.pick_open(tr("Add library"), &["figcraft"], |app, p| { + if let Some(r) = app.run(ADD, json!({"path": p.to_string_lossy()})) { + added(app, &r); + } + }); } } diff --git a/crates/ui-egui/src/async_dialog_tests.rs b/crates/ui-egui/src/async_dialog_tests.rs new file mode 100644 index 000000000..ec2c20f03 --- /dev/null +++ b/crates/ui-egui/src/async_dialog_tests.rs @@ -0,0 +1,34 @@ +use std::path::PathBuf; + +use figcraft_engine::Session; + +use crate::{DialogFuture, FigApp, Services}; + +struct OpenFile(PathBuf); + +impl Services for OpenFile { + fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture { + Box::pin(std::future::ready(Some(self.0.clone()))) + } +} + +#[test] +fn open_dialog_finishes_after_the_async_picker_returns() { + let dir = std::env::temp_dir().join(format!("figcraft-async-open-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("opened.figcraft"); + let doc = figcraft_doc::Document::new("Opened"); + std::fs::write(&path, figcraft_format::save_bytes(&doc, None).unwrap()).unwrap(); + + let mut app = FigApp::new(Session::empty(), Box::new(OpenFile(path.clone()))); + app.ui.home = true; + app.open_dialog(); + assert!(app.session.docs.is_empty(), "opening waits for the picker result"); + + app.poll_dialog(&egui::Context::default()); + assert_eq!(app.session.docs.len(), 1); + assert_eq!(app.session.docs[0].path.as_deref(), Some(path.to_string_lossy().as_ref())); + assert!(!app.ui.home); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index 9a1123679..782745074 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -460,23 +460,21 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { ui.add_space(12.0); if let Some(i) = widgets::segmented(ui, &kinds, Some(active), 30.0) { let base = paint.preview_color(); + let mut image_pending = false; paint.kind = match i { 0 => PaintKind::Solid { color: base.with_alpha(1.0) }, 5 => { - // Pick an image file. - match app.services.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]) { - Some(p) => match std::fs::read(&p) { - Ok(bytes) => { - let hash = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)); - match hash { - Some(h) => Paint::image(h, ImageScaleMode::Fill).kind, - None => paint.kind.clone(), - } - } - Err(_) => paint.kind.clone(), - }, - None => paint.kind.clone(), - } + image_pending = true; + let target = target.clone(); + app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| { + let Ok(bytes) = std::fs::read(&p) else { return }; + let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return }; + if let Some(mut paint) = current_paint(app, &target) { + paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind; + write(app, &target, &paint, false); + } + }); + paint.kind.clone() } 6 => match pattern_sources(app, &target).first() { Some((id, _)) => Paint::pattern(id.0).kind, @@ -495,7 +493,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } } }; - write(app, &target, &paint, false); + if !image_pending { + write(app, &target, &paint, false); + } } }); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 221ee6361..24fc12a45 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -464,15 +464,17 @@ fn color_profile_dialog(app: &mut FigApp, ctx: &Context) { /// Save the whole file as `.fig`; the toast says how much was left out. fn save_fig(app: &mut FigApp, name: &str) { - let Some(p) = app.services.pick_save(tr("Export"), &format!("{name}.fig"), &["fig"]) else { return }; - let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); - if let Some(r) = app.run("file.export", json!({"format": "fig", "path": p.to_string_lossy()})) { - let left_out = r["warnings"].as_array().map_or(0, Vec::len); - app.toast(match left_out { - 0 => trf("Exported {name}", &[("name", &file)]), - n => trn("Exported {name}; {n} kinds of content were left out", n, &[("name", &file)]), - }); - } + let filename = format!("{name}.fig"); + app.pick_save(tr("Export"), &filename, &["fig"], |app, p| { + let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); + if let Some(r) = app.run("file.export", json!({"format": "fig", "path": p.to_string_lossy()})) { + let left_out = r["warnings"].as_array().map_or(0, Vec::len); + app.toast(match left_out { + 0 => trf("Exported {name}", &[("name", &file)]), + n => trn("Exported {name}; {n} kinds of content were left out", n, &[("name", &file)]), + }); + } + }); } /// What the Export dialog was asked to do. diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/export_panel.rs index 1c0c6a377..ade5df297 100644 --- a/crates/ui-egui/src/export_panel.rs +++ b/crates/ui-egui/src/export_panel.rs @@ -318,11 +318,13 @@ pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId], assets: bool) { // The suggested name keeps only the file's own name (a layer's folders apply to folders). [one] => save_one(app, &[one.id], &one.setting, one.file.rsplit('/').next().unwrap_or(&one.file)), _ => { - let Some(dir) = app.services.pick_folder(tr("Export")) else { return }; - if let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "dir": dir.to_string_lossy(), "assets": assets})) { - let n = r["files"].as_array().map_or(0, Vec::len); - app.toast(trn("Exported {n} files", n, &[])); - } + let ids = ids.to_vec(); + app.pick_folder(tr("Export"), move |app, dir| { + if let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "dir": dir.to_string_lossy(), "assets": assets})) { + let n = r["files"].as_array().map_or(0, Vec::len); + app.toast(trn("Exported {n} files", n, &[])); + } + }); } } } @@ -330,14 +332,14 @@ pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId], assets: bool) { /// Export `ids` together as one file with `setting`, through a save dialog suggesting `file`. pub(crate) fn save_one(app: &mut FigApp, ids: &[NodeId], setting: &ExportSetting, file: &str) { let ext = setting.format.ext(); - let Some(p) = app.services.pick_save(tr("Export"), file, &[ext]) else { return }; - let path = p.to_string_lossy().to_string(); let mut params = setting_params(setting); params["ids"] = json!(ids); - params["path"] = json!(path); - if app.run("file.export", params).is_some() { - app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); - } + app.pick_save(tr("Export"), file, &[ext], move |app, p| { + params["path"] = json!(p.to_string_lossy()); + if app.run("file.export", params).is_some() { + app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); + } + }); } /// The file name a one-off export of `ids` suggests: the first layer's name (or `fallback`) with diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f98..44b94ae28 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -66,6 +66,8 @@ pub mod windows; #[cfg(test)] mod a11y_tests; #[cfg(test)] +mod async_dialog_tests; +#[cfg(test)] mod branch_ui_tests; #[cfg(test)] mod canvas_drag_tests; @@ -155,7 +157,11 @@ mod windows_qa_tests; mod windows_tests; use std::collections::BTreeSet; +use std::future::Future; use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context as TaskContext, Poll, Wake, Waker}; use figcraft_doc::NodeId; use figcraft_engine::Session; @@ -165,15 +171,15 @@ pub use control::{ControlReply, ControlRequest}; /// Platform services the app crate provides (file dialogs, clipboard, URLs). pub trait Services { - fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> Option { - None + fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture { + Box::pin(std::future::ready(None)) } - fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> Option { - None + fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> DialogFuture { + Box::pin(std::future::ready(None)) } /// A folder to write several files into. - fn pick_folder(&mut self, _title: &str) -> Option { - None + fn pick_folder(&mut self, _title: &str) -> DialogFuture { + Box::pin(std::future::ready(None)) } fn clipboard_set_text(&mut self, _s: &str) {} fn clipboard_get_text(&mut self) -> Option { @@ -209,6 +215,20 @@ pub trait Services { } } +pub type DialogFuture = Pin>>>; +type DialogDone = Box; + +struct RepaintWaker(egui::Context); +impl Wake for RepaintWaker { + fn wake(self: Arc) { + self.0.request_repaint(); + } + + fn wake_by_ref(self: &Arc) { + self.0.request_repaint(); + } +} + /// No-op services (tests, headless screenshots). pub struct NoServices; impl Services for NoServices {} @@ -503,6 +523,10 @@ pub struct FigApp { window_requests: Vec, /// Commands waiting for a server ([`FigApp::run_background`]) and what to do with their results. waiting: Vec<(String, Done)>, + /// Native dialogs complete asynchronously so their modal event loop never re-enters winit. + pending_dialog: Option<(DialogFuture, DialogDone)>, + /// The latest UI context schedules the first poll and wakes the app when a dialog completes. + ui_context: Option, } /// What to do with the result of a command run in the background. @@ -530,6 +554,8 @@ impl FigApp { windows: windows::Windows::default(), window_requests: Vec::new(), waiting: Vec::new(), + pending_dialog: None, + ui_context: None, } } @@ -790,12 +816,12 @@ impl FigApp { pub fn save_as_dialog(&mut self) { let name = self.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into()); - if let Some(p) = self.services.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft"]) { + self.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft"], |app, p| { let ps = p.to_string_lossy().to_string(); - if self.run("file.saveAs", json!({"path": ps})).is_some() { - self.toast(i18n::trf("Saved {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); + if app.run("file.saveAs", json!({"path": ps})).is_some() { + app.toast(i18n::trf("Saved {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); } - } + }); } pub fn save(&mut self) { @@ -811,14 +837,12 @@ impl FigApp { } pub fn open_dialog(&mut self) { - if let Some(p) = - self.services.pick_open(i18n::tr("Open"), &["figcraft", "fig", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]) - { + self.pick_open(i18n::tr("Open"), &["figcraft", "fig", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], |app, p| { let ps = p.to_string_lossy().to_string(); - if self.run("file.open", json!({"path": ps})).is_some() { - self.ui.home = false; + if app.run("file.open", json!({"path": ps})).is_some() { + app.ui.home = false; } - } + }); } pub fn import_dialog(&mut self, place: bool) { @@ -827,9 +851,44 @@ impl FigApp { } else { &["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig"] }; - if let Some(p) = self.services.pick_open(if place { i18n::tr("Place image") } else { i18n::tr("Import") }, exts) { + let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") }; + self.pick_open(title, exts, move |app, p| { let ps = p.to_string_lossy().to_string(); - self.run("file.import", json!({"path": ps})); + app.run("file.import", json!({"path": ps})); + }); + } + + pub fn pick_open(&mut self, title: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + self.start_dialog(|services| services.pick_open(title, exts), done); + } + + pub fn pick_save(&mut self, title: &str, name: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + self.start_dialog(|services| services.pick_save(title, name, exts), done); + } + + pub fn pick_folder(&mut self, title: &str, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + self.start_dialog(|services| services.pick_folder(title), done); + } + + fn start_dialog(&mut self, start: impl FnOnce(&mut dyn Services) -> DialogFuture, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + if self.pending_dialog.is_some() { + return; + } + self.pending_dialog = Some((start(self.services.as_mut()), Box::new(done))); + if let Some(ctx) = &self.ui_context { + ctx.request_repaint(); + } + } + + fn poll_dialog(&mut self, ctx: &egui::Context) { + let Some((future, _)) = &mut self.pending_dialog else { return }; + let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone()))); + let mut task = TaskContext::from_waker(&waker); + let Poll::Ready(path) = future.as_mut().poll(&mut task) else { return }; + if let Some((_, done)) = self.pending_dialog.take() + && let Some(path) = path + { + done(self, path); } } @@ -926,8 +985,10 @@ impl FigApp { #[cfg(not(target_arch = "wasm32"))] let t0 = std::time::Instant::now(); let ctx = ui.ctx().clone(); + self.ui_context = Some(ctx.clone()); self.frame_stats.record(&ctx); let first = !self.fonts_installed; + self.poll_dialog(&ctx); self.logic(&ctx); self.autosave_tick(&ctx); if first { diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 0d4da40c5..481b446c3 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -124,10 +124,11 @@ fn run(app: &mut FigApp, cmd: &str, params: Value) { /// Plugins ▸ Development ▸ Import plugin from manifest…: pick a `figcraft-plugin.json`. pub fn import_dialog(app: &mut FigApp) { - let Some(p) = app.services.pick_open(tr("Import plugin from manifest"), &["json"]) else { return }; - if let Some(v) = app.run("plugin.import", json!({"path": p.to_string_lossy()})) { - app.toast(trf("Imported {name}", &[("name", &v["name"].as_str().unwrap_or(tr("the plugin")))])); - } + app.pick_open(tr("Import plugin from manifest"), &["json"], |app, p| { + if let Some(v) = app.run("plugin.import", json!({"path": p.to_string_lossy()})) { + app.toast(trf("Imported {name}", &[("name", &v["name"].as_str().unwrap_or(tr("the plugin")))])); + } + }); } /// The installed plugins at the top of the Plugins menu: a plugin with one command is an item, one diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index 240a51a57..e7711e12f 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -205,24 +205,28 @@ fn header(app: &mut FigApp, ui: &mut Ui, open: &mut bool) { /// Export this file's collections through a save dialog. fn export_dialog(app: &mut FigApp, format: &str) { let name = app.session.doc().map(|d| format!("{}.variables.{format}", d.name)).unwrap_or_else(|_| format!("variables.{format}")); - let Some(p) = app.services.pick_save(tr("Export variables"), &name, &[format]) else { return }; - if app.run("variable.export", json!({"format": format, "path": p.to_string_lossy()})).is_some() { - let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); - app.toast(trf("Exported {file}", &[("file", &file)])); - } + let format = format.to_owned(); + let ext = format.clone(); + app.pick_save(tr("Export variables"), &name, &[&ext], move |app, p| { + if app.run("variable.export", json!({"format": format, "path": p.to_string_lossy()})).is_some() { + let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); + app.toast(trf("Exported {file}", &[("file", &file)])); + } + }); } /// Import a variables JSON picked in an open dialog. fn import_dialog(app: &mut FigApp) { - let Some(p) = app.services.pick_open(tr("Import variables"), &["json"]) else { return }; - let Some(r) = app.run("variable.import", json!({"variables": p.to_string_lossy()})) else { return }; - let (added, updated) = (r["variables"].as_u64().unwrap_or(0), r["updated"].as_u64().unwrap_or(0)); - let skipped = r["warnings"].as_array().map_or(0, Vec::len); - let mut msg = trn("Imported {n} new variables, updated {updated}", added as usize, &[("updated", &updated)]); - if skipped > 0 { - msg = format!("{msg} {}", trn("({n} warnings: some entries were skipped)", skipped, &[])); - } - app.toast(msg); + app.pick_open(tr("Import variables"), &["json"], |app, p| { + let Some(r) = app.run("variable.import", json!({"variables": p.to_string_lossy()})) else { return }; + let (added, updated) = (r["variables"].as_u64().unwrap_or(0), r["updated"].as_u64().unwrap_or(0)); + let skipped = r["warnings"].as_array().map_or(0, Vec::len); + let mut msg = trn("Imported {n} new variables, updated {updated}", added as usize, &[("updated", &updated)]); + if skipped > 0 { + msg = format!("{msg} {}", trn("({n} warnings: some entries were skipped)", skipped, &[])); + } + app.toast(msg); + }); } /// Extend library `lib`'s collection `c` and show the new collection. From f3cd0509cbf6a731ecab4bc77a7a307a242932e6 Mon Sep 17 00:00:00 2001 From: Piettro Castro Date: Wed, 14 Oct 2026 00:50:01 -0500 Subject: [PATCH 002/120] fix(engine): read the documented gradient shorthand on every route (#252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `{"type":"linearGradient","stops":["#hex",…]}` was only expanded by the MCP tools, so `figcraft-cli run` and the control channel's `engine.execute` refused it ("`fills` must be paints or colours"). The expansion now lives in the engine (`params::normalize_paint`, used by `paint_of`), so node.set, node.create props, fill.add/set, strokes and paint styles all take it; MCP calls the engine's copy instead of its own. Tests: engine (every paint command, MCP-equal defaults, junk still refused), control channel `engine.execute`, and `figcraft-cli run` end to end. Co-Authored-By: Claude Opus 5.5 --- apps/figcraft-cli/tests/cli.rs | 21 ++++ crates/engine/src/cmd/props.rs | 2 +- crates/engine/src/gradient_shorthand_tests.rs | 107 ++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/params.rs | 74 +++++++++++- crates/mcp/src/tools.rs | 68 +---------- .../ui-egui/src/gradient_shorthand_tests.rs | 21 ++++ crates/ui-egui/src/lib.rs | 2 + docs/control-protocol.md | 4 +- docs/mcp.md | 8 +- 10 files changed, 238 insertions(+), 71 deletions(-) create mode 100644 crates/engine/src/gradient_shorthand_tests.rs create mode 100644 crates/ui-egui/src/gradient_shorthand_tests.rs diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/cli.rs index 390e64143..9dd522031 100644 --- a/apps/figcraft-cli/tests/cli.rs +++ b/apps/figcraft-cli/tests/cli.rs @@ -78,6 +78,27 @@ fn run_export_info_convert() { std::fs::remove_dir_all(&d).ok(); } +#[test] +fn run_takes_the_documented_gradient_shorthand() { + // #252: only MCP used to accept it; `run` said "`fills` must be paints or colours". + let d = tmp("gradient"); + let script = d.join("s.json"); + std::fs::write( + &script, + r##"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":200,"height":100}}, + {"command":"node.set","params":{"ids":[2],"fills":[{"type":"linearGradient","stops":["#ff0000","#0000ff"],"from":[0,0.5],"to":[1,0.5]}]}}]"##, + ) + .unwrap(); + let file = d.join("g.figcraft"); + let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap(); + assert!(out.status.success(), "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + let json = d.join("g.json"); + assert!(bin().arg("convert").arg(&file).arg(&json).status().unwrap().success()); + let doc = std::fs::read_to_string(&json).unwrap(); + assert!(doc.contains("\"kind\":\"linear\"") || doc.contains("\"kind\": \"linear\""), "{doc}"); + std::fs::remove_dir_all(&d).ok(); +} + #[test] fn mcp_over_stdio() { let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 257d1a124..dd2daa82a 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -13,7 +13,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st /// Keys `node.set` understands (documented for agents). pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \ -fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ +fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \ clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \ mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \ diff --git a/crates/engine/src/gradient_shorthand_tests.rs b/crates/engine/src/gradient_shorthand_tests.rs new file mode 100644 index 000000000..3ea51a898 --- /dev/null +++ b/crates/engine/src/gradient_shorthand_tests.rs @@ -0,0 +1,107 @@ +//! The documented gradient shorthand (`{"type":"linearGradient","stops":["#hex",…]}`) works +//! through every route into the engine, not only MCP (#252): `figcraft-cli run` and the control +//! channel send params straight to `Session::execute`. + +use figcraft_color::Paint; +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::{id_of, paint_of}; + +/// The red→blue left-to-right gradient as written in the docs. +fn short() -> Value { + json!({"type": "linearGradient", "stops": ["#ff0000", "#0000ff"], "from": [0, 0.5], "to": [1, 0.5]}) +} + +/// The same gradient in the engine's own form. +fn long() -> Value { + json!({"type": "gradient", "kind": "linear", + "stops": [{"position": 0, "color": {"r": 1, "g": 0, "b": 0, "a": 1}}, + {"position": 1, "color": {"r": 0, "g": 0, "b": 1, "a": 1}}], + "from": [0, 0.5], "to": [1, 0.5], "width": [0, 1.5]}) +} + +fn rect(s: &mut Session) -> NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"]).unwrap() +} + +fn fills(s: &Session, id: NodeId) -> Vec { + s.doc().unwrap().node(id).unwrap().fills.clone() +} + +#[test] +fn the_shorthand_reads_as_the_long_form() { + assert_eq!(paint_of(&short()).expect("the documented shorthand is a paint"), paint_of(&long()).unwrap()); +} + +#[test] +fn node_set_takes_the_shorthand() { + let mut s = Session::new(); + let r = rect(&mut s); + s.execute("node.set", &json!({"ids": [r], "fills": [short()]})).unwrap(); + s.execute("node.set", &json!({"ids": [r], "strokes": [short()]})).unwrap(); + let want = paint_of(&long()).unwrap(); + assert_eq!(fills(&s, r), vec![want.clone()]); + assert_eq!(s.doc().unwrap().node(r).unwrap().strokes, vec![want]); +} + +#[test] +fn node_create_fill_add_and_fill_set_take_the_shorthand() { + let want = paint_of(&long()).unwrap(); + let mut s = Session::new(); + let v = s.execute("node.create", &json!({"type": "rectangle", "width": 200, "height": 100, "props": {"fills": [short()]}})).unwrap(); + let r = id_of(&v["id"]).unwrap(); + assert_eq!(fills(&s, r), vec![want.clone()]); + s.execute("fill.add", &json!({"ids": [r], "paint": short()})).unwrap(); + assert_eq!(fills(&s, r), vec![want.clone(), want.clone()]); + let r2 = rect(&mut s); + s.execute("fill.set", &json!({"ids": [r2], "index": 0, "paint": short()})).unwrap(); + assert_eq!(fills(&s, r2), vec![want]); +} + +#[test] +fn shorthand_defaults_match_the_mcp_server() { + // No handles: top to bottom, with a square width handle. + let p = paint_of(&json!({"type": "linearGradient", "stops": ["#000000", "#ffffff"]})).unwrap(); + let v = serde_json::to_value(&p).unwrap(); + assert_eq!( + (v["kind"].clone(), v["from"].clone(), v["to"].clone(), v["width"].clone()), + (json!("linear"), json!([0.5, 0.0]), json!([0.5, 1.0]), json!([-0.5, 0.0])) + ); + // Plain colour stops are spread evenly. + let p = paint_of(&json!({"type": "radialGradient", "stops": ["#000000", "#808080", "#ffffff"]})).unwrap(); + let v = serde_json::to_value(&p).unwrap(); + assert_eq!(v["kind"], "radial"); + let pos: Vec = v["stops"].as_array().unwrap().iter().map(|s| s["position"].as_f64().unwrap()).collect(); + assert_eq!(pos, vec![0.0, 0.5, 1.0]); + for kind in ["angular", "diamond"] { + let p = paint_of(&json!({"type": format!("{kind}Gradient"), "stops": ["#000000", "#ffffff"]})).unwrap(); + assert_eq!(serde_json::to_value(&p).unwrap()["kind"], kind); + } + // A CSS colour inside a solid paint or a positioned stop is read too. + assert_eq!(paint_of(&json!({"type": "solid", "color": "#00ff00"})), paint_of(&json!("#00ff00"))); + let p = paint_of(&json!({"type": "gradient", "stops": [{"position": 0, "color": "#ff0000"}, {"position": 1, "color": "#0000ff"}], "from": [0, 0.5], "to": [1, 0.5]})).unwrap(); + assert_eq!(p, paint_of(&long()).unwrap()); +} + +#[test] +fn junk_is_still_refused() { + let mut s = Session::new(); + let r = rect(&mut s); + for junk in [ + json!({"type": "linearGradient", "stops": "red"}), + json!({"type": "linearGradient", "stops": [42]}), + json!({"type": "fooGradient", "stops": ["#ff0000"]}), + json!({"type": "linearGradient", "stops": ["#ff0000"], "from": "left"}), + ] { + let e = s.execute("node.set", &json!({"ids": [r], "fills": [junk.clone()]})).unwrap_err().to_string(); + assert!(e.contains("`fills` must be paints or colours"), "{junk}: {e}"); + } + // Deep nesting doesn't overflow the stack. + let mut deep = json!("#ff0000"); + for _ in 0..200 { + deep = json!({"type": "linearGradient", "stops": [{"position": 0, "color": deep}]}); + } + assert!(paint_of(&deep).is_none()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a906..ac0ef938a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1467,6 +1467,8 @@ mod fx_behaviour_tests; #[cfg(test)] mod gesture_preview_tests; #[cfg(test)] +mod gradient_shorthand_tests; +#[cfg(test)] mod hide_publishing_tests; #[cfg(test)] mod history_behaviour_tests; diff --git a/crates/engine/src/params.rs b/crates/engine/src/params.rs index 4eca1444b..bbaa42a45 100644 --- a/crates/engine/src/params.rs +++ b/crates/engine/src/params.rs @@ -3,7 +3,7 @@ use figcraft_color::{BlendMode, Color, Paint}; use figcraft_doc::NodeId; -use serde_json::Value; +use serde_json::{Value, json}; use crate::{EngineError, Result, Session, bad}; @@ -135,12 +135,80 @@ pub fn color(p: &Value, key: &str) -> Option { get(p, key).and_then(color_of) } -/// A paint from JSON: a colour string/object (solid) or a full paint object. +/// Read the friendlier paint/effect JSON that the docs and MCP tools describe as the engine's own +/// form: any `"color"` given as a CSS string ("#RRGGBB[AA]", "rgb(…)") becomes {r,g,b,a}, +/// `{"type":"linearGradient"}` becomes `{"type":"gradient","kind":"linear"}`, gradients get default +/// handles (top to bottom) when `from`/`to`/`width` are missing, and stops given as plain colours +/// are spread evenly. Anything else is left for serde to refuse. +pub fn normalize_paint(v: &mut Value) { + normalize_paintish(v, 0); +} + +fn normalize_paintish(v: &mut Value, depth: usize) { + if depth > 16 { + return; + } + match v { + Value::Array(a) => { + for x in a { + normalize_paintish(x, depth + 1); + } + } + Value::Object(o) => { + if let Some(c) = o.get("color").filter(|c| c.is_string()).and_then(color_of) { + o.insert("color".into(), serde_json::to_value(c).unwrap_or(Value::Null)); + } + let is_gradient = o.get("type").and_then(Value::as_str).is_some_and(|t| t.to_ascii_lowercase().contains("gradient")); + if is_gradient { + // Accept {"type":"linearGradient"} as well as {"type":"gradient","kind":"linear"}. + if let Some(t) = o.get("type").and_then(Value::as_str).map(str::to_ascii_lowercase) + && t != "gradient" + { + let kind = t.trim_end_matches("gradient").to_string(); + o.insert("type".into(), json!("gradient")); + o.entry("kind").or_insert(json!(if kind.is_empty() { "linear".to_string() } else { kind })); + } + o.entry("kind").or_insert(json!("linear")); + let linear = o.get("kind").and_then(Value::as_str) == Some("linear"); + let (from, to, width) = if linear { ([0.5, 0.0], [0.5, 1.0], [-0.5, 0.0]) } else { ([0.5, 0.5], [0.5, 1.0], [1.0, 0.5]) }; + o.entry("from").or_insert(json!(from)); + o.entry("to").or_insert(json!(to)); + // A linear gradient's width handle goes square to its axis (else its bands skew, + // or vanish when the handle lies on the axis). + let pt = |k: &str| o.get(k).and_then(Value::as_array).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))); + if linear + && !o.contains_key("width") + && let (Some(f), Some(t)) = (pt("from"), pt("to")) + { + o.insert("width".into(), json!([f.0 - (t.1 - f.1), f.1 + (t.0 - f.0)])); + } + o.entry("width").or_insert(json!(width)); + if let Some(Value::Array(stops)) = o.get_mut("stops") { + let n = stops.len(); + for (i, s) in stops.iter_mut().enumerate() { + if let Some(c) = color_of(s).filter(|_| s.is_string()) { + let pos = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 }; + *s = json!({"position": pos, "color": c}); + } else { + normalize_paintish(s, depth + 1); + } + } + } + } + } + _ => {} + } +} + +/// A paint from JSON: a colour string/object (solid), a full paint object or its shorthand +/// ([`normalize_paint`]). pub fn paint_of(v: &Value) -> Option { if let Some(c) = color_of(v) { return Some(Paint::solid(c)); } - let mut p: Paint = serde_json::from_value(v.clone()).ok()?; + let mut v = v.clone(); + normalize_paint(&mut v); + let mut p: Paint = serde_json::from_value(v).ok()?; p.opacity = if p.opacity.is_finite() { p.opacity.clamp(0.0, 1.0) } else { 1.0 }; Some(p) } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index b8d3f3ad0..9f4d5b8fb 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -3,6 +3,7 @@ //! behave the same. use base64::Engine as _; +use figcraft_engine::params::normalize_paint; use serde_json::{Map, Value, json}; use crate::backend::{Backend, decode_data, page_children}; @@ -369,74 +370,17 @@ fn bounds_of(b: &mut dyn Backend, ids: &[u64]) -> Option<(f64, f64, f64, f64)> { acc } -/// Make paint/effect JSON friendlier than the engine's strict serde: any `"color"` given as a CSS -/// string ("#RRGGBB[AA]", "rgb(…)") becomes the engine's {r,g,b,a} object, gradient paints get -/// default handles (top-to-bottom) when `from`/`to`/`width` are missing, and gradient stops given -/// as plain colours are spread evenly. +/// Make paint/effect JSON friendlier than the engine's strict serde (see +/// [`figcraft_engine::params::normalize_paint`]). The engine reads paints this way itself; effects +/// still need it here. pub fn normalize_props(props: &mut Map) { for k in ["fills", "strokes", "effects"] { if let Some(v) = props.get_mut(k) { - normalize_paintish(v, 0); + normalize_paint(v); } } } -fn normalize_paintish(v: &mut Value, depth: usize) { - if depth > 16 { - return; - } - match v { - Value::Array(a) => { - for x in a { - normalize_paintish(x, depth + 1); - } - } - Value::Object(o) => { - if let Some(c) = o.get("color").filter(|c| c.is_string()).and_then(figcraft_engine::params::color_of) { - o.insert("color".into(), serde_json::to_value(c).unwrap_or(Value::Null)); - } - let is_gradient = o.get("type").and_then(Value::as_str).is_some_and(|t| t.to_ascii_lowercase().contains("gradient")); - if is_gradient { - // Accept {"type":"linearGradient"} as well as {"type":"gradient","kind":"linear"}. - if let Some(t) = o.get("type").and_then(Value::as_str).map(str::to_ascii_lowercase) - && t != "gradient" - { - let kind = t.trim_end_matches("gradient").to_string(); - o.insert("type".into(), json!("gradient")); - o.entry("kind").or_insert(json!(if kind.is_empty() { "linear".to_string() } else { kind })); - } - o.entry("kind").or_insert(json!("linear")); - let linear = o.get("kind").and_then(Value::as_str) == Some("linear"); - let (from, to, width) = if linear { ([0.5, 0.0], [0.5, 1.0], [-0.5, 0.0]) } else { ([0.5, 0.5], [0.5, 1.0], [1.0, 0.5]) }; - o.entry("from").or_insert(json!(from)); - o.entry("to").or_insert(json!(to)); - // A linear gradient's width handle goes square to its axis (else its bands skew, - // or vanish when the handle lies on the axis). - let pt = |k: &str| o.get(k).and_then(Value::as_array).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))); - if linear - && !o.contains_key("width") - && let (Some(f), Some(t)) = (pt("from"), pt("to")) - { - o.insert("width".into(), json!([f.0 - (t.1 - f.1), f.1 + (t.0 - f.0)])); - } - o.entry("width").or_insert(json!(width)); - if let Some(Value::Array(stops)) = o.get_mut("stops") { - let n = stops.len(); - for (i, s) in stops.iter_mut().enumerate() { - if let Some(c) = figcraft_engine::params::color_of(s).filter(|_| s.is_string()) { - let pos = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 }; - *s = json!({"position": pos, "color": c}); - } else { - normalize_paintish(s, depth + 1); - } - } - } - } - } - _ => {} - } -} - /// [`normalize_props`] for command params: `props`, flat node.set keys, `paint`, `effect`. fn friendly_params(p: &mut Value) { let Some(o) = p.as_object_mut() else { return }; @@ -446,7 +390,7 @@ fn friendly_params(p: &mut Value) { normalize_props(o); for k in ["paint", "effect"] { if let Some(v) = o.get_mut(k) { - normalize_paintish(v, 0); + normalize_paint(v); } } } diff --git a/crates/ui-egui/src/gradient_shorthand_tests.rs b/crates/ui-egui/src/gradient_shorthand_tests.rs new file mode 100644 index 000000000..db368f8e6 --- /dev/null +++ b/crates/ui-egui/src/gradient_shorthand_tests.rs @@ -0,0 +1,21 @@ +//! The control channel takes the documented gradient shorthand, as MCP does (#252). + +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +#[test] +fn engine_execute_takes_the_gradient_shorthand() { + let mut u = Ui::new(Session::new()); + let exec = |u: &mut Ui, command: &str, params: serde_json::Value| { + crate::control::handle(&mut u.app, &u.ctx.clone(), "engine.execute", &json!({"command": command, "params": params})) + }; + let r = exec(&mut u, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"].clone(); + let short = json!({"type": "linearGradient", "stops": ["#ff0000", "#0000ff"], "from": [0, 0.5], "to": [1, 0.5]}); + exec(&mut u, "node.set", json!({"ids": [r], "fills": [short]})).unwrap(); + let n = exec(&mut u, "node.get", json!({"id": r, "depth": 0})).unwrap(); + let f = &n["fills"][0]; + assert_eq!((f["type"].clone(), f["kind"].clone(), f["width"].clone()), (json!("gradient"), json!("linear"), json!([0.0, 1.5])), "{n}"); + assert_eq!(f["stops"].as_array().map(Vec::len), Some(2), "{n}"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f98..85fa092d0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -84,6 +84,8 @@ mod frame_label_tests; #[cfg(test)] mod gap_handle_tests; #[cfg(test)] +mod gradient_shorthand_tests; +#[cfg(test)] mod home_recents_tests; #[cfg(test)] mod home_tests; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9fc9937d9..3a4d10ae1 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -51,7 +51,9 @@ unknown id is an error. Run any command (the same ids as the menus, shortcuts, Actions palette, CLI and MCP). Programmatic calls never open dialogs: dialog requests are dropped. Returns the command's result (creation -commands return `{"id": N}`). +commands return `{"id": N}`). Paints (`fills`, `strokes`, `paint`) take a colour, the full paint +JSON or the gradient shorthand `{"type":"linearGradient","stops":["#hex","#hex"],"from"?,"to"?}` +(as in `docs/mcp.md`). ```json {"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}} diff --git a/docs/mcp.md b/docs/mcp.md index 2b66657c2..11f9c056f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -128,10 +128,10 @@ Or in a project's `.mcp.json`: resolved `gridCell`. - **Every menu action is a command** (`list_commands`), and most commands act on `ids` when given, else on the selection. New layers become the selection. -- Colours are `"#RRGGBB"` or `"#RRGGBBAA"`. Inside fills, strokes and effects passed through the MCP - tools, `color` may also be a hex string and gradients can be written - `{"type": "linearGradient", "stops": ["#7048E8", "#1098AD"]}`; the server converts them to the - engine's paint JSON. +- Colours are `"#RRGGBB"` or `"#RRGGBBAA"`. Inside fills and strokes, `color` may also be a hex + string and gradients can be written `{"type": "linearGradient", "stops": ["#7048E8", "#1098AD"]}`; + the engine reads them as its paint JSON, so `run_command`, `figcraft-cli run` and the control + channel take them too. Effects passed through the MCP tools may use hex `color` strings as well. - **Blend modes**: `blend` on a layer (`node.set`) or a paint (`fill.set` / `stroke.set`) is a menu label or CSS keyword (`"Normal"`, `"pass through"`, `"multiply"`, `"plusDarker"`, ...; `normal` is Normal). Groups and frames start as pass-through: their children blend with what is From 7a0bd980c6181070d7a85519d6f0abd4c3d70e94 Mon Sep 17 00:00:00 2001 From: VanTan <77483789+VanTan666@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:33:58 +0300 Subject: [PATCH 003/120] M9: add Russian interface translation --- README.md | 2 +- ROADMAP.md | 10 +- contributors/people.toml | 3 + crates/engine/src/cmd/view.rs | 28 +- crates/ui-egui/build.rs | 14 +- crates/ui-egui/locales/ru.toml | 2178 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 72 +- crates/ui-egui/src/i18n_tests.rs | 54 +- crates/ui-egui/src/layers.rs | 5 +- docs/gaps.md | 12 +- docs/localization-parity.md | 31 +- docs/mcp.md | 2 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 5 +- docs/translating.md | 15 +- 15 files changed, 2366 insertions(+), 70 deletions(-) create mode 100644 crates/ui-egui/locales/ru.toml diff --git a/README.md b/README.md index 9b67078be..9fb4aa97c 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust. - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. -- **Languages:** English and Spanish (a draft waiting for a native speaker's review), picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). +- **Languages:** English, Russian (reviewed by VanTan), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). - **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29b..9b2a7b8e2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Russian catalog and integer plural forms; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -63,7 +63,7 @@ native speakers' reviews of the six language drafts need people. | Stability | 55% | 20–40 | [gaps](docs/gaps.md) | | Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | | Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | -| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | +| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; reviewed Russian; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | | Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | | Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) | | AI features | 0% | 30–60 | [gaps](docs/gaps.md) | @@ -99,6 +99,7 @@ native speakers' reviews of the six language drafts need people. | Language | Code | Strings | Status | |---|---|---|---| | English | en | 100% | full | +| Russian | ru | 100% (2,165 entries) | wording reviewed by VanTan | | Simplified Chinese | zh-Hans | 100% | draft (needs native review) | | Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) | | Hindi | hi | 0% | none | @@ -111,9 +112,9 @@ native speakers' reviews of the six language drafts need people. | Korean | ko | 100% | draft (needs native review) | | Vietnamese | vi | 0% | none | -No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese +Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), -French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸ +French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts, and Russian reviewed by VanTan (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI text uses an installed CJK font, loaded only when needed; adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, @@ -312,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Russian UI catalog (M9): 1,577 UI keys, 557 command labels and 31 menu wordings, reviewed and approved by VanTan. Preferences lists Русский; ru regional tags follow it. Russian integer plurals use one/few/many with other as fallback; existing languages keep their rules. Contributor display name VanTan added. Headline estimates unchanged. | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/contributors/people.toml b/contributors/people.toml index e61cfd31a..30482465b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -16,3 +16,6 @@ # # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. + +[people.VanTan666] +display_name = "VanTan" diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 6135e5dfe..4cd44e561 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -369,18 +369,24 @@ pub fn specs() -> Vec { Ok(json!({"url": s.prefs.publish_server, "default": crate::live::DEFAULT_SERVER})) } ), - spec!("pref.language", "Interface language", "{language: system|en|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}", false, |s, p| { - let lang = req_string("pref.language", p, "language")?; - let lang = lang.trim(); - // A BCP 47-style tag ("es", "pt-BR", "zh_Hans") or "system". - let tag = lang.len() <= 35 - && lang.split(['-', '_']).all(|part| !part.is_empty() && part.len() <= 8 && part.chars().all(|c| c.is_ascii_alphanumeric())); - if !tag { - return Err(bad("pref.language", format!("`{lang}` is not a language code"))); + spec!( + "pref.language", + "Interface language", + "{language: system|en|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}", + false, + |s, p| { + let lang = req_string("pref.language", p, "language")?; + let lang = lang.trim(); + // A BCP 47-style tag ("es", "pt-BR", "zh_Hans") or "system". + let tag = lang.len() <= 35 + && lang.split(['-', '_']).all(|part| !part.is_empty() && part.len() <= 8 && part.chars().all(|c| c.is_ascii_alphanumeric())); + if !tag { + return Err(bad("pref.language", format!("`{lang}` is not a language code"))); + } + s.prefs.language = lang.to_string(); + Ok(json!({"language": s.prefs.language})) } - s.prefs.language = lang.to_string(); - Ok(json!({"language": s.prefs.language})) - }), + ), spec!("app.preferences", "Preferences", "{}", false, |s, _| ui(s, "dialog.preferences")), spec!("app.actions", "Actions", "{}", false, |s, _| ui(s, "palette.actions")), spec!("help.shortcuts", "Keyboard shortcuts", "{}", false, |s, _| ui(s, "panel.shortcuts")), diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 368ea6267..d5a4d3f08 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -71,7 +71,7 @@ fn locales() -> String { o } -/// The rows of one catalog table: `"key" = "text"` or `"key" = { one = "…", other = "…" }`. +/// The rows of one catalog table: strings or plural forms (`other` required). fn table(o: &mut String, path: &std::path::Path, t: Option<&toml::Value>) { let Some(t) = t.and_then(toml::Value::as_table) else { return }; // Sorted by key (byte order), as the lookup's binary search needs. @@ -81,8 +81,16 @@ fn table(o: &mut String, path: &std::path::Path, t: Option<&toml::Value>) { let s = |k: &str| v.get(k).and_then(toml::Value::as_str); match (v.as_str(), s("one"), s("other")) { (Some(text), ..) => _ = writeln!(o, " ({k:?}, Text::Plain({text:?})),"), - (None, one, Some(other)) => _ = writeln!(o, " ({k:?}, Text::Plural({}, {other:?})),", name(one)), - _ => println!("cargo::warning={}: `{k}` is neither a string nor {{ one, other }}; left out", path.display()), + (None, one, Some(other)) => { + _ = writeln!( + o, + " ({k:?}, Text::Plural {{ one: {}, few: {}, many: {}, other: {other:?} }}),", + name(one), + name(s("few")), + name(s("many")), + ); + } + _ => println!("cargo::warning={}: `{k}` is neither a string nor plural forms with `other`; left out", path.display()), } } } diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml new file mode 100644 index 000000000..3ab407572 --- /dev/null +++ b/crates/ui-egui/locales/ru.toml @@ -0,0 +1,2178 @@ +# FigCraft interface strings: Russian. +# Wording reviewed and approved by VanTan. +# Keep Frame, Dev Mode and Smart animate as familiar tool names. +# See docs/translating.md; plural forms follow Russian integer rules. + +[meta] +name = "Русский" + +[ui] +"% of all commits" = "% всех коммитов" +"(no longer published)" = "(публикация снята)" +"({n} warnings: some entries were skipped)" = { one = "({n} предупреждение: некоторые записи пропущены)", few = "({n} предупреждения: некоторые записи пропущены)", many = "({n} предупреждений: некоторые записи пропущены)", other = "({n} предупреждения: некоторые записи пропущены)" } +"+ Create variable" = "+ Создать переменную" +".fig (experimental)" = ".fig (экспериментально)" +"1…9, 0" = "1…9, 0" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, коммитов: {commits}, +{added} / −{deleted} строк (Δ {delta}), +{bin_added} / −{bin_deleted} бинарных ресурсов, {first} – {last}" +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Коллекция этого файла с этими режимами и переменными: переопределяйте значения для каждого режима и добавляйте собственные переменные" +"A responsive product page: hero, features, figures, a quote and a footer" = "Адаптивная страница продукта: главный блок, возможности, показатели, цитата и подвал" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "Пружинная анимация длится до затухания: её время задают масса, жёсткость и демпфирование" +"A title, an agenda, a big figure, a chart and a closing slide" = "Заголовок, повестка, крупный показатель, диаграмма и заключительный слайд" +"A to Z" = "От А до Я" +"About" = "О программе" +"About FigCraft" = "О FigCraft" +"About this server" = "Об этом сервере" +"Action" = "Действие" +"Actions" = "Действия" +"Actions for {name}" = "Действия для {name}" +"Actions…" = "Действия…" +"Active" = "Активно" +"Actual size (100%)" = "Фактический размер (100%)" +"Add a category" = "Добавить категорию" +"Add a comment" = "Добавить комментарий" +"Add a description" = "Добавить описание" +"Add a frame (F) or a shape (R) to get started" = "Для начала добавьте Frame (F) или фигуру (R)" +"Add a layer or component to tile first" = "Сначала добавьте слой или компонент для повторения" +"Add a note for developers" = "Добавить заметку для разработчиков" +"Add action" = "Добавить действие" +"Add actions for when no condition holds" = "Добавить действия на случай, если ни одно условие не выполнено" +"Add an annotation for developers" = "Добавить аннотацию для разработчиков" +"Add another condition" = "Добавить ещё одно условие" +"Add auto layout" = "Добавить автолейаут" +"Add background behind overlay" = "Добавить фон за оверлеем" +"Add content to this slot" = "Добавить содержимое в этот слот" +"Add dev resource" = "Добавить ресурс для разработки" +"Add effect" = "Добавить эффект" +"Add export setting" = "Добавить настройку экспорта" +"Add fill" = "Добавить заливку" +"Add interaction" = "Добавить взаимодействие" +"Add layout grid" = "Добавить сетку макета" +"Add library" = "Добавить библиотеку" +"Add library file…" = "Добавить файл библиотеки…" +"Add max height" = "Добавить максимальную высоту" +"Add max width" = "Добавить максимальную ширину" +"Add min height" = "Добавить минимальную высоту" +"Add min width" = "Добавить минимальную ширину" +"Add mode" = "Добавить режим" +"Add new page" = "Добавить страницу" +"Add or update collections from a variables JSON" = "Добавить или обновить коллекции из JSON переменных" +"Add properties to make instances configurable" = "Добавьте свойства, чтобы настраивать экземпляры" +"Add reaction" = "Добавить реакцию" +"Add starting point" = "Добавить начальную точку" +"Add stroke" = "Добавить обводку" +"Add variant" = "Добавить вариант" +"Add variant with a new value" = "Добавить вариант с новым значением" +"Added" = "Добавлено" +"Added the library \"{name}\"" = "Добавлена библиотека «{name}»" +"Adjust" = "Коррекция" +"Adjustment presets" = "Пресеты коррекции" +"Advanced" = "Дополнительно" +"Advanced stroke" = "Расширенные настройки обводки" +"After delay" = "После задержки" +"After delay {ms}ms" = "После задержки {ms} мс" +"Align" = "Выравнивание" +"Align bottom" = "Выровнять по нижнему краю" +"Align center" = "Выровнять по центру" +"Align horizontal centers" = "Выровнять центры по горизонтали" +"Align left" = "Выровнять по левому краю" +"Align middle" = "Выровнять по середине" +"Align right" = "Выровнять по правому краю" +"Align text baselines" = "Выровнять базовые линии текста" +"Align top" = "Выровнять по верхнему краю" +"Align vertical centers" = "Выровнять центры по вертикали" +"Alignment" = "Выравнивание" +"All" = "Все" +"All categories" = "Все категории" +"All comments" = "Все комментарии" +"All components" = "Все компоненты" +"All fonts" = "Все шрифты" +"All supported properties" = "Все поддерживаемые свойства" +"All variables" = "Все переменные" +"Alpha" = "Альфа" +"Alphabetical" = "По алфавиту" +"Alt+drag" = "Alt+перетаскивание" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Экран аналитики на сетке: показатели, векторные диаграммы и таблица" +"An invite link that opens FigCraft and joins" = "Ссылка приглашения, которая открывает FigCraft и подключает к сессии" +"Anchor" = "Привязка" +"Android" = "Android" +"Angular" = "Угловой" +"Animation" = "Анимация" +"Annotations" = "Аннотации" +"Annotations shown on the canvas" = "Аннотации на холсте" +"Another server, or one that needs its key. The usual server is set in Preferences." = "Другой сервер или сервер, требующий ключ. Основной сервер задаётся в настройках." +"Any status" = "Любой статус" +"Anyone on your network with this code can edit the file, while this app stays open." = "Любой в вашей сети с этим кодом может редактировать файл, пока приложение открыто." +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "По ссылке для редактирования можно изменять файл, по ссылке для просмотра — только смотреть. Ссылки содержат секреты: передавайте их как пароли. `figcraft-server links` создаёт новые ссылки и отзывает старые." +"Anyone with this code can edit the file." = "Любой с этим кодом может редактировать файл." +"Anyone with this code can look at the file, not change it." = "Любой с этим кодом может просматривать файл без возможности редактирования." +"Appearance" = "Внешний вид" +"Apply a component property" = "Применить свойство компонента" +"Apply variable" = "Применить переменную" +"Apply variable to text" = "Применить переменную к тексту" +"Apply variable to visibility" = "Применить переменную к видимости" +"Apps" = "Приложения" +"Apr" = "апр." +"Arc" = "Дуга" +"Arc ratio" = "Соотношение дуги" +"Arc start" = "Начало дуги" +"Arc sweep" = "Угол дуги" +"Archive" = "Архивировать" +"Archived" = "В архиве" +"Arrange" = "Упорядочить" +"Arrow" = "Стрелка" +"As typed" = "Как введено" +"Asking the server…" = "Запрос к серверу…" +"Assets" = "Ресурсы" +"Aug" = "авг." +"Auto" = "Авто" +"Auto (space around)" = "Авто (отступы вокруг)" +"Auto (space between)" = "Авто (отступы между)" +"Auto (space evenly)" = "Авто (равные отступы)" +"Auto ({mode})" = "Авто ({mode})" +"Auto columns" = "Автостолбцы" +"Auto gap" = "Автоинтервал" +"Auto height" = "Автовысота" +"Auto layout" = "Автолейаут" +"Auto rows" = "Автостроки" +"Auto width" = "Автоширина" +"Back to the FigCraft server" = "Вернуться к серверу FigCraft" +"Back to the font's value" = "Вернуть значение шрифта" +"Back" = "Назад" +"Back to Ready for dev" = "Снова готово к разработке" +"Back to Ready for dev: the next change compares with how it is now" = "Снова готово к разработке: следующие изменения сравниваются с текущим состоянием" +"Back to assets" = "Назад к ресурсам" +"Back to current" = "Вернуться к текущему" +"Back to styles" = "Назад к стилям" +"Back to the canvas from a focused region" = "Вернуться из выбранной области к холсту" +"Back to the current version" = "Вернуться к текущей версии" +"Back to the values the font style implies" = "Вернуть значения стиля шрифта" +"Background" = "Фон" +"Background blur" = "Размытие фона" +"Basics" = "Основы" +"Before" = "До" +"Bend" = "Изгиб" +"Bevel" = "Скошенное" +"Binary assets added" = "Добавлено бинарных ресурсов" +"Binary assets removed" = "Удалено бинарных ресурсов" +"Black & white" = "Чёрно-белое" +"Bleed" = "Вылет за обрез" +"Bleed marks" = "Метки вылета" +"Bleed marks need a bleed" = "Для меток вылета нужно задать вылет" +"Blend" = "Смешивание" +"Blend mode" = "Режим наложения" +"Blue" = "Синий" +"Blur" = "Размытие" +"Boolean" = "Логическое" +"Boolean groups" = "Булевы группы" +"Both directions" = "Оба направления" +"Bottom" = "Снизу" +"Bottom left radius" = "Радиус нижнего левого угла" +"Bottom right radius" = "Радиус нижнего правого угла" +"Branch" = "Ветка" +"Branch archived" = "Ветка архивирована" +"Branch merged" = "Ветка объединена" +"Branch name" = "Имя ветки" +"Branch of {file}" = "Ветка файла {file}" +"Branch of {file} · merged or archived" = "Ветка файла {file} · объединена или архивирована" +"Branch updated from main" = "Ветка обновлена из основной" +"Branch {name}" = "Ветка {name}" +"Branches" = "Ветки" +"Bound to {name}. Click to detach the variable." = "Привязано к {name}. Нажмите, чтобы отвязать переменную." +"Bring forward" = "На уровень вперёд" +"Bring the main file's changes into the branch" = "Перенести изменения основного файла в ветку" +"Bring to front" = "На передний план" +"Bristle" = "Щетина" +"Browse" = "Обзор" +"Brush" = "Кисть" +"Brush rotation jitter" = "Колебание угла кисти" +"Brush scatter" = "Рассеивание кисти" +"Brush size jitter" = "Колебание размера кисти" +"Brush spacing" = "Интервал кисти" +"Bulleted list" = "Маркированный список" +"C or Shift+C" = "C или Shift+C" +"C or ⇧C" = "C или ⇧C" +"CMYK colour" = "Цвет CMYK" +"Can view" = "Может просматривать" +"Checking the server…" = "Проверка сервера…" +"Clear from this list" = "Убрать из этого списка" +"Code or invite link" = "Код или ссылка приглашения" +"Color profile: {name}" = "Цветовой профиль: {name}" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Цветовые и текстовые стили, переменные светлой и тёмной тем, кнопки, поля ввода и карточки" +"CSS custom properties…" = "Пользовательские свойства CSS…" +"Can edit" = "Может редактировать" +"Can't read this library: {e}" = "Не удалось прочитать библиотеку: {e}" +"Cancel" = "Отмена" +"Canvas" = "Холст" +"Canvas stacking" = "Порядок наложения на холсте" +"Cap height to baseline" = "От высоты прописных до базовой линии" +"Case" = "Регистр" +"Case-sensitive forms" = "Формы с учётом регистра" +"Categories" = "Категории" +"Center" = "По центру" +"Change alias" = "Изменить алиас" +"Change resolved" = "Изменение разрешено" +"Change style" = "Изменить стиль" +"Change to" = "Переключить на" +"Change to needs a layer inside a variant" = "Для переключения нужен слой внутри варианта" +"Changed" = "Изменено" +"Changed on both sides: keep one" = "Изменено с обеих сторон: выберите один вариант" +"Changes on main" = "Изменения в основной ветке" +"Changes on the branch" = "Изменения в ветке" +"Changes unknown" = "Изменения неизвестны" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Выберите выше React, Leptos, SwiftUI или Compose, чтобы связать компонент с его кодом." +"Choose another profile to change it." = "Для смены выберите другой профиль." +"Choose collection" = "Выбрать коллекцию" +"Choose image" = "Выбрать изображение" +"Choose mode" = "Выбрать режим" +"Choose variable" = "Выбрать переменную" +"Circle" = "Круг" +"Clear recent" = "Очистить недавние" +"Click + to replace mixed content" = "Нажмите +, чтобы заменить смешанное содержимое" +"Click anywhere on the canvas to leave one." = "Нажмите в любом месте холста, чтобы оставить комментарий." +"Click or drag to merge · {alt} removes" = "Нажмите или перетащите для объединения · {alt} удаляет" +"Click or drag to remove · {alt} merges" = "Нажмите или перетащите для удаления · {alt} объединяет" +"Click to record a key" = "Нажмите для записи клавиши" +"Clip content" = "Обрезать содержимое" +"Clip to shape" = "Обрезать по фигуре" +"Close" = "Закрыть" +"Close overlay" = "Закрыть оверлей" +"Close preview" = "Закрыть предпросмотр" +"Close tab" = "Закрыть вкладку" +"Close version history" = "Закрыть историю версий" +"Close when clicking outside" = "Закрывать при нажатии снаружи" +"Close without saving" = "Закрыть без сохранения" +"Code" = "Код" +"Code syntax" = "Синтаксис кода" +"Collapse" = "Свернуть" +"Collapse panel" = "Свернуть панель" +"Collection" = "Коллекция" +"Collections" = "Коллекции" +"Color" = "Цвет" +"Color burn" = "Затемнение основы" +"Color dodge" = "Осветление основы" +"Color format" = "Формат цвета" +"Color profile" = "Цветовой профиль" +"Color styles" = "Цветовые стили" +"Color values in this file are in this color space." = "Значения цветов в этом файле заданы в этом цветовом пространстве." +"Color variables" = "Цветовые переменные" +"Colour" = "Цвет" +"Column" = "Столбец" +"Column gap" = "Интервал между столбцами" +"Column sizes" = "Размеры столбцов" +"Column span" = "Объединение столбцов" +"Columns" = "Столбцы" +"Columns {count}" = "Столбцы: {count}" +"Comment" = "Комментарий" +"Comments" = "Комментарии" +"Commits" = "Коммиты" +"Company" = "Компания" +"Compare changes" = "Сравнить изменения" +"Completed" = "Завершено" +"Component" = "Компонент" +"Component name" = "Имя компонента" +"Component property" = "Свойство компонента" +"Component set" = "Набор компонентов" +"Component sets" = "Наборы компонентов" +"Components" = "Компоненты" +"Conditional" = "Условие" +"Confetti" = "Конфетти" +"Conflicts" = "Конфликты" +"Connect to code" = "Связать с кодом" +"Connected" = "Подключено" +"Connected to code: {from}" = "Связано с кодом: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "Соединение потеряно, переподключение (попытка {attempt})…" +"Constrain proportions" = "Сохранять пропорции" +"Constraints" = "Ограничения" +"Content" = "Содержимое" +"Contents hidden" = "Содержимое скрыто" +"Contents only" = "Только содержимое" +"Contextual alternates" = "Контекстные альтернативы" +"Contrast" = "Контраст" +"Contributors" = "Участники" +"Contributors: {people} · Commits: {commits}" = "Участники: {people} · Коммиты: {commits}" +"Controller" = "Контроллер" +"Cool" = "Холодный" +"Copied as CSS" = "Скопировано как CSS" +"Copied as PNG" = "Скопировано как PNG" +"Copied as SVG" = "Скопировано как SVG" +"Copied as text" = "Скопировано как текст" +"Copied {value}" = "Скопировано: {value}" +"Copy" = "Копировать" +"Copy as" = "Копировать как" +"Copy as CSS" = "Копировать как CSS" +"Copy as PNG" = "Копировать как PNG" +"Copy as SVG" = "Копировать как SVG" +"Copy code" = "Копировать код" +"Copy link" = "Копировать ссылку" +"Copy the {label}" = "Копировать: {label}" +"Copy {value}" = "Копировать {value}" +"Corner radius" = "Радиус углов" +"Corner smoothing" = "Сглаживание углов" +"Couldn't reach {server}: showing what this computer remembers." = "Не удалось связаться с {server}: показаны данные, сохранённые на этом компьютере." +"Count" = "Количество" +"Create" = "Создать" +"Create a variable collection first" = "Сначала создайте коллекцию переменных" +"Create a variable first" = "Сначала создайте переменную" +"Create alias" = "Создать алиас" +"Create branch" = "Создать ветку" +"Create collection" = "Создать коллекцию" +"Create component" = "Создать компонент" +"Create component property" = "Создать свойство компонента" +"Create components, styles or variables to publish them as a library." = "Создайте компоненты, стили или переменные, чтобы опубликовать их как библиотеку." +"Create property…" = "Создать свойство…" +"Create style" = "Создать стиль" +"Create style from the selected text" = "Создать стиль из выделенного текста" +"Created" = "Создано" +"Crop" = "Обрезка" +"Crop marks" = "Метки обреза" +"Ctrl+arrows" = "Ctrl+стрелки" +"Ctrl+click" = "Ctrl+нажатие" +"Ctrl+scroll" = "Ctrl+прокрутка" +"Current name" = "Текущее имя" +"Current version" = "Текущая версия" +"Cursor chat" = "Чат у курсора" +"Custom" = "Пользовательский" +"Cut" = "Вырезать" +"Dark" = "Тёмная" +"Darken" = "Затемнение" +"Dash" = "Штрих" +"Dashboard" = "Панель аналитики" +"Date created" = "Дата создания" +"Dec" = "дек." +"Decoration style" = "Стиль оформления" +"Decoration" = "Оформление" +"Deep select" = "Глубокое выделение" +"Default" = "По умолчанию" +"Default content" = "Содержимое по умолчанию" +"Default value (the top-left variant)" = "Значение по умолчанию (верхний левый вариант)" +"Delay" = "Задержка" +"Delete" = "Удалить" +"Delete category (its annotations stay)" = "Удалить категорию (аннотации сохранятся)" +"Delete collection" = "Удалить коллекцию" +"Delete group" = "Удалить группу" +"Delete mode" = "Удалить режим" +"Delete page" = "Удалить страницу" +"Delete preset" = "Удалить пресет" +"Delete property" = "Удалить свойство" +"Delete the style (layers keep their values)" = "Удалить стиль (значения слоёв сохранятся)" +"Delete thread" = "Удалить обсуждение" +"Delete variable" = "Удалить переменную" +"Delete “{name}”? This can't be undone." = "Удалить «{name}»? Это действие нельзя отменить." +"Density" = "Плотность" +"Depth" = "Глубина" +"Describe how and when to use this component" = "Опишите, как и когда использовать этот компонент" +"Describe what changed" = "Опишите изменения" +"Description" = "Описание" +"Description (optional)" = "Описание (необязательно)" +"Design" = "Дизайн" +"Design system starter" = "Основа дизайн-системы" +"Desktop" = "Компьютер" +"Destination" = "Назначение" +"Detach alias" = "Отвязать алиас" +"Detach instance" = "Отсоединить экземпляр" +"Detach property" = "Отвязать свойство" +"Detach style" = "Отвязать стиль" +"Detach styles" = "Отвязать стили" +"Detach variable" = "Отвязать переменную" +"Details" = "Подробности" +"Dev Mode" = "Dev Mode" +"Dev resources" = "Ресурсы для разработки" +"Development" = "Разработка" +"Device" = "Устройство" +"Device at 100%" = "Устройство в масштабе 100%" +"Diamond" = "Ромб" +"Difference" = "Разница" +"Dimensions" = "Размеры" +"Direction" = "Направление" +"Disconnect from code" = "Отвязать от кода" +"Dispersion" = "Дисперсия" +"Display name" = "Отображаемое имя" +"Dissolve" = "Растворение" +"Distribute and tidy" = "Распределить и упорядочить" +"Distribute horizontal spacing" = "Распределить по горизонтали" +"Distribute vertical spacing" = "Распределить по вертикали" +"Divider" = "Разделитель" +"Don't truncate" = "Не обрезать" +"Done" = "Готово" +"Done editing" = "Завершить редактирование" +"Dots" = "Точки" +"Dotted" = "Точечный" +"Down" = "Вниз" +"Drafts" = "Черновики" +"Drag the stroke to add a width point" = "Перетащите обводку, чтобы добавить точку ширины" +"Draw" = "Рисование" +"Draw a frame" = "Нарисовать Frame" +"Drawing tools" = "Инструменты рисования" +"Drop shadow" = "Внешняя тень" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Двойная лицензия MIT OR Apache-2.0. Создано командой и сообществом ArtCraft." +"Duo" = "Дуо" +"Duplicate" = "Дублировать" +"Duplicate by dragging" = "Дублировать перетаскиванием" +"Duplicate mode" = "Дублировать режим" +"Duplicate page" = "Дублировать страницу" +"Edit" = "Правка" +"Edit as an expression" = "Редактировать как выражение" +"Edit colour" = "Изменить цвет" +"Edit comment" = "Изменить комментарий" +"Edit dev resource" = "Изменить ресурс для разработки" +"Edit link" = "Ссылка для редактирования" +"Edit link copied" = "Ссылка для редактирования скопирована" +"Edit style" = "Изменить стиль" +"Edit variable" = "Изменить переменную" +"Edit with the builder" = "Редактировать в конструкторе" +"Edited {when}" = "Изменено {when}" +"Effect settings" = "Настройки эффекта" +"Effect styles" = "Стили эффектов" +"Effects" = "Эффекты" +"Ellipse" = "Эллипс" +"Ellipses" = "Эллипсы" +"Else" = "Иначе" +"Else if" = "Иначе если" +"Embedded profile" = "Встроенный профиль" +"Empty" = "Пусто" +"End" = "Конец" +"End point" = "Конечная точка" +"Ends the session; everyone keeps their copy" = "Завершает сессию; у всех остаются свои копии" +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Введите адрес figcraft-server и нажмите «Обзор», чтобы увидеть доступные плагины." +"Enter to save · Esc to cancel" = "Enter — сохранить · Esc — отменить" +"Eraser" = "Ластик" +"Esc" = "Esc" +"Essential" = "Основные" +"Every conflict has a choice." = "Для каждого конфликта выбран вариант." +"Every top-level frame already starts a flow" = "Каждый Frame верхнего уровня уже начинает сценарий" +"Exact opacity (4 then 5: 45%)" = "Точная непрозрачность (4, затем 5: 45%)" +"Exclude" = "Исключение" +"Excluded" = "Исключено" +"Excluded from layout" = "Исключено из компоновки" +"Excludes" = "Исключения" +"Exclusion" = "Исключение" +"Exit focus view (Esc)" = "Выйти из режима фокуса (Esc)" +"Expand" = "Развернуть" +"Export" = "Экспорт" +"Export all" = "Экспортировать всё" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Экспортировать Frame и слои в PNG, JPG, SVG или PDF" +"Export page as PNG" = "Экспортировать страницу в PNG" +"Export settings" = "Настройки экспорта" +"Export variables" = "Экспортировать переменные" +"Export {name}" = "Экспортировать {name}" +"Export {n} layers" = { one = "Экспортировать {n} слой", few = "Экспортировать {n} слоя", many = "Экспортировать {n} слоёв", other = "Экспортировать {n} слоя" } +"Exported {file}" = "Экспортирован файл {file}" +"Exported {name}" = "Экспортировано: {name}" +"Exported {name}; {n} kinds of content were left out" = { one = "Экспортировано: {name}; пропущен {n} тип содержимого", few = "Экспортировано: {name}; пропущены {n} типа содержимого", many = "Экспортировано: {name}; пропущено {n} типов содержимого", other = "Экспортировано: {name}; пропущено {n} типа содержимого" } +"Exported {n} files" = { one = "Экспортирован {n} файл", few = "Экспортированы {n} файла", many = "Экспортировано {n} файлов", other = "Экспортировано {n} файла" } +"Export…" = "Экспорт…" +"Expose properties to instances" = "Сделать свойства доступными в экземплярах" +"Exposure" = "Экспозиция" +"Expression" = "Выражение" +"Extend collection" = "Расширить коллекцию" +"Extends \"{name}\": overridden values are marked" = "Расширяет «{name}»: переопределённые значения отмечены" +"Eye" = "Глаз" +"F" = "F" +"Fade" = "Затухание" +"False" = "Ложь" +"Feb" = "февр." +"Fetching from the server…" = "Загрузка с сервера…" +"File" = "Файл" +"File name" = "Имя файла" +"File not found" = "Файл не найден" +"File published" = "Файл опубликован" +"Files aren't end-to-end encrypted: the server's operator can read them." = "Файлы не защищены сквозным шифрованием: оператор сервера может их прочитать." +"Files nobody opens for {n} days are deleted." = { one = "Файлы, которые никто не открывает {n} день, удаляются.", few = "Файлы, которые никто не открывает {n} дня, удаляются.", many = "Файлы, которые никто не открывает {n} дней, удаляются.", other = "Файлы, которые никто не открывает {n} дня, удаляются." } +"Fill" = "Заливка" +"Fill (fr)" = "Заполнение (fr)" +"Fill container" = "Заполнить контейнер" +"Fill window" = "Заполнить окно" +"Fills" = "Заливки" +"Fill|sizing" = "Заполнение" +"Filter and sort" = "Фильтрация и сортировка" +"Find" = "Найти" +"Find and replace" = "Найти и заменить" +"Find layers" = "Найти слои" +"First" = "Первый" +"First commit" = "Первый коммит" +"First on top" = "Первый сверху" +"Fit" = "Вписать" +"Fit device to window" = "Вписать устройство в окно" +"Fit to contents" = "По содержимому" +"Fit to window" = "Вписать в окно" +"Fit width" = "По ширине" +"Fixed" = "Фиксированный" +"Fixed gap" = "Фиксированный интервал" +"Fixed size" = "Фиксированный размер" +"Flatten" = "Свести" +"Flip" = "Отразить" +"Flip horizontal" = "Отразить по горизонтали" +"Flip text to the other side" = "Перенести текст на другую сторону" +"Flip vertical" = "Отразить по вертикали" +"Flip width profile" = "Отразить профиль ширины" +"Flow starting point" = "Начальная точка сценария" +"Flow starting points" = "Начальные точки сценариев" +"Flows" = "Сценарии" +"Focus view" = "Режим фокуса" +"Fold autosaves" = "Свернуть автосохранения" +"Folder: click to collapse" = "Папка: нажмите, чтобы свернуть" +"Folder: click to expand" = "Папка: нажмите, чтобы развернуть" +"Follow" = "Следить" +"Follow {name}" = "Следить за {name}" +"Following" = "Следование" +"Following {name} · {key} to stop" = "Следование за {name} · {key} — остановить" +"Font" = "Шрифт" +"Font not installed" = "Шрифт не установлен" +"Font size" = "Размер шрифта" +"Font style" = "Начертание шрифта" +"Forget on this computer" = "Забыть на этом компьютере" +"Four screens of a trail app with components and a prototype flow" = "Четыре экрана приложения для походов с компонентами и сценарием прототипа" +"Fractions" = "Дроби" +"Frame" = "Frame" +"Frame selection" = "Поместить выделение во Frame" +"Frame tools" = "Инструменты Frame" +"Frames" = "Frames" +"From the library \"{name}\": read-only" = "Из библиотеки «{name}»: только чтение" +"From {name}" = "Из {name}" +"From {server}" = "С {server}" +"Frost" = "Иней" +"Gap" = "Интервал" +"Gap between columns" = "Интервал между столбцами" +"Gap between items" = "Интервал между элементами" +"Gap between rows" = "Интервал между строками" +"Getting started" = "Начало работы" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Укажите адрес общедоступного сервера или адрес сервера с ключом (`figcraft-server key` выводит ключ)." +"Glass" = "Стекло" +"Go to main component" = "Перейти к основному компоненту" +"Go to {name}" = "Перейти к {name}" +"Grab bag" = "Список имён" +"Gradient" = "Градиент" +"Gray" = "Серый" +"Green" = "Зелёный" +"Greyscale components and two example screens to sketch a flow with" = "Монохромные компоненты и два примера экранов для наброска сценария" +"Grid" = "Сетка" +"Grid ({size})" = "Сетка ({size})" +"Grid styles" = "Стили сеток" +"Grid {size}" = "Сетка {size}" +"Group" = "Группа" +"Group selection" = "Сгруппировать выделение" +"Groups" = "Группы" +"Gutter" = "Межколонник" +"Hand tool" = "Инструмент «Рука»" +"Hanging list" = "Выступающий список" +"Hanging punctuation" = "Висячая пунктуация" +"Hard light" = "Жёсткий свет" +"Height" = "Высота" +"Help" = "Справка" +"Help and resources" = "Справка и ресурсы" +"Help page" = "Страница справки" +"Here: {names}" = "Здесь: {names}" +"Hidden" = "Скрыто" +"Hide contents" = "Скрыть содержимое" +"Hide from publishing" = "Исключить из публикации" +"Hide section contents" = "Скрыть содержимое секции" +"Hide these tips (Getting started in the Help menu shows them again)" = "Скрыть подсказки (их можно вернуть через «Начало работы» в меню справки)" +"Hide when publishing" = "Скрывать при публикации" +"Highlight layers on hover" = "Подсвечивать слои при наведении" +"Highlights" = "Света" +"Hold Alt" = "Удерживайте Alt" +"Hold Alt+Z" = "Удерживайте Alt+Z" +"Hold Ctrl" = "Удерживайте Ctrl" +"Hold Z" = "Удерживайте Z" +"Hold ⌃ or ⌘" = "Удерживайте ⌃ или ⌘" +"Hold ⌥" = "Удерживайте ⌥" +"Hold ⌥Z" = "Удерживайте ⌥Z" +"Home" = "Главная" +"Horizontal" = "Горизонтально" +"Horizontal align" = "Горизонтальное выравнивание" +"Horizontal hex" = "Горизонтальный шестиугольник" +"Horizontal padding" = "Горизонтальные внутренние отступы" +"Horizontal spacing" = "Горизонтальный интервал" +"Hosted by {operator}" = "Оператор: {operator}" +"Hover a layer to see its properties" = "Наведите на слой, чтобы увидеть его свойства" +"Hover other layers to measure the distance" = "Наведите на другие слои, чтобы измерить расстояние" +"Hue" = "Цветовой тон" +"Hug" = "По содержимому" +"Hug contents" = "По размеру содержимого" +"Icon components on a 24 px grid with keylines and a size preview" = "Компоненты иконок на сетке 24 px с направляющими и предпросмотром размеров" +"Icon set starter" = "Основа набора иконок" +"If" = "Если" +"If {condition}" = "Если {condition}" +"Ignore auto layout (absolute position)" = "Игнорировать автолейаут (абсолютная позиция)" +"Image" = "Изображение" +"Import" = "Импорт" +"Import path" = "Путь импорта" +"Import plugin from manifest" = "Импортировать плагин из манифеста" +"Import plugin from manifest…" = "Импортировать плагин из манифеста…" +"Import variables" = "Импортировать переменные" +"Imported {name}" = "Импортировано: {name}" +"Imported {n} new variables, updated {updated}" = { one = "Импортирована {n} новая переменная, обновлено: {updated}", few = "Импортированы {n} новые переменные, обновлено: {updated}", many = "Импортировано {n} новых переменных, обновлено: {updated}", other = "Импортировано {n} новой переменной, обновлено: {updated}" } +"Import…" = "Импорт…" +"In an instance, this comes from the main component" = "В экземпляре это значение берётся из основного компонента" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "В браузере можно подключаться к сессиям настольного приложения и файлам на сервере FigCraft. Страница, открытая по https, подключается к другим компьютерам только по ссылкам wss:// (сервер с TLS)." +"Include bounding box" = "Включить ограничивающий прямоугольник" +"Include id attribute" = "Включить атрибут id" +"Included" = "Включено" +"Included in layout" = "Участвует в компоновке" +"Individual corners" = "Углы по отдельности" +"Individual strokes" = "Обводки по отдельности" +"Ink" = "Чернила" +"Inner shadow" = "Внутренняя тень" +"Inside" = "Внутри" +"Inspect" = "Инспектор" +"Inspect the layer under the pointer: size, colours, typography" = "Свойства слоя под указателем: размер, цвета, типографика" +"Install plugin" = "Установить плагин" +"Install" = "Установить" +"Installed {name}" = "Установлено: {name}" +"Installed" = "Установлено" +"Installing…" = "Установка…" +"Instance" = "Экземпляр" +"Instance properties" = "Свойства экземпляра" +"Instance swap" = "Замена экземпляра" +"Instance swap (no other component)" = "Замена экземпляра (других компонентов нет)" +"Instances" = "Экземпляры" +"Instant" = "Мгновенно" +"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." +"Intersect" = "Пересечение" +"Intersects" = "Пересечения" +"Invite link" = "Ссылка приглашения" +"Invite link copied" = "Ссылка приглашения скопирована" +"It asks permission to:" = "Запрашивает разрешение на:" +"Jan" = "янв." +"Join" = "Подключиться" +"Join code copied" = "Код подключения скопирован" +"Join code or invite link" = "Код подключения или ссылка приглашения" +"Join live session" = "Подключиться к совместной сессии" +"Join us on Discord" = "Присоединиться к нам в Discord" +"Join with a code" = "Подключиться по коду" +"Joined" = "Подключено" +"Joined {file}" = "Подключено к {file}" +"Joining…" = "Подключение…" +"Join|stroke" = "Соединение" +"Jul" = "июл." +"Jun" = "июн." +"Just now" = "Только что" +"Justify" = "По ширине" +"Keep appearance" = "Сохранить внешний вид" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "«Сохранить внешний вид» преобразует все значения цветов в {profile}, сохраняя их вид. «Сохранить значения» оставляет числа без изменений, поэтому цвета меняются." +"Keep tool selected after use" = "Оставлять инструмент выбранным после использования" +"Keep values" = "Сохранить значения" +"Key/Gamepad" = "Клавиша/геймпад" +"Key/Gamepad {key}" = "Клавиша/геймпад {key}" +"Keyboard shortcuts" = "Сочетания клавиш" +"Kits" = "Наборы" +"Landing page" = "Лендинг" +"Language" = "Язык" +"Lasso" = "Лассо" +"Last" = "Последний" +"Last change: {note}" = "Последнее изменение: {note}" +"Last commit" = "Последний коммит" +"Last modified" = "Последнее изменение" +"Last on top" = "Последний сверху" +"Layer blur" = "Размытие слоя" +"Layer name" = "Имя слоя" +"Layers" = "Слои" +"Layers in an instance take their position from the main component" = "Положение слоёв экземпляра задаётся основным компонентом" +"Layout" = "Компоновка" +"Layout grid" = "Сетка макета" +"Layout grid settings" = "Настройки сетки макета" +"Layout grids" = "Сетки макета" +"Leave session" = "Покинуть сессию" +"Leaves" = "Листья" +"Left" = "Слева" +"Left & right" = "Слева и справа" +"Left side" = "Левая сторона" +"Left side width" = "Ширина левой стороны" +"Letter case" = "Регистр букв" +"Letter spacing" = "Межбуквенный интервал" +"Libraries" = "Библиотеки" +"Libraries in this file" = "Библиотеки в этом файле" +"Library updates available" = "Доступны обновления библиотек" +"Library: click to collapse" = "Библиотека: нажмите, чтобы свернуть" +"Library: click to expand" = "Библиотека: нажмите, чтобы развернуть" +"Library: its variables are read-only here; edit them in the library file and publish it" = "Библиотека: здесь переменные доступны только для чтения; измените их в файле библиотеки и опубликуйте его" +"Light" = "Светлая" +"Lighten" = "Осветление" +"Light|glass" = "Свет" +"Line" = "Линия" +"Line arrow" = "Линейная стрелка" +"Line delta" = "Разница строк" +"Line height" = "Межстрочный интервал" +"Linear" = "Линейный" +"Lines" = "Линии" +"Lines +/−" = "Строки +/−" +"Lines added" = "Добавлено строк" +"Lines deleted" = "Удалено строк" +"Link" = "Ссылка" +"Link copied" = "Ссылка скопирована" +"Link docs, code or tickets for developers." = "Добавьте ссылки на документацию, код или задачи для разработчиков." +"Link frames on the Prototype tab, then present the flow" = "Свяжите Frame на вкладке «Прототип», затем запустите сценарий" +"Link this component to its code component: instances then show your real snippet with their values." = "Свяжите этот компонент с компонентом в коде: экземпляры будут показывать ваш код со своими значениями." +"List spacing" = "Интервал списка" +"List style" = "Стиль списка" +"Live session" = "Совместная сессия" +"Live · {people}" = "Онлайн · {people}" +"Local components" = "Локальные компоненты" +"Local network" = "Локальная сеть" +"Local styles" = "Локальные стили" +"Local variables" = "Локальные переменные" +"Lock/Unlock" = "Заблокировать/разблокировать" +"Locked" = "Заблокировано" +"Lowercase" = "Строчные" +"Luminance" = "Яркость" +"Luminosity" = "Свечение" +"Main" = "Основная" +"Main component deleted" = "Основной компонент удалён" +"Main menu" = "Главное меню" +"Make a component" = "Создать компонент" +"Make slot" = "Сделать слотом" +"Mar" = "мар." +"Margin" = "Внешний отступ" +"Mark all as read" = "Отметить всё как прочитанное" +"Mark as resolved" = "Отметить как решённое" +"Mark as unread" = "Отметить как непрочитанное" +"Mark as unresolved" = "Отметить как нерешённое" +"Mask" = "Маска" +"Mass · Stiffness · Damping" = "Масса · Жёсткость · Демпфирование" +"Match (empty: the whole name)" = "Совпадение (пусто: всё имя)" +"Max H" = "Макс. В" +"Max W" = "Макс. Ш" +"Max lines" = "Максимум строк" +"Maximize" = "Развернуть окно" +"May" = "май" +"Measure to the selection" = "Измерить до выделения" +"Merge into main" = "Объединить с основной" +"Merge regions" = "Объединить области" +"Merge the branch into the main file (one undo step) and archive it" = "Объединить ветку с основным файлом (один шаг отмены) и архивировать её" +"Merged" = "Объединено" +"Merged PRs" = "Объединённые PR" +"Min H" = "Мин. В" +"Min W" = "Мин. Ш" +"Minimize" = "Свернуть окно" +"Mirror angle" = "Отражать угол" +"Mirror angle and length" = "Отражать угол и длину" +"Mirroring" = "Зеркальность" +"Missing" = "Отсутствует" +"Missing layer" = "Слой отсутствует" +"Missing style" = "Стиль отсутствует" +"Missing variable" = "Переменная отсутствует" +"Miter" = "Острое" +"Miter angle" = "Угол острого соединения" +"Mixed" = "Смешанное" +"Mixed fonts" = "Разные шрифты" +"Mixed interactions: click + to replace them" = "Разные взаимодействия: нажмите + для замены" +"Mobile app" = "Мобильное приложение" +"Mode" = "Режим" +"Model" = "Модель" +"Models" = "Модели" +"Mono" = "Моно" +"More" = "Ещё" +"More instance actions" = "Другие действия с экземпляром" +"Mouse down" = "Нажатие кнопки мыши" +"Mouse enter" = "Вход указателя" +"Mouse leave" = "Выход указателя" +"Mouse up" = "Отпускание кнопки мыши" +"Move along the toolbar or the layer rows" = "Перемещение по панели инструментов или строкам слоёв" +"Move" = "Перемещение" +"Move column left" = "Переместить столбец влево" +"Move column right" = "Переместить столбец вправо" +"Move down" = "Переместить вниз" +"Move in" = "Сдвиг внутрь" +"Move out" = "Сдвиг наружу" +"Move tools" = "Инструменты перемещения" +"Move up" = "Переместить вверх" +"Moved to {name}" = "Перемещено в {name}" +"Multi" = "Мульти" +"Multiply" = "Умножение" +"Muted" = "Приглушённый" +"Name" = "Имя" +"Name (A–Z)" = "Имя (А–Я)" +"Named instance" = "Именованный экземпляр" +"Navigate to" = "Перейти к" +"Nested instance" = "Вложенный экземпляр" +"New design file" = "Новый файл дизайна" +"Newest first" = "Сначала новые" +"Next or previous control in the focused region" = "Следующий или предыдущий элемент в активной области" +"Next ready for dev" = "Следующий готовый к разработке" +"Next size option" = "Следующий вариант размера" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "Нет ресурсов: настроек экспорта, иконок или изображений. Добавьте настройки экспорта на вкладке «Дизайн»." +"No boolean variables fit here." = "Нет подходящих логических переменных." +"No boolean variables yet. Create them in Local variables." = "Логических переменных пока нет. Создайте их в разделе «Локальные переменные»." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Веток нет. Ветка — копия файла, в которой можно попробовать изменения, затем проверить и объединить их." +"No category" = "Без категории" +"No changes" = "Нет изменений" +"No colour variables fit here." = "Нет подходящих цветовых переменных." +"No colour variables yet. Create them in Local variables." = "Цветовых переменных пока нет. Создайте их в разделе «Локальные переменные»." +"No comments yet" = "Комментариев пока нет" +"No components yet. Select layers and press {keys}." = "Компонентов пока нет. Выделите слои и нажмите {keys}." +"No conflicts." = "Конфликтов нет." +"No contributor data was built into this copy." = "В этой сборке нет данных об участниках." +"No fonts match" = "Подходящих шрифтов нет" +"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, опубликованный как библиотека, или вставьте ссылку на командную библиотеку." +"No list" = "Без списка" +"No matching comments" = "Подходящих комментариев нет" +"No matching components" = "Подходящих компонентов нет" +"No matching layers" = "Подходящих слоёв нет" +"No matching styles" = "Подходящих стилей нет" +"No matching variables" = "Подходящих переменных нет" +"No mirroring" = "Без зеркальности" +"No model credits were built into this copy." = "В этой сборке нет сведений об использованных моделях." +"No number variables fit here." = "Нет подходящих числовых переменных." +"No number variables yet. Create them in Local variables." = "Числовых переменных пока нет. Создайте их в разделе «Локальные переменные»." +"No paints" = "Заливок нет" +"No plugins on {server}." = "На {server} нет плагинов." +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Плагинов пока нет. Импортируйте плагин для разработки из его figcraft-plugin.json." +"No preferred components" = "Предпочтительных компонентов нет" +"No preset" = "Без пресета" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "Сохранённых версий пока нет. Сохраните версию, чтобы можно было вернуться к этому этапу работы." +"No scrolling" = "Без прокрутки" +"No shortcuts match" = "Подходящих сочетаний нет" +"No status" = "Без статуса" +"No string variables fit here." = "Нет подходящих строковых переменных." +"No string variables yet. Create them in Local variables." = "Строковых переменных пока нет. Создайте их в разделе «Локальные переменные»." +"No styles yet." = "Стилей пока нет." +"No styles yet. Click + to create one from the selection." = "Стилей пока нет. Нажмите +, чтобы создать стиль из выделения." +"No templates match your search" = "По вашему запросу нет шаблонов" +"No text styles yet. Click + to create one from the selected text." = "Текстовых стилей пока нет. Нажмите +, чтобы создать стиль из выделенного текста." +"No variables fit here." = "Нет подходящих переменных." +"No variables yet. Create them in Local variables." = "Переменных пока нет. Создайте их в разделе «Локальные переменные»." +"Nobody else is here yet" = "Здесь пока больше никого нет" +"Noise" = "Шум" +"None" = "Нет" +"None." = "Нет." +"Normal" = "Обычный" +"Not bound" = "Не привязано" +"Not in a live session" = "Нет активной совместной сессии" +"Not in this font" = "Нет в этом шрифте" +"Not properties, kept as written: {names}" = "Не свойства, оставлены как есть: {names}" +"Not read yet" = "Ещё не прочитано" +"Not shared" = "Не опубликовано" +"Note {n}" = "Заметка {n}" +"Nothing changed since this was marked ready for dev." = "С момента отметки «Готово к разработке» изменений нет." +"Nothing here yet. Set a frame's status to list it." = "Здесь пока пусто. Задайте статус Frame, чтобы он появился в списке." +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Онлайн-файлов пока нет. Нажмите «Поделиться» в файле, чтобы опубликовать его и добавить сюда." +"Nothing to export" = "Нечего экспортировать" +"Nov" = "нояб." +"Now" = "Сейчас" +"Nudge (Shift: big nudge)" = "Сдвиг (Shift: большой шаг)" +"Nudge amount" = "Шаг сдвига" +"Number" = "Число" +"Number ↑" = "Число ↑" +"Number ↓" = "Число ↓" +"Numbered list" = "Нумерованный список" +"Object" = "Объект" +"Oct" = "окт." +"Off" = "Выкл." +"Offered first when filling the slot (any content is allowed)" = "Предлагается первым при заполнении слота (можно использовать любое содержимое)" +"Offered first when swapping the instance (any component is allowed)" = "Предлагается первым при замене экземпляра (можно использовать любой компонент)" +"Offset" = "Смещение" +"Oldest first" = "Сначала старые" +"On click" = "По нажатию" +"On drag" = "При перетаскивании" +"On this page" = "На этой странице" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Одно объявление в пяти популярных размерах: квадрат, портрет, история, ссылка и баннер" +"Online" = "Онлайн" +"Online files" = "Онлайн-файлы" +"Online sharing is off: files are shared on the local network only." = "Онлайн-публикация отключена: файлы доступны только в локальной сети." +"Only the first changed layers are listed." = "Показаны только первые изменённые слои." +"Only the first {n} changed layers are listed." = "Показаны только первые {n} изменённых слоёв." +"Only your threads" = "Только ваши обсуждения" +"Opacity" = "Непрозрачность" +"Opacity & blend" = "Непрозрачность и наложение" +"Opacity 10%…90%, 0: 100%" = "Непрозрачность 10%…90%, 0: 100%" +"Open" = "Открыть" +"Open .figcraft, .svg or images" = "Открыть .figcraft, .svg или изображения" +"Open branch" = "Открыть ветку" +"Open file" = "Открыть файл" +"Open files" = "Открыть файлы" +"Open in focus view" = "Открыть в режиме фокуса" +"Open in new window" = "Открыть в новом окне" +"Open invite links in FigCraft" = "Открывать ссылки приглашения в FigCraft" +"Open link" = "Открыть ссылку" +"Open link {url}" = "Открыть ссылку {url}" +"Open overlay" = "Открыть оверлей" +"Open prototype preview" = "Открыть предпросмотр прототипа" +"Open variables ({count})" = "Открыть переменные ({count})" +"Opened “{name}” in a new tab" = "«{name}» открыт в новой вкладке" +"Open|comments" = "Открытые" +"Options" = "Параметры" +"Orange" = "Оранжевый" +"Order" = "Порядок" +"Other apps may not open every .fig file exported here." = "Другие приложения могут открыть не каждый экспортированный здесь файл .fig." +"Others lose access and keep their copy; the file stays on the server for you" = "Другие участники потеряют доступ, сохранив свои копии; файл останется на сервере для вас" +"Others type this under Share ▸ Join with a code" = "Другие участники вводят это в «Поделиться» ▸ «Подключиться по коду»" +"Outline stroke" = "Преобразовать обводку в контур" +"Outline text" = "Преобразовать текст в контуры" +"Outlines" = "Контуры" +"Outside" = "Снаружи" +"Overflow" = "Переполнение" +"Overlay" = "Оверлей" +"Overlay position" = "Положение оверлея" +"Overridden here (the collection it extends has another value)" = "Переопределено здесь (в исходной коллекции другое значение)" +"Overrides" = "Переопределения" +"Owner token" = "Токен владельца" +"Owner token copied" = "Токен владельца скопирован" +"PDF print options" = "Параметры печати PDF" +"PDF print settings" = "Настройки печати PDF" +"Padding" = "Внутренние отступы" +"Page" = "Страница" +"Pages" = "Страницы" +"Pages: {pages}" = "Страницы: {pages}" +"Paint bucket" = "Заливка области" +"Pan the canvas" = "Перемещение по холсту" +"Pan with nothing selected (Shift: farther)" = "Перемещение без выделения (Shift: дальше)" +"Panels" = "Панели" +"Paper" = "Бумага" +"Paragraph indent" = "Абзацный отступ" +"Paragraph indent applies to left-aligned text" = "Абзацный отступ применяется к тексту с выравниванием влево" +"Paragraph spacing" = "Интервал между абзацами" +"Pass through" = "Пропуск" +"Paste a link (https://…)" = "Вставьте ссылку (https://…)" +"Paste a team library link (a view link)" = "Вставьте ссылку на командную библиотеку (для просмотра)" +"Paste here" = "Вставить здесь" +"Paste to replace" = "Вставить с заменой" +"Pattern" = "Паттерн" +"Pattern alignment" = "Выравнивание паттерна" +"Pattern scale" = "Масштаб паттерна" +"Pattern source" = "Источник паттерна" +"Pen" = "Перо" +"Pencil" = "Карандаш" +"People in this file" = "Участники в этом файле" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "Участники подключаются по коду из любой сети. Пока файл опубликован, он хранится на {server}." +"People on your network join with a code. Nothing goes through a server." = "Участники вашей сети подключаются по коду. Сервер не используется." +"Phone" = "Телефон" +"Pick color" = "Пипетка" +"Pick color (I)" = "Пипетка (I)" +"Pick one font family first" = "Сначала выберите одно семейство шрифта" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Выберите инструмент Frame, затем протяните область на холсте или выберите размер устройства" +"Pin a property" = "Закрепить свойство" +"Pinch" = "Сужение" +"Pixel grid" = "Пиксельная сетка" +"Pixel preview 1x" = "Пиксельный предпросмотр 1x" +"Pixel preview 2x" = "Пиксельный предпросмотр 2x" +"Place a shape or frame (its tool chosen)" = "Разместить фигуру или Frame (выбранным инструментом)" +"Place automatically" = "Разместить автоматически" +"Place image" = "Разместить изображение" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Плагины работают в песочнице FigCraft без доступа к файлам, сети и часам. Устанавливайте только плагины, которым доверяете." +"Plugins" = "Плагины" +"Plus darker" = "Линейное затемнение" +"Plus lighter" = "Линейное осветление" +"Point" = "Точка" +"Points" = "Точки" +"Polygon" = "Многоугольник" +"Polygons" = "Многоугольники" +"Port" = "Порт" +"Position" = "Положение" +"Post" = "Отправить" +"Preferences" = "Настройки" +"Preferred" = "Предпочтительные" +"Present" = "Презентация" +"Present or preview" = "Презентация или предпросмотр" +"Present this flow" = "Запустить этот сценарий" +"Presentation" = "Презентация" +"Presentation slides" = "Слайды презентации" +"Presenting: comments" = "Презентация: комментарии" +"Presenting: cycle the size options" = "Презентация: переключить размер" +"Presenting: full screen" = "Презентация: полный экран" +"Presenting: next frame" = "Презентация: следующий Frame" +"Presenting: previous frame" = "Презентация: предыдущий Frame" +"Presenting: restart" = "Презентация: начать заново" +"Preserve scroll position" = "Сохранять положение прокрутки" +"Presets" = "Пресеты" +"Press a key…" = "Нажмите клавишу…" +"Playground" = "Песочница" +"Preview" = "Предпросмотр" +"Preview this flow" = "Предпросмотр этого сценария" +"Preview this frame" = "Предпросмотр этого Frame" +"Previous ready for dev" = "Предыдущий готовый к разработке" +"Progressive" = "Прогрессивный" +"Properties" = "Свойства" +"Prototype" = "Прототип" +"Prototype preview" = "Предпросмотр прототипа" +"Prototype settings" = "Настройки прототипа" +"Publish" = "Опубликовать" +"Publish changes" = "Опубликовать изменения" +"Publish to another server…" = "Опубликовать на другом сервере…" +"Publish to server" = "Опубликовать на сервере" +"Published version {v}" = "Опубликована версия {v}" +"Published version {v} to the server" = "Версия {v} опубликована на сервере" +"Published version {v}: save the file so other files can use it" = "Опубликована версия {v}: сохраните файл, чтобы её можно было использовать в других файлах" +"Published · {people}" = "Опубликовано · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Для публикации на сервере нужно настольное приложение; в браузере можно подключаться к файлам сервера" +"Publishing with the server's key." = "Публикация с ключом сервера." +"Publishing…" = "Публикация…" +"Push" = "Вытеснение" +"Push changes to main component" = "Перенести изменения в основной компонент" +"Put text on path" = "Разместить текст на пути" +"R" = "R" +"Radial" = "Радиальный" +"Radius" = "Радиус" +"Ratio" = "Соотношение" +"Reading the library…" = "Чтение библиотеки…" +"Ready for dev" = "Готово к разработке" +"Real name" = "Настоящее имя" +"Recent" = "Недавние" +"Recent files" = "Недавние файлы" +"Recents" = "Недавние" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "После сбоя восстановлен {n} несохранённый файл: сохраните его", few = "После сбоя восстановлены {n} несохранённых файла: сохраните их", many = "После сбоя восстановлено {n} несохранённых файлов: сохраните их", other = "После сбоя восстановлено {n} несохранённого файла: сохраните их" } +"Rectangle" = "Прямоугольник" +"Rectangles" = "Прямоугольники" +"Red" = "Красный" +"Reduce motion" = "Уменьшить движение" +"Refraction" = "Преломление" +"Regular expression" = "Регулярное выражение" +"Remove" = "Удалить" +"Remove all" = "Удалить всё" +"Remove annotation" = "Удалить аннотацию" +"Remove auto layout" = "Убрать автолейаут" +"Remove dev resource" = "Удалить ресурс для разработки" +"Remove export setting" = "Удалить настройку экспорта" +"Remove from recents" = "Убрать из недавних" +"Remove from server" = "Удалить с сервера" +"Remove from server…" = "Удалить с сервера…" +"Remove guide" = "Удалить направляющую" +"Remove interaction" = "Удалить взаимодействие" +"Remove library from this file" = "Удалить библиотеку из этого файла" +"Remove min and max" = "Убрать минимум и максимум" +"Remove min and max height" = "Убрать минимальную и максимальную высоту" +"Remove min and max width" = "Убрать минимальную и максимальную ширину" +"Remove regions" = "Удалить области" +"Remove slot" = "Убрать слот" +"Remove starting point" = "Убрать начальную точку" +"Remove stop" = "Удалить точку градиента" +"Remove text from path" = "Снять текст с пути" +"Remove the else and its actions" = "Удалить «Иначе» и его действия" +"Remove this condition and its actions" = "Удалить это условие и его действия" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Удалить файл с сервера? Его больше нельзя будет открывать и публиковать оттуда. Ваша копия останется открытой здесь." +"Remove {name} from {server}?" = "Удалить {name} с {server}?" +"Removed" = "Удалено" +"Removed from the server" = "Удалено с сервера" +"Removed since it was marked" = "Удалено после отметки" +"Rename" = "Переименовать" +"Rename duplicated layers" = "Переименовывать дубликаты слоёв" +"Rename folder" = "Переименовать папку" +"Rename group" = "Переименовать группу" +"Rename layers" = "Переименовать слои" +"Rename mode" = "Переименовать режим" +"Rename on the server" = "Переименовать на сервере" +"Rename on the server…" = "Переименовать на сервере…" +"Rename page" = "Переименовать страницу" +"Rename to" = "Новое имя" +"Replace all" = "Заменить всё" +"Replace mixed export settings" = "Заменить разные настройки экспорта" +"Replace with" = "Заменить на" +"Replace with a new interaction" = "Заменить новым взаимодействием" +"Reply" = "Ответить" +"Report" = "Пожаловаться" +"Report this file…" = "Пожаловаться на этот файл…" +"Reset" = "Сбросить" +"Reset adjustments" = "Сбросить коррекцию" +"Reset all changes" = "Сбросить все изменения" +"Reset all overrides on this layer" = "Сбросить все переопределения слоя" +"Reset overrides" = "Сбросить переопределения" +"Reset slot" = "Сбросить слот" +"Reset this override" = "Сбросить переопределение" +"Reset to style" = "Сбросить к стилю" +"Reset to the inherited value" = "Вернуть унаследованное значение" +"Resize by the nudge amount" = "Изменить размер на шаг сдвига" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Менять размер Frame под экран устройства (или окно, если устройство не выбрано): ограничения и автолейаут подстраиваются" +"Resize to fit" = "Подогнать размер" +"Resolve" = "Решить" +"Resolve change" = "Разрешить изменение" +"Resolved" = "Решённые" +"Resources" = "Ресурсы" +"Responsive" = "Адаптивно" +"Restart" = "Начать заново" +"Restore" = "Восстановить" +"Restore main component" = "Восстановить основной компонент" +"Restore this version" = "Восстановить эту версию" +"Restored “{name}”" = "«{name}» восстановлено" +"Reverse the order" = "Обратить порядок" +"Reversed triangle" = "Обратный треугольник" +"Review and merge" = "Проверить и объединить" +"Ribbon" = "Лента" +"Right" = "Справа" +"Right side" = "Правая сторона" +"Right side width" = "Ширина правой стороны" +"Rotate 90°" = "Повернуть на 90°" +"Rotate 90° right" = "Повернуть на 90° вправо" +"Rotate to landscape" = "Повернуть в альбомную ориентацию" +"Rotate to portrait" = "Повернуть в портретную ориентацию" +"Rotation" = "Поворот" +"Rotation jitter" = "Колебание угла" +"Round" = "Круглое" +"Rounded" = "Скруглённый" +"Row" = "Строка" +"Row gap" = "Интервал между строками" +"Row sizes" = "Размеры строк" +"Row span" = "Объединение строк" +"Rows" = "Строки" +"Rows {count}" = "Строки: {count}" +"Rulers" = "Линейки" +"Run" = "Запустить" +"Run {label}" = "Запустить {label}" +"Search templates" = "Поиск шаблонов" +"See all templates" = "Все шаблоны" +"Select layers and they space themselves as their content changes" = "Выделите слои: интервалы между ними будут подстраиваться при изменении содержимого" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "Как у слоя" +"Saturation" = "Насыщенность" +"Save" = "Сохранить" +"Save as" = "Сохранить как" +"Save as preset…" = "Сохранить как пресет…" +"Save version {key}" = "Сохранить версию {key}" +"Saved" = "Сохранено" +"Saved {name}" = "Сохранено: {name}" +"Say something" = "Напишите сообщение" +"Scale" = "Масштаб" +"Scatter" = "Рассеивание" +"Scoping: where pickers offer it" = "Область применения: где предлагается переменная" +"Screen" = "Экран" +"Scroll behavior" = "Поведение при прокрутке" +"Scroll to" = "Прокрутить к" +"Scroll to layer" = "Прокрутить к слою" +"Scroll with parent" = "Прокручивать с родителем" +"Search plugins" = "Поиск плагинов" +"Search" = "Поиск" +"Search actions, layers and tools" = "Поиск действий, слоёв и инструментов" +"Search assets" = "Поиск ресурсов" +"Search comments" = "Поиск комментариев" +"Search fonts" = "Поиск шрифтов" +"Search layers" = "Поиск слоёв" +"Search shortcuts" = "Поиск сочетаний клавиш" +"Section" = "Секция" +"Section color" = "Цвет секции" +"Sections" = "Секции" +"Select a frame and add a starting point to present it as a flow." = "Выделите Frame и добавьте начальную точку, чтобы запустить его как сценарий." +"Select a frame to preview it" = "Выделите Frame для предпросмотра" +"Select a layer to see its properties and code" = "Выделите слой, чтобы увидеть его свойства и код" +"Select all" = "Выделить всё" +"Select all with same" = "Выделить всё с одинаковым" +"Select layer" = "Выделить слой" +"Select layers to apply the style to" = "Выделите слои для применения стиля" +"Select overlapping shapes" = "Выделите пересекающиеся фигуры" +"Select points to edit their position, radius and mirroring" = "Выделите точки для изменения положения, радиуса и зеркальности" +"Select slot" = "Выделить слот" +"Selection" = "Выделение" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Область выделения {w} на {h} в {x}, {y}: стрелки — перемещение, Shift+стрелки или {cmd}+стрелки — размер, Enter — выделить, Escape — отменить" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Область выделения: стрелки — перемещение, Shift+стрелки или {cmd}+стрелки — размер, Enter — выделить, Escape — отменить" +"Send backward" = "На уровень назад" +"Send report" = "Отправить жалобу" +"Send to back" = "На задний план" +"Sep" = "сент." +"Server address (ws://host:47900/)" = "Адрес сервера (ws://host:47900/)" +"Server address" = "Адрес сервера" +"Server for online sharing" = "Сервер для онлайн-публикации" +"Set as default" = "Задать по умолчанию" +"Set variable" = "Задать переменную" +"Set variable mode" = "Задать режим переменных" +"Set variable {name}" = "Задать переменную {name}" +"Set {name} to {value}" = "Задать {name}: {value}" +"Shadows" = "Тени" +"Shape" = "Фигура" +"Shape builder" = "Конструктор фигур" +"Shape tools" = "Инструменты фигур" +"Share" = "Поделиться" +"Share this file" = "Поделиться файлом" +"Share with a code" = "Поделиться по коду" +"Share: live session, export" = "Поделиться: совместная сессия, экспорт" +"Shared" = "Опубликовано" +"Shared: copy its code from the file's menu" = "Опубликовано: скопируйте код в меню файла" +"Sharing a file needs the desktop app; here you can join one." = "Для публикации файла нужно настольное приложение; здесь можно подключиться к опубликованному файлу." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Общий доступ в локальной сети (онлайн-публикация отключена: сервер не задан в настройках)." +"Sharing on your network · {people}" = "Общий доступ в вашей сети · {people}" +"Sharing online · {people}" = "Общий доступ онлайн · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "Общий доступ остановлен: файл остаётся на сервере, доступен только вам" +"Show" = "Показать" +"Show all templates" = "Показать все шаблоны" +"Show as grid" = "Показать сеткой" +"Show as list" = "Показать списком" +"Show autosaves" = "Показать автосохранения" +"Show behind transparent areas" = "Показать за прозрачными областями" +"Show comments (Shift+C)" = "Показать комментарии (Shift+C)" +"Show device frame" = "Показать рамку устройства" +"Show dimensions on objects" = "Показать размеры объектов" +"Show hotspot hints" = "Показать интерактивные области" +"Show in Explorer" = "Показать в Проводнике" +"Show in Finder" = "Показать в Finder" +"Show in folder" = "Показать в папке" +"Show only this on the canvas" = "Показать на холсте только это" +"Show open" = "Показать открытые" +"Show section contents" = "Показать содержимое секции" +"Show this layer" = "Показать этот слой" +"Show usage" = "Показать использование" +"Show/Hide" = "Показать/скрыть" +"Show/Hide UI" = "Показать/скрыть интерфейс" +"Side by side" = "Рядом" +"Sidebar" = "Боковая панель" +"Simplify stroke" = "Упростить обводку" +"Size" = "Размер" +"Size jitter" = "Колебание размера" +"Skip ink" = "Пропускать пересечения с буквами" +"Slashed zero" = "Перечёркнутый ноль" +"Slice" = "Срез" +"Slices" = "Срезы" +"Slide in" = "Въезд" +"Slide out" = "Выезд" +"Slot" = "Слот" +"Slot content" = "Содержимое слота" +"Small capitals" = "Капительные буквы" +"Small caps" = "Капитель" +"Smart animate" = "Smart animate" +"Snap to pixel grid" = "Привязка к пиксельной сетке" +"Social and slides" = "Соцсети и слайды" +"Social media" = "Соцсети" +"Social post sizes" = "Размеры публикаций для соцсетей" +"Soft light" = "Мягкий свет" +"Solid" = "Сплошная" +"Some variants have the same property values: give each one a unique combination" = "Некоторые варианты имеют одинаковые значения свойств: задайте каждому уникальное сочетание" +"Sort" = "Сортировка" +"Sort by" = "Сортировать по" +"Sort by date" = "Сортировать по дате" +"Sort by page" = "Сортировать по странице" +"Sort by {column}" = "Сортировать по {column}" +"Space around" = "Отступы вокруг" +"Space between" = "Отступы между" +"Space drag" = "Пробел и перетаскивание" +"Space evenly" = "Равные отступы" +"Space+drag" = "Пробел+перетаскивание" +"Spacing" = "Интервал" +"Spam, stolen work, abuse…" = "Спам, украденные работы, оскорбления…" +"Splay" = "Расхождение" +"Spotlight me" = "Показывать мой экран всем" +"Spread" = "Размах" +"Square" = "Квадрат" +"Standard" = "Стандартный" +"Standard ligatures" = "Стандартные лигатуры" +"Star" = "Звезда" +"Stars" = "Звёзды" +"Start" = "Начало" +"Start ascending from / stop descending at" = "Начать увеличение с / закончить уменьшение на" +"Start from a blank canvas" = "Начать с пустого холста" +"Start from a template" = "Начать с шаблона" +"Start on path" = "Начало на пути" +"Start point" = "Начальная точка" +"Start sharing" = "Включить общий доступ" +"Start sharing with the Share button, then pass on the code it gives" = "Включите общий доступ кнопкой «Поделиться», затем передайте полученный код" +"Starting to share…" = "Включение общего доступа…" +"Status" = "Статус" +"Sticky" = "Закреплённый" +"Stop" = "Остановить" +"Stop following {name}" = "Перестать следить за {name}" +"Stop session" = "Завершить сессию" +"Stop sharing" = "Остановить общий доступ" +"Stop spotlighting" = "Прекратить показ всем" +"Stop {n} {hex}" = "Точка {n} {hex}" +"Stretch" = "Растянуть" +"Strikethrough" = "Зачёркивание" +"String" = "Строка" +"Stroke" = "Обводка" +"Stroke style" = "Стиль обводки" +"Stroke weight" = "Толщина обводки" +"Strokes" = "Обводки" +"Style" = "Стиль" +"Styles" = "Стили" +"Stylistic set 1" = "Стилистический набор 1" +"Stylistic set 2" = "Стилистический набор 2" +"Stylistic set 3" = "Стилистический набор 3" +"Stylistic set 4" = "Стилистический набор 4" +"Stylistic set 5" = "Стилистический набор 5" +"Subtract" = "Вычитание" +"Subtracts" = "Вычитания" +"Suffix" = "Суффикс" +"Suspend snapping while dragging" = "Временно отключить привязку при перетаскивании" +"Swap instance" = "Заменить экземпляр" +"Swap library: use another library's assets of the same names" = "Заменить библиотеку: использовать одноимённые ресурсы другой библиотеки" +"Swap orientation" = "Сменить ориентацию" +"Swap overlay" = "Заменить оверлей" +"Swap to {name}" = "Заменить на {name}" +"Swap width and height" = "Поменять ширину и высоту" +"Swapped instance" = "Экземпляр заменён" +"Swapped {n} assets" = { one = "Заменён {n} ресурс", few = "Заменены {n} ресурса", many = "Заменено {n} ресурсов", other = "Заменено {n} ресурса" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "Заменён {n} ресурс; без совпадений: {missed}, для них сохранена старая библиотека", few = "Заменены {n} ресурса; без совпадений: {missed}, для них сохранена старая библиотека", many = "Заменено {n} ресурсов; без совпадений: {missed}, для них сохранена старая библиотека", other = "Заменено {n} ресурса; без совпадений: {missed}, для них сохранена старая библиотека" } +"Sweep" = "Угол дуги" +"System" = "Системный" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "Таблица" +"Tablet" = "Планшет" +"Tabular numbers" = "Табличные цифры" +"Take the values from the first selected layer" = "Взять значения первого выделенного слоя" +"Taper" = "Сужение к концу" +"Taper both ends" = "Сужение с обоих концов" +"Tell the server's operator what is wrong with it" = "Сообщить оператору сервера о проблеме" +"Temperature" = "Температура" +"Template" = "Шаблон" +"Templates" = "Шаблоны" +"Terms" = "Условия" +"Text" = "Текст" +"Text align" = "Выравнивание текста" +"Text content" = "Содержимое текста" +"Text style" = "Текстовый стиль" +"Text styles" = "Текстовые стили" +"Texts" = "Тексты" +"Texture" = "Текстура" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "На холсте цвета отображаются в sRGB; экспортированные PNG, JPG и WebP помечены профилем Display P3." +"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." = "Файл удаляется с сервера: его больше нельзя будет открыть или подключиться к нему оттуда. Отменить это нельзя. Уже открытые копии остаются у участников." +"The file now holds another library" = "Теперь файл содержит другую библиотеку" +"The file now holds another library (\"{name}\")" = "Теперь файл содержит другую библиотеку («{name}»)" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "Файлы, которыми вы поделились или которые опубликовали онлайн с этого компьютера. Они остаются на сервере, пока вы их не удалите." +"The link carries the session's secret: share it only with people you invite." = "Ссылка содержит секрет сессии: передавайте её только приглашённым участникам." +"The name in your online files and on the server. The document inside keeps its own name." = "Имя в списке онлайн-файлов и на сервере. Документ внутри сохраняет собственное имя." +"The selection uses several text styles: click to apply one" = "В выделении несколько текстовых стилей: нажмите, чтобы применить один" +"The server deletes this file if nobody opens it before {date}." = "Сервер удалит файл, если никто не откроет его до {date}." +"The whole file" = "Весь файл" +"Theme" = "Тема" +"There's no destination for that action" = "Для этого действия не задано назначение" +"Thickness" = "Толщина" +"This branch can't be reviewed: {e}" = "Не удалось проверить ветку: {e}" +"This branch was merged or archived." = "Ветка объединена или архивирована." +"This computer only" = "Только этот компьютер" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Этот компьютер сохраняет токен владельца для управления файлом из списка «Онлайн» на главной. Скопируйте токен для управления с другого устройства: это пароль к файлу, он больше не будет показан." +"This condition is edited as an expression" = "Это условие редактируется как выражение" +"This file" = "Этот файл" +"This file has unsaved changes" = "В файле есть несохранённые изменения" +"This file is on {server}, open only to you. Share it to let others in with a new code." = "Файл находится на {server} и доступен только вам. Включите общий доступ, чтобы пригласить других по новому коду." +"This frame is new on the branch." = "Этот Frame добавлен в ветке." +"This frame isn't on the branch." = "Этого Frame нет в ветке." +"This server is open to the public: no key needed." = "Сервер общедоступен: ключ не нужен." +"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Сервер требует ключ: добавьте ?key=… к адресу (`figcraft-server key` выводит его на компьютере сервера)." +"This server takes files only with its key: see Advanced." = "Сервер принимает файлы только с ключом: см. «Дополнительно»." +"Tidy up" = "Упорядочить" +"Tile" = "Повторение" +"Tile type" = "Тип повторения" +"Tint" = "Оттенок" +"Title" = "Заголовок" +"Title case" = "Каждое Слово С Прописной" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Чтобы поделиться библиотекой с командой, опубликуйте файл на сервере («Поделиться» ▸ «Опубликовать на сервере…»), затем опубликуйте там библиотеку." +"Toggle" = "Переключить" +"Toggle light/dark theme" = "Переключить светлую/тёмную тему" +"Toggle visibility" = "Переключить видимость" +"Tools" = "Инструменты" +"Top" = "Сверху" +"Top & bottom" = "Сверху и снизу" +"Top left radius" = "Радиус верхнего левого угла" +"Top right radius" = "Радиус верхнего правого угла" +"Transform" = "Трансформация" +"Triangle arrow" = "Треугольная стрелка" +"Trigger" = "Триггер" +"True" = "Истина" +"Truncate text" = "Обрезать текст" +"Truncate with an ellipsis" = "Обрезать с многоточием" +"Try again" = "Повторить" +"Try another search or filter." = "Попробуйте другой запрос или фильтр." +"Try another word, or look at every kind." = "Попробуйте другое слово или посмотрите все категории." +"Turn a layer into a component, reuse it, and change every copy at once" = "Превратите слой в компонент, используйте повторно и изменяйте все копии сразу" +"Two digits" = "Две цифры" +"Type" = "Тип" +"Type settings" = "Настройки типографики" +"Type the join code you were given, or paste an invite link." = "Введите полученный код подключения или вставьте ссылку приглашения." +"Typography" = "Типографика" +"Underline" = "Подчёркивание" +"Undo takes back only your own changes; what others changed since stays." = "Отмена затрагивает только ваши изменения; последующие изменения других участников сохраняются." +"Ungroup" = "Разгруппировать" +"Uniform" = "Равномерный" +"Union" = "Объединение" +"Unions" = "Объединения" +"Unknown — treated as sRGB" = "Неизвестный — считается sRGB" +"Unread" = "Непрочитанные" +"Unresolve" = "Снова открыть" +"Unsaved changes" = "Несохранённые изменения" +"Unsaved changes in {names}. Close the window and lose them?" = "Несохранённые изменения в {names}. Закрыть окно и потерять их?" +"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Есть несохранённые изменения: сохраните ({save}) или закройте вкладку нажатием с {alt}, чтобы отбросить их" +"Unsaved changes: save first, or close again with {key} to discard" = "Есть несохранённые изменения: сохраните или закройте ещё раз с {key}, чтобы отбросить их" +"Up" = "Вверх" +"Update plugin" = "Обновить плагин" +"Update" = "Обновить" +"Update from main" = "Обновить из основной ветки" +"Update from selection" = "Обновить из выделения" +"Updated {components} and {styles}" = "Обновлено: {components} и {styles}" +"Updated {name}" = "Обновлено: {name}" +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Загружает файл на сервер FigCraft и синхронизирует эту вкладку с ним. Вы получите ссылки для редактирования и просмотра." +"Uppercase" = "Прописные" +"Use a fixed value" = "Использовать фиксированное значение" +"Use an expression, e.g. count + 1" = "Использовать выражение, например count + 1" +"Use as mask" = "Использовать как маску" +"Use auto layout" = "Использовать автолейаут" +"Use number keys for opacity" = "Задавать непрозрачность цифровыми клавишами" +"Use the layer's fill" = "Использовать заливку слоя" +"Used in {files} · {instances}" = "Используется в {files} · {instances}" +"Username" = "Имя пользователя" +"Value" = "Значение" +"Uses variables: each layer shows them in its own mode" = "Используются переменные: каждый слой отображает их в своём режиме" +"Variable" = "Переменная" +"Variable collections" = "Коллекции переменных" +"Variable modes" = "Режимы переменных" +"Variable width" = "Переменная ширина" +"Variables" = "Переменные" +"Variables JSON…" = "JSON переменных…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "Переменные хранят значения для повторного использования (цвета, числа, строки, логические значения) с режимами, например светлым и тёмным." +"Variant" = "Вариант" +"Vector" = "Вектор" +"Vectors" = "Векторы" +"Version" = "Версия" +"Version history" = "История версий" +"Version {version}" = "Версия {version}" +"Version {v} · {components} · {styles} · {variables}" = "Версия {v} · {components} · {styles} · {variables}" +"Vertical" = "Вертикально" +"Vertical align" = "Вертикальное выравнивание" +"Vertical hex" = "Вертикальный шестиугольник" +"Vertical padding" = "Вертикальные внутренние отступы" +"Vertical spacing" = "Вертикальный интервал" +"Vertical trim" = "Вертикальная обрезка" +"View" = "Вид" +"View link" = "Ссылка для просмотра" +"View link copied" = "Ссылка для просмотра скопирована" +"View only" = "Только просмотр" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Только просмотр: вы видите все изменения сразу, но не можете редактировать. Выйдите из сессии, чтобы сохранить редактируемую копию." +"Viewing “{name}”" = "Просмотр «{name}»" +"Violet" = "Фиолетовый" +"Visibility" = "Видимость" +"Visible" = "Видимый" +"Vivid" = "Яркий" +"Warm" = "Тёплый" +"Watch" = "Часы" +"Wavy" = "Волнистый" +"Web" = "Веб" +"Websites" = "Веб-сайты" +"WebSocket URL" = "URL WebSocket" +"WebSocket URL copied" = "URL WebSocket скопирован" +"Wedge" = "Клин" +"Weight" = "Толщина" +"What you try here changes this preview and the code below, not the file" = "Здесь меняются только предпросмотр и код ниже; файл остаётся без изменений" +"What changed since it was marked ready for dev" = "Изменения после отметки «Готово к разработке»" +"What is wrong with this file? The server's operator gets your report." = "Что не так с файлом? Оператор сервера получит вашу жалобу." +"What it's for" = "Назначение" +"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." = "Исходное состояние неизвестно: изменение произошло до того, как FigCraft начал сохранять копии Frame, готовых к разработке. Разрешите изменение (или снова отметьте готовность), чтобы сравнивать последующие изменения." +"While hovering" = "При наведении" +"While pressing" = "При удержании" +"White" = "Белый" +"Who runs it, its terms and how long it keeps files" = "Оператор, условия и срок хранения файлов" +"Why it changed" = "Причина изменения" +"Widgets" = "Виджеты" +"Width" = "Ширина" +"Width point" = "Точка ширины" +"Width profile" = "Профиль ширины" +"Wireframe kit" = "Набор вайрфреймов" +"With the layers' export settings" = "С настройками экспорта слоёв" +"Wrap" = "Перенос" +"Wrap in new section" = "Поместить в новую секцию" +"Yellow" = "Жёлтый" +"Yesterday" = "Вчера" +"You" = "Вы" +"You'll get the shared file as a new tab and edit it together." = "Общий файл откроется в новой вкладке для совместного редактирования." +"You're spotlighting: everyone follows your view" = "Вы показываете экран всем: участники следуют за вашим видом" +"Your copy of the file stays open" = "Ваша копия файла останется открытой" +"Your copy stays open; the file stays on the server" = "Ваша копия останется открытой; файл останется на сервере" +"Your name" = "Ваше имя" +"Your name in comments" = "Ваше имя в комментариях" +"Your system asks for reduced motion: animations stay off." = "В системе включено уменьшение движения: анимации отключены." +"Z" = "Z" +"Z to A" = "От Я до А" +"Zoom" = "Масштаб" +"Zoom around the pointer" = "Масштабировать вокруг указателя" +"Zoom device to fill window" = "Масштабировать устройство до заполнения окна" +"Zoom in" = "Увеличить" +"Zoom out" = "Уменьшить" +"Zoom to 100%" = "Масштаб 100%" +"Zoom to 200%" = "Масштаб 200%" +"Zoom to 50%" = "Масштаб 50%" +"Zoom to fit" = "Вписать всё" +"Zoom to selection" = "Масштаб по выделению" +"Zoom tool: click to zoom in, drag an area" = "Инструмент масштаба: нажмите для увеличения или выделите область" +"Zoom tool: zoom out" = "Инструмент масштаба: уменьшение" +"and {n} more" = { one = "и ещё {n}", few = "и ещё {n}", many = "и ещё {n}", other = "и ещё {n}" } +"bleed marks" = "метки вылета" +"by {author}" = "автор: {author}" +"call any command its access allows" = "вызывать любые команды в рамках разрешений" +"call only: {commands}" = "вызывать только: {commands}" +"can edit the document" = "может редактировать документ" +"crop marks" = "метки обреза" +"development: {path}" = "разработка: {path}" +"e.g. count + 1" = "например, count + 1" +"e.g. count >= 3 and not done" = "например, count >= 3 and not done" +"figcraft:// links from a browser, chat or email open this app and join the session." = "Ссылки figcraft:// из браузера, чата или почты открывают это приложение и подключают к сессии." +"from {server}" = "с {server}" +"hidden" = "скрыто" +"iOS" = "iOS" +"just now" = "только что" +"locked" = "заблокировано" +"no bleed" = "без вылета" +"nobody else here yet" = "здесь пока больше никого нет" +"now" = "сейчас" +"or" = "или" +"put a widget on the canvas" = "размещать виджет на холсте" +"read and edit the document" = "читать и редактировать документ" +"read the document" = "читать документ" +"reads the document" = "читает документ" +"the plugin" = "плагин" +"this session only" = "только эта сессия" +"yesterday" = "вчера" +"{instances} in {files}" = "{instances} в {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: черновой перевод, ожидает проверки носителем языка." +"{length} bleed" = "вылет {length}" +"{month} {day}, {year}" = "{day} {month} {year}" +"{name} cleared from the list" = "{name} убран из списка" +"{name} is forgotten on this computer; the file stays on {server}" = "{name} забыт на этом компьютере; файл остаётся на {server}" +"{name} is on the server." = "{name} находится на сервере." +"{name} is spotlighting" = "{name} показывает экран всем" +"{name}\nClick to apply to the selection" = "{name}\nНажмите, чтобы применить к выделению" +"{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}\nПеретащите на холст или нажмите, чтобы разместить в центре вида.\nУдерживайте Alt (Option) при переносе на экземпляр, чтобы заменить его." +"{name} (from {library})" = "{name} (из {library})" +"{name} (you)" = "{name} (вы)" +"{name} ({layers} layers)" = "{name} (слоёв: {layers})" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, по умолчанию {default}. Двойное нажатие на ползунок возвращает значение стиля; Escape при перетаскивании отменяет изменение." +"{name} from {path}" = "{name} из {path}" +"{name} was removed from the server" = "{name} удалён с сервера" +"{name} · deleted on main" = "{name} · удалено в основной ветке" +"{name} · deleted on the branch" = "{name} · удалено в ветке" +"{name} · not published" = "{name} · не опубликовано" +"{name} · on a server" = "{name} · на сервере" +"{name} · published version {v}" = "{name} · опубликована версия {v}" +"{name} · version {v} on the server" = "{name} · версия {v} на сервере" +"{name}, in a live session" = "{name}, в совместной сессии" +"{name}, online, open only to you" = "{name}, онлайн, доступен только вам" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} на {h}, выделено" +"{n} added" = { one = "Добавлено: {n}", few = "Добавлено: {n}", many = "Добавлено: {n}", other = "Добавлено: {n}" } +"{n} autosave versions" = { one = "{n} автосохранённая версия", few = "{n} автосохранённые версии", many = "{n} автосохранённых версий", other = "{n} автосохранённой версии" } +"{n} changed" = { one = "Изменено: {n}", few = "Изменено: {n}", many = "Изменено: {n}", other = "Изменено: {n}" } +"{n} columns" = { one = "{n} столбец", few = "{n} столбца", many = "{n} столбцов", other = "{n} столбца" } +"{n} components" = { one = "{n} компонент", few = "{n} компонента", many = "{n} компонентов", other = "{n} компонента" } +"{n} components and styles have newer versions" = { one = "{n} компонент или стиль имеет новую версию", few = "{n} компонента или стиля имеют новые версии", many = "{n} компонентов и стилей имеют новые версии", other = "{n} компонента или стиля имеют новые версии" } +"{n} conflicts to resolve" = { one = "{n} конфликт для разрешения", few = "{n} конфликта для разрешения", many = "{n} конфликтов для разрешения", other = "{n} конфликта для разрешения" } +"{n} days ago" = { one = "{n} день назад", few = "{n} дня назад", many = "{n} дней назад", other = "{n} дня назад" } +"{n} files" = { one = "{n} файл", few = "{n} файла", many = "{n} файлов", other = "{n} файла" } +"{n} hours ago" = { one = "{n} час назад", few = "{n} часа назад", many = "{n} часов назад", other = "{n} часа назад" } +"{n} instances" = { one = "{n} экземпляр", few = "{n} экземпляра", many = "{n} экземпляров", other = "{n} экземпляра" } +"{n} layers" = { one = "{n} слой", few = "{n} слоя", many = "{n} слоёв", other = "{n} слоя" } +"{n} layers as one file" = { one = "{n} слой в одном файле", few = "{n} слоя в одном файле", many = "{n} слоёв в одном файле", other = "{n} слоя в одном файле" } +"{n} layers selected" = { one = "Выделен {n} слой", few = "Выделены {n} слоя", many = "Выделено {n} слоёв", other = "Выделено {n} слоя" } +"{n} layers · {parts}" = { one = "{n} слой · {parts}", few = "{n} слоя · {parts}", many = "{n} слоёв · {parts}", other = "{n} слоя · {parts}" } +"{n} matches" = { one = "{n} совпадение", few = "{n} совпадения", many = "{n} совпадений", other = "{n} совпадения" } +"{n} minutes ago" = { one = "{n} минуту назад", few = "{n} минуты назад", many = "{n} минут назад", other = "{n} минуты назад" } +"{n} months ago" = { one = "{n} месяц назад", few = "{n} месяца назад", many = "{n} месяцев назад", other = "{n} месяца назад" } +"{n} of {total}" = "{n} из {total}" +"{n} people joined" = { one = "Подключился {n} участник", few = "Подключились {n} участника", many = "Подключились {n} участников", other = "Подключилось {n} участника" } +"{n} preferred components" = { one = "{n} предпочтительный компонент", few = "{n} предпочтительных компонента", many = "{n} предпочтительных компонентов", other = "{n} предпочтительного компонента" } +"{n} ready" = { one = "Готово: {n}", few = "Готово: {n}", many = "Готово: {n}", other = "Готово: {n}" } +"{n} removed" = { one = "Удалено: {n}", few = "Удалено: {n}", many = "Удалено: {n}", other = "Удалено: {n}" } +"{n} replies" = { one = "{n} ответ", few = "{n} ответа", many = "{n} ответов", other = "{n} ответа" } +"{n} rows" = { one = "{n} строка", few = "{n} строки", many = "{n} строк", other = "{n} строки" } +"{n} styles" = { one = "{n} стиль", few = "{n} стиля", many = "{n} стилей", other = "{n} стиля" } +"{n} variables" = { one = "{n} переменная", few = "{n} переменные", many = "{n} переменных", other = "{n} переменной" } +"{n} weeks ago" = { one = "{n} неделю назад", few = "{n} недели назад", many = "{n} недель назад", other = "{n} недели назад" } +"{n} years ago" = { one = "{n} год назад", few = "{n} года назад", many = "{n} лет назад", other = "{n} года назад" } +"{n}d" = "{n} д" +"{n}h" = "{n} ч" +"{n}m" = "{n} мин" +"{n}w" = "{n} нед" +"{page}, nothing selected" = "{page}, ничего не выделено" +"{profile} (current)" = "{profile} (текущий)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} — значение свойства; {property ? \"a\" : \"b\"} выбирает строку. Другие {…} остаются как есть." +"{server} can't say what became of your files (it is older than this app)." = "{server} не может сообщить, что стало с вашими файлами (версия сервера старее приложения)." +"{server} · No longer on the server" = "{server} · Больше нет на сервере" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · Открыто {date}" +"{tool} hold {key}" = "{tool} удерживайте {key}" +"{value} isn't a colour" = "{value} не является цветом" +"{when} · edited" = "{when} · изменено" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"←" = "←" +"→, Space or N" = "→, Пробел или N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ стрелки" +"⌘ click" = "⌘ нажатие" +"⌘ scroll" = "⌘ прокрутка" +"⌥ drag" = "⌥ перетаскивание" + +[commands] +"align.bottom" = "Выровнять по нижнему краю" +"align.hcenter" = "Выровнять центры по горизонтали" +"align.left" = "Выровнять по левому краю" +"align.right" = "Выровнять по правому краю" +"align.top" = "Выровнять по верхнему краю" +"align.vcenter" = "Выровнять центры по вертикали" +"annotation.add" = "Добавить аннотацию" +"annotation.addCategory" = "Добавить категорию аннотаций" +"annotation.categories" = "Категории аннотаций" +"annotation.edit" = "Изменить аннотацию" +"annotation.editCategory" = "Изменить категорию аннотаций" +"annotation.list" = "Список аннотаций" +"annotation.remove" = "Удалить аннотацию" +"annotation.removeCategory" = "Удалить категорию аннотаций" +"app.actions" = "Действия" +"app.preferences" = "Настройки" +"arrange.back" = "На задний план" +"arrange.backward" = "На уровень назад" +"arrange.forward" = "На уровень вперёд" +"arrange.front" = "На передний план" +"arrange.packH" = "Уплотнить по горизонтали" +"arrange.packV" = "Уплотнить по вертикали" +"arrange.roundToPixels" = "Округлить до пикселей" +"arrange.tidy" = "Упорядочить" +"bool.exclude" = "Исключить выделение" +"bool.flatten" = "Свести булеву группу" +"bool.intersect" = "Пересечь выделение" +"bool.subtract" = "Вычесть выделение" +"bool.union" = "Объединить выделение" +"branch.archive" = "Архивировать ветку" +"branch.create" = "Создать ветку" +"branch.list" = "Список веток" +"branch.merge" = "Объединить ветку" +"branch.open" = "Открыть ветку" +"branch.review" = "Проверить ветку" +"branch.updateFromMain" = "Обновить из основной ветки" +"codeConnect.export" = "Экспортировать связи с кодом" +"codeConnect.import" = "Импортировать связи с кодом" +"codeConnect.remove" = "Отвязать от кода" +"codeConnect.set" = "Связать с кодом" +"collab.cursor" = "Показать курсор в точке" +"collab.cursorChat" = "Чат у курсора" +"collab.diff" = "Показать изменения синхронизации" +"collab.files" = "Ваши онлайн-файлы" +"collab.follow" = "Следить" +"collab.forgetFile" = "Забыть онлайн-файл" +"collab.host" = "Начать совместную сессию" +"collab.join" = "Подключиться к совместной сессии" +"collab.leave" = "Покинуть совместную сессию" +"collab.manageFile" = "Управлять опубликованным файлом" +"collab.openFile" = "Открыть онлайн-файл" +"collab.peers" = "Участники в этом файле" +"collab.publish" = "Опубликовать на сервере" +"collab.recoverFile" = "Восстановить онлайн-файл" +"collab.registerLinks" = "Открывать ссылки приглашения в FigCraft" +"collab.report" = "Пожаловаться на файл оператору сервера" +"collab.serverInfo" = "Информация о сервере" +"collab.share" = "Поделиться по коду подключения" +"collab.spotlight" = "Показывать мой экран всем" +"collab.stop" = "Завершить совместную сессию" +"command.batch" = "Выполнить несколько команд" +"comment.add" = "Добавить комментарий" +"comment.delete" = "Удалить комментарий" +"comment.edit" = "Изменить комментарий" +"comment.link" = "Копировать ссылку на комментарий" +"comment.list" = "Список комментариев" +"comment.markRead" = "Отметить комментарий как прочитанный" +"comment.move" = "Переместить комментарий" +"comment.next" = "Следующий комментарий" +"comment.open" = "Открыть комментарий" +"comment.people" = "Участники комментариев" +"comment.react" = "Добавить реакцию на комментарий" +"comment.reply" = "Ответить на комментарий" +"comment.resolve" = "Отметить комментарий как решённый" +"comment.zoom" = "Масштаб по комментариям" +"component.addProperty" = "Добавить свойство компонента" +"component.addVariant" = "Добавить вариант" +"component.bindProperty" = "Привязать слой к свойству" +"component.combineVariants" = "Объединить в варианты" +"component.create" = "Создать компонент" +"component.createMultiple" = "Создать несколько компонентов" +"component.deleteProperty" = "Удалить свойство компонента" +"component.detach" = "Отсоединить экземпляр" +"component.editProperty" = "Изменить свойство компонента" +"component.exposeInstance" = "Открыть доступ к вложенному экземпляру" +"component.goToMain" = "Перейти к основному компоненту" +"component.hideWhenPublishing" = "Скрывать при публикации" +"component.list" = "Список компонентов" +"component.makeSlot" = "Сделать слотом" +"component.playground" = "Песочница компонента" +"component.properties" = "Свойства компонента" +"component.pushChanges" = "Перенести изменения в основной компонент" +"component.removeSlot" = "Убрать слот" +"component.resetInstance" = "Сбросить все изменения" +"component.resetOverride" = "Сбросить переопределение" +"component.restoreMain" = "Восстановить основной компонент" +"dev.compare" = "Сравнить изменения" +"dev.focus" = "Режим фокуса" +"dev.resolveChange" = "Разрешить изменение" +"dev.unfocus" = "Выйти из режима фокуса" +"devResource.add" = "Добавить ресурс для разработки" +"devResource.edit" = "Изменить ресурс для разработки" +"devResource.list" = "Список ресурсов для разработки" +"devResource.remove" = "Удалить ресурс для разработки" +"distribute.horizontal" = "Распределить по горизонтали" +"distribute.vertical" = "Распределить по вертикали" +"doc.home" = "Главная" +"doc.list" = "Список документов" +"doc.nextTab" = "Следующая вкладка" +"doc.previousTab" = "Предыдущая вкладка" +"doc.switch" = "Переключить документ" +"edit.copy" = "Копировать" +"edit.copyAsCss" = "Копировать как CSS" +"edit.copyAsPng" = "Копировать как PNG" +"edit.copyAsSvg" = "Копировать как SVG" +"edit.copyAsText" = "Копировать как текст" +"edit.copyLink" = "Копировать ссылку на выделение" +"edit.copyProperties" = "Копировать свойства" +"edit.cut" = "Вырезать" +"edit.delete" = "Удалить" +"edit.duplicate" = "Дублировать" +"edit.findReplace" = "Найти и заменить" +"edit.paste" = "Вставить" +"edit.pasteOver" = "Вставить поверх выделения" +"edit.pasteProperties" = "Вставить свойства" +"edit.pasteReplace" = "Вставить с заменой" +"edit.pickColor" = "Пипетка" +"edit.redo" = "Повторить" +"edit.repeat" = "Повторить последнюю команду" +"edit.setDefaultProperties" = "Задать свойства по умолчанию" +"edit.undo" = "Отменить" +"effect.add" = "Добавить эффект" +"effect.move" = "Переместить эффект" +"effect.remove" = "Удалить эффект" +"effect.set" = "Задать эффект" +"export.add" = "Добавить настройку экспорта" +"export.remove" = "Удалить настройку экспорта" +"export.update" = "Изменить настройку экспорта" +"file.close" = "Закрыть вкладку" +"file.colorProfile" = "Цветовой профиль" +"file.duplicate" = "Дублировать файл" +"file.export" = "Экспорт" +"file.exportFramesPdf" = "Экспортировать Frame в PDF" +"file.exportLayers" = "Экспортировать слои" +"file.exportSvg" = "Экспортировать выделение в SVG" +"file.import" = "Импорт" +"file.new" = "Новый файл дизайна" +"file.newFromTemplate" = "Создать из шаблона" +"file.open" = "Открыть" +"file.openRecent" = "Открыть недавние" +"file.placeImage" = "Разместить изображение" +"file.recover" = "Восстановить файл" +"file.rename" = "Переименовать файл" +"file.revert" = "Вернуть сохранённое состояние" +"file.save" = "Сохранить" +"file.saveAs" = "Сохранить как" +"file.saveVersion" = "Сохранить в историю версий" +"file.versionHistory" = "Показать историю версий" +"fill.add" = "Добавить заливку" +"fill.move" = "Переместить заливку" +"fill.pattern" = "Заливка паттерном" +"fill.remove" = "Удалить заливку" +"fill.set" = "Задать заливку" +"frame.create" = "Создать Frame" +"frame.presets" = "Список пресетов Frame" +"frame.swapOrientation" = "Сменить ориентацию" +"grid.add" = "Добавить сетку макета" +"grid.move" = "Переместить сетку макета" +"grid.remove" = "Удалить сетку макета" +"guide.add" = "Добавить направляющую" +"guide.clear" = "Удалить все направляющие" +"guide.move" = "Переместить направляющую" +"guide.remove" = "Удалить направляющую" +"guide.select" = "Выделить направляющую" +"help.gettingStarted" = "Начало работы" +"help.about" = "О FigCraft" +"help.page" = "Страница справки" +"help.shortcuts" = "Сочетания клавиш" +"image.adjust" = "Коррекция изображения" +"image.deletePreset" = "Удалить пресет изображения" +"image.place" = "Разместить изображение" +"image.presets" = "Пресеты изображения" +"image.savePreset" = "Сохранить пресет изображения" +"inspect.assets" = "Инспектор ресурсов" +"inspect.code" = "Код слоя" +"inspect.componentCode" = "Код компонента" +"inspect.properties" = "Инспектор свойств" +"inspect.readyForDev" = "Готово к разработке" +"instance.create" = "Создать экземпляр" +"instance.exposed" = "Доступные вложенные экземпляры" +"instance.place" = "Разместить экземпляр" +"instance.resetSlot" = "Сбросить слот" +"instance.setProperties" = "Задать свойства экземпляра" +"instance.slotInsert" = "Вставить в слот" +"instance.swap" = "Заменить экземпляр" +"instance.swapAt" = "Заменить экземпляр в точке" +"layers.collapseAll" = "Свернуть слои" +"layout.add" = "Добавить автолейаут" +"layout.remove" = "Убрать автолейаут" +"layout.setTrack" = "Задать размер дорожки сетки" +"layout.suggest" = "Предложить автолейаут" +"libraries.open" = "Библиотеки" +"libraries.publish" = "Опубликовать библиотеку…" +"library.acceptUpdates" = "Принять обновления библиотеки" +"library.add" = "Добавить библиотеку" +"library.aliasVariable" = "Создать алиас переменной библиотеки" +"library.applyStyle" = "Применить стиль библиотеки" +"library.bindVariable" = "Привязать переменную библиотеки" +"library.extendCollection" = "Расширить коллекцию библиотеки" +"library.import" = "Импортировать компонент библиотеки" +"library.importVariable" = "Импортировать переменную библиотеки" +"library.list" = "Список библиотек" +"library.place" = "Разместить компонент библиотеки" +"library.publish" = "Опубликовать библиотеку" +"library.publishToServer" = "Опубликовать библиотеку на сервере" +"library.remove" = "Удалить библиотеку" +"library.setVariableMode" = "Задать режим переменных библиотеки" +"library.swap" = "Заменить библиотеку" +"library.swapAt" = "Заменить экземпляр компонентом библиотеки" +"library.updates" = "Обновления библиотеки" +"library.usage" = "Использование библиотеки" +"measure.distances" = "Измерить расстояния" +"measure.padding" = "Измерить внутренние отступы автолейаута" +"node.create" = "Создать слой" +"node.get" = "Получить слой" +"node.reorder" = "Изменить порядок слоя" +"node.reparent" = "Переместить слой в" +"node.set" = "Задать свойства" +"object.batchRename" = "Массовое переименование" +"object.convertToFrame" = "Преобразовать во Frame" +"object.convertToGroup" = "Преобразовать в группу" +"object.flipH" = "Отразить по горизонтали" +"object.flipV" = "Отразить по вертикали" +"object.frameSelection" = "Поместить выделение во Frame" +"object.group" = "Сгруппировать выделение" +"object.hideOthers" = "Скрыть другие слои" +"object.mask" = "Использовать как маску" +"object.moveToPage" = "Переместить на страницу" +"object.opacityKeys" = "Непрозрачность цифровыми клавишами" +"object.rasterize" = "Растрировать выделение" +"object.releaseMask" = "Снять маску" +"object.removeFill" = "Удалить заливку" +"object.removeFrame" = "Убрать Frame" +"object.removeStroke" = "Удалить обводку" +"object.rename" = "Переименовать" +"object.resizeToFit" = "Подогнать размер" +"object.rotate180" = "Повернуть на 180°" +"object.rotateLeft" = "Повернуть на 90° влево" +"object.rotateRight" = "Повернуть на 90° вправо" +"object.setThumbnail" = "Использовать как миниатюру" +"object.swapFillStroke" = "Поменять заливку и обводку" +"object.toggleLock" = "Заблокировать/разблокировать выделение" +"object.toggleVisible" = "Показать/скрыть выделение" +"object.ungroup" = "Разгруппировать выделение" +"object.wrapInSection" = "Поместить в новую секцию" +"page.add" = "Добавить страницу" +"page.delete" = "Удалить страницу" +"page.duplicate" = "Дублировать страницу" +"page.list" = "Список страниц" +"page.move" = "Переместить страницу" +"page.rename" = "Переименовать страницу" +"page.select" = "Перейти к странице" +"page.setBackground" = "Фон страницы" +"paint.documentColors" = "Цвета документа" +"plugin.close" = "Закрыть панель плагина" +"plugin.directory.install" = "Установить плагин из каталога" +"plugin.directory.list" = "Обзор каталога плагинов" +"plugin.directory.publish" = "Опубликовать плагин в каталоге" +"plugin.directory.unpublish" = "Снять плагин с публикации в каталоге" +"plugin.event" = "Использовать элемент панели плагина" +"plugin.import" = "Импортировать плагин из манифеста…" +"plugin.list" = "Список плагинов" +"plugin.remove" = "Удалить плагин" +"plugin.run" = "Запустить плагин" +"plugin.ui" = "Панель плагина" +"plugins.manage" = "Управление плагинами…" +"plugins.runLast" = "Запустить последний плагин" +"pref.highlightHover" = "Подсвечивать слои при наведении" +"pref.keepTool" = "Оставлять инструмент выбранным после использования" +"pref.language" = "Язык интерфейса" +"pref.nudge" = "Шаг сдвига" +"pref.numberKeysOpacity" = "Задавать непрозрачность цифровыми клавишами" +"pref.publishServer" = "Сервер для онлайн-публикации" +"pref.reduceMotion" = "Уменьшить движение" +"pref.renameDuplicates" = "Переименовывать дубликаты слоёв" +"pref.showDimensions" = "Показать размеры объектов" +"pref.userName" = "Ваше имя в комментариях" +"proto.action.add" = "Добавить действие прототипа" +"proto.action.move" = "Изменить порядок действия прототипа" +"proto.action.remove" = "Удалить действие прототипа" +"proto.action.set" = "Изменить действие прототипа" +"proto.closePreview" = "Закрыть предпросмотр прототипа" +"proto.conditional.addBlock" = "Добавить «Иначе если» / «Иначе»" +"proto.conditional.removeBlock" = "Удалить «Если» / «Иначе если» / «Иначе»" +"proto.conditional.setCondition" = "Изменить условие" +"proto.evaluate" = "Вычислить выражение прототипа" +"proto.flow.add" = "Добавить начальную точку сценария" +"proto.flow.remove" = "Удалить сценарий" +"proto.flow.rename" = "Переименовать сценарий" +"proto.interaction.add" = "Добавить взаимодействие" +"proto.interaction.clear" = "Удалить все взаимодействия" +"proto.interaction.remove" = "Удалить взаимодействие" +"proto.interaction.set" = "Изменить взаимодействие" +"proto.present" = "Презентация" +"proto.presentOptions" = "Параметры презентации" +"proto.preview" = "Открыть предпросмотр прототипа" +"proto.settings" = "Настройки прототипа" +"section.toggleContents" = "Скрыть/показать содержимое секции" +"select.add" = "Добавить к выделению" +"select.all" = "Выделить всё" +"select.area" = "Выделить область" +"select.byName" = "Выделить слои по имени" +"select.children" = "Выделить дочерние слои" +"select.inverse" = "Инвертировать выделение" +"select.layersAt" = "Слои в точке" +"select.matching" = "Выделить соответствующие слои" +"select.next" = "Выделить следующий соседний слой" +"select.none" = "Снять выделение" +"select.parent" = "Выделить родителя" +"select.previous" = "Выделить предыдущий соседний слой" +"select.remove" = "Убрать из выделения" +"select.sameEffect" = "Выделить с одинаковым эффектом" +"select.sameFill" = "Выделить с одинаковой заливкой" +"select.sameFont" = "Выделить с одинаковым шрифтом" +"select.sameInstance" = "Выделить одинаковые экземпляры" +"select.sameProperties" = "Выделить с одинаковыми свойствами" +"select.sameStroke" = "Выделить с одинаковой обводкой" +"select.sameText" = "Выделить с одинаковыми свойствами текста" +"select.set" = "Выделить" +"shape.arrow" = "Стрелка" +"shape.ellipse" = "Эллипс" +"shape.line" = "Линия" +"shape.polygon" = "Многоугольник" +"shape.rectangle" = "Прямоугольник" +"shape.star" = "Звезда" +"shapeBuilder.merge" = "Объединить области" +"shapeBuilder.mode" = "Режим конструктора фигур" +"shapeBuilder.regions" = "Области конструктора фигур" +"shapeBuilder.remove" = "Удалить области" +"stroke.add" = "Добавить обводку" +"stroke.brush" = "Кисть" +"stroke.move" = "Переместить обводку" +"stroke.remove" = "Удалить обводку" +"stroke.set" = "Задать обводку" +"stroke.widthProfile" = "Профиль ширины" +"style.apply" = "Применить стиль" +"style.bind" = "Привязать переменную в стиле" +"style.create" = "Создать стиль" +"style.delete" = "Удалить стиль" +"style.detach" = "Отвязать стиль" +"style.duplicate" = "Дублировать стиль" +"style.list" = "Список стилей" +"style.move" = "Переместить стили" +"style.renameFolder" = "Переименовать папку стилей" +"style.update" = "Изменить стиль" +"text.alignCenter" = "Выровнять по центру" +"text.alignJustify" = "По ширине" +"text.alignLeft" = "Выровнять по левому краю" +"text.alignRight" = "Выровнять по правому краю" +"text.bold" = "Полужирный" +"text.bulletList" = "Маркированный список" +"text.case" = "Регистр букв" +"text.caseLower" = "Строчные" +"text.caseOriginal" = "Исходный регистр" +"text.caseSmallCaps" = "Капитель" +"text.caseTitle" = "Каждое Слово С Прописной" +"text.caseUpper" = "Прописные" +"text.create" = "Текст" +"text.decoration" = "Оформление" +"text.edit.begin" = "Редактировать текст" +"text.edit.caret" = "Установить текстовый курсор" +"text.edit.end" = "Завершить редактирование текста" +"text.edit.insert" = "Ввести текст" +"text.edit.key" = "Клавиша текста" +"text.edit.preedit" = "Составить текст (IME)" +"text.edit.selectLine" = "Выделить строку" +"text.edit.selectWord" = "Выделить слово" +"text.edit.selection" = "Выделение текста" +"text.endPreview" = "Завершить предпросмотр текста" +"text.fonts" = "Список шрифтов" +"text.italic" = "Курсив" +"text.lineHeightDown" = "Уменьшить межстрочный интервал" +"text.lineHeightUp" = "Увеличить межстрочный интервал" +"text.link" = "Создать ссылку" +"text.listIndent" = "Увеличить отступ списка" +"text.listOutdent" = "Уменьшить отступ списка" +"text.listStyle" = "Стиль списка" +"text.maxLines" = "Максимум строк" +"text.numberList" = "Нумерованный список" +"text.onPath" = "Текст на пути" +"text.paragraphIndent" = "Абзацный отступ" +"text.paragraphSpacing" = "Интервал между абзацами" +"text.pathOptions" = "Параметры текста на пути" +"text.preview" = "Предпросмотр изменения текста" +"text.removeFromPath" = "Снять текст с пути" +"text.set" = "Задать текст" +"text.setFeatures" = "Функции OpenType" +"text.setVariations" = "Оси вариативного шрифта" +"text.sizeDown" = "Уменьшить размер шрифта" +"text.sizeUp" = "Увеличить размер шрифта" +"text.strikethrough" = "Зачёркивание" +"text.style" = "Оформить диапазон текста" +"text.trackingDown" = "Уменьшить межбуквенный интервал" +"text.trackingUp" = "Увеличить межбуквенный интервал" +"text.underline" = "Подчёркивание" +"text.weightDown" = "Уменьшить толщину шрифта" +"text.weightUp" = "Увеличить толщину шрифта" +"tool.arrow" = "Стрелка" +"tool.comment" = "Комментарий" +"tool.ellipse" = "Эллипс" +"tool.frame" = "Frame" +"tool.hand" = "Рука" +"tool.line" = "Линия" +"tool.move" = "Перемещение" +"tool.pen" = "Перо" +"tool.pencil" = "Карандаш" +"tool.pencilBrush" = "Кисть карандаша" +"tool.place" = "Разместить выбранным инструментом" +"tool.polygon" = "Многоугольник" +"tool.rectangle" = "Прямоугольник" +"tool.scale" = "Масштаб" +"tool.scaleAnchor" = "Центр масштабирования" +"tool.section" = "Секция" +"tool.set" = "Выбрать инструмент" +"tool.shapeBuilder" = "Конструктор фигур" +"tool.slice" = "Срез" +"tool.star" = "Звезда" +"tool.text" = "Текст" +"transform.move" = "Перемещение" +"transform.moveTo" = "Переместить в" +"transform.nudge" = "Сдвинуть" +"transform.nudgeSize" = "Изменить размер на шаг сдвига" +"transform.resize" = "Изменить размер" +"transform.rotate" = "Повернуть" +"transform.rotationOrigin" = "Переместить центр вращения" +"transform.scale" = "Масштаб" +"transform.setRotationOrigin" = "Задать центр вращения" +"variable.addMode" = "Добавить режим" +"variable.bind" = "Привязать переменную" +"variable.create" = "Создать переменную" +"variable.createCollection" = "Создать коллекцию переменных" +"variable.delete" = "Удалить переменную" +"variable.deleteCollection" = "Удалить коллекцию" +"variable.duplicate" = "Дублировать переменную" +"variable.export" = "Экспортировать переменные" +"variable.import" = "Импортировать переменные" +"variable.list" = "Список переменных" +"variable.moveMode" = "Переместить режим" +"variable.removeMode" = "Удалить режим" +"variable.renameCollection" = "Переименовать коллекцию" +"variable.renameMode" = "Переименовать режим" +"variable.resetOverride" = "Сбросить переопределение переменной" +"variable.set" = "Задать значение переменной" +"variable.setMode" = "Задать режим переменных" +"variable.update" = "Изменить переменную" +"vector.create" = "Вектор" +"vector.deleteHeal" = "Удалить выделение и восстановить путь" +"vector.edit.addVertex" = "Добавить точку" +"vector.edit.begin" = "Редактировать вектор" +"vector.edit.bend" = "Изогнуть сегмент" +"vector.edit.bounds" = "Границы выделенных точек" +"vector.edit.connect" = "Соединить точки" +"vector.edit.cornerRadius" = "Радиус угла точки" +"vector.edit.cut" = "Разрезать путь" +"vector.edit.delete" = "Удалить точки" +"vector.edit.end" = "Завершить редактирование" +"vector.edit.erase" = "Стереть" +"vector.edit.lasso" = "Выделить лассо" +"vector.edit.mirroring" = "Зеркальность направляющих" +"vector.edit.moveHandles" = "Переместить направляющие" +"vector.edit.moveVertices" = "Переместить точки" +"vector.edit.nudge" = "Сдвинуть точки" +"vector.edit.paint" = "Заливка области" +"vector.edit.pen" = "Точка пера" +"vector.edit.penEnd" = "Завершить путь пера" +"vector.edit.position" = "Положение точки" +"vector.edit.resize" = "Изменить размер выделенных точек" +"vector.edit.rotate" = "Повернуть точки" +"vector.edit.select" = "Выделить точки" +"vector.edit.selectAll" = "Выделить все точки" +"vector.edit.setHandle" = "Задать направляющую" +"vector.edit.smooth" = "Сгладить или заострить точки" +"vector.edit.state" = "Состояние редактирования вектора" +"vector.edit.strokeCap" = "Окончание обводки в точке" +"vector.edit.strokeJoin" = "Соединение обводки в точке" +"vector.edit.tool" = "Инструмент редактирования вектора" +"vector.edit.transform" = "Трансформировать точки" +"vector.edit.widthPoint" = "Точка ширины" +"vector.flatten" = "Свести" +"vector.join" = "Соединить выделение" +"vector.offset" = "Сместить путь" +"vector.outlineStroke" = "Преобразовать обводку в контур" +"vector.reverse" = "Обратить направление пути" +"vector.simplify" = "Упростить вектор" +"vector.smoothJoin" = "Плавно соединить выделение" +"version.autosave" = "Автосохранить версию" +"version.delete" = "Удалить версию" +"version.duplicate" = "Дублировать версию" +"version.exitPreview" = "Вернуться к текущей версии" +"version.list" = "Список версий" +"version.preview" = "Просмотреть версию" +"version.rename" = "Переименовать версию" +"version.restore" = "Восстановить версию" +"view.assets" = "Панель ресурсов" +"view.comments" = "Комментарии" +"view.contextMenu" = "Показать контекстное меню" +"view.design" = "Панель дизайна" +"view.designPrototype" = "Переключить дизайн/прототип" +"view.devMode" = "Dev Mode" +"view.frameOutlines" = "Контуры Frame" +"view.get" = "Получить вид" +"view.layers" = "Панель слоёв" +"view.layoutGrids" = "Сетки макета" +"view.leftSidebar" = "Показать/скрыть левую панель" +"view.maskOutlines" = "Контуры масок" +"view.multiplayerCursors" = "Курсоры участников" +"view.nextFrame" = "Масштаб по следующему Frame" +"view.nextPage" = "Следующая страница" +"view.nextRegion" = "Фокус на следующей области" +"view.outlines" = "Контуры" +"view.outlinesHiddenLayers" = "Включить скрытые слои в контуры" +"view.outlinesObjectBounds" = "Включить границы объектов в контуры" +"view.outlinesSelection" = "Контуры выделения" +"view.pan" = "Переместить вид" +"view.panelWidths" = "Сбросить ширину панелей" +"view.pixelGrid" = "Пиксельная сетка" +"view.pixelPreview" = "Пиксельный предпросмотр" +"view.prevFrame" = "Масштаб по предыдущему Frame" +"view.prevPage" = "Предыдущая страница" +"view.previousRegion" = "Фокус на предыдущей области" +"view.proofSrgb" = "Экранная цветопроба sRGB" +"view.fullscreen" = "Полный экран" +"view.prototype" = "Панель прототипа" +"view.rulers" = "Линейки" +"view.set" = "Задать вид" +"view.slices" = "Срезы" +"view.snapGeometry" = "Привязка к геометрии" +"view.snapObjects" = "Привязка к объектам" +"view.snapPixel" = "Привязка к пиксельной сетке" +"view.themeDark" = "Тёмная тема" +"view.themeLight" = "Светлая тема" +"view.themeSystem" = "Системная тема" +"view.toggleUi" = "Показать/скрыть интерфейс" +"view.zoom100" = "Масштаб 100%" +"view.zoom200" = "Масштаб 200%" +"view.zoom50" = "Масштаб 50%" +"view.zoomAt" = "Инструмент масштаба" +"view.zoomFit" = "Вписать всё" +"view.zoomIn" = "Увеличить" +"view.zoomOut" = "Уменьшить" +"view.zoomSelection" = "Масштаб по выделению" +"widget.click" = "Нажать виджет" +"widget.insert" = "Вставить виджет" +"widget.list" = "Список виджетов" +"window.focus" = "Вывести окно на передний план" +"window.new" = "Новое окно" +"template.list" = "Список шаблонов" + +[menu] +"bool.exclude" = "Исключение" +"bool.flatten" = "Свести" +"bool.intersect" = "Пересечение" +"bool.subtract" = "Вычитание" +"bool.union" = "Объединение" +"file.duplicate" = "Дублировать" +"file.import" = "Импортировать из файла…" +"file.rename" = "Переименовать" +"libraries.open" = "Открыть библиотеки" +"select.sameEffect" = "Эффект" +"select.sameFill" = "Заливка" +"select.sameFont" = "Шрифт" +"select.sameInstance" = "Экземпляр" +"select.sameProperties" = "Свойства" +"select.sameStroke" = "Обводка" +"select.sameText" = "Свойства текста" +"text.alignCenter" = "Выровнять текст по центру" +"text.alignJustify" = "Выровнять текст по ширине" +"text.alignLeft" = "Выровнять текст по левому краю" +"text.alignRight" = "Выровнять текст по правому краю" +"view.assets" = "Ресурсы" +"view.design" = "Дизайн" +"view.layers" = "Слои" +"view.outlines" = "Показать контуры" +"view.outlinesHiddenLayers" = "Включить скрытые слои" +"view.outlinesObjectBounds" = "Включить границы объектов" +"view.outlinesSelection" = "Показать контуры выделения" +"view.prototype" = "Прототип" +"view.themeDark" = "Тёмная" +"view.themeLight" = "Светлая" +"view.themeSystem" = "Системная" diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 3b8e1b1a3..46ccd9097 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -13,8 +13,13 @@ use std::sync::atomic::{AtomicUsize, Ordering}; /// One catalog entry. pub enum Text { Plain(&'static str), - /// Plural forms: `one` (when the language has it) and `other`. - Plural(Option<&'static str>, &'static str), + /// Optional language-specific plural forms, with `other` as the fallback. + Plural { + one: Option<&'static str>, + few: Option<&'static str>, + many: Option<&'static str>, + other: &'static str, + }, } /// A language's catalog (generated from `locales/.toml`). @@ -104,7 +109,7 @@ fn lookup(lang: usize, key: &str) -> Option<&'static Text> { /// two translations gets a context after `|` ("Fill|sizing"); the English catalog maps it back. pub fn tr(key: &str) -> &str { match lookup(CURRENT.load(Ordering::Relaxed), key) { - Some(Text::Plain(s) | Text::Plural(_, s)) => s, + Some(Text::Plain(s) | Text::Plural { other: s, .. }) => s, None => key, } } @@ -123,26 +128,59 @@ pub fn trf(key: &str, args: &[(&str, &dyn Display)]) -> String { /// The plural form of `key` (its English "other" form) for count `n`, with `{n}` and `args` filled. pub fn trn(key: &str, n: usize, args: &[(&str, &dyn Display)]) -> String { let lang = CURRENT.load(Ordering::Relaxed); - let text = match lookup(lang, key) { - Some(Text::Plural(one, other)) => one.filter(|_| plural_one(lang, n)).unwrap_or(other), - Some(Text::Plain(s)) => s, - None => key, - }; + let text = counted(lang, key, n); let mut all: Vec<(&str, &dyn Display)> = vec![("n", &n)]; all.extend_from_slice(args); fill(text, &all) } +/// Pick a plural using the catalog that supplies the text: an English fallback keeps English's +/// rule, even when the selected language is Russian. +fn counted(lang: usize, key: &str, n: usize) -> &str { + chain(lang) + .find_map(|l| { + find(l.ui, key).map(|text| match text { + Text::Plain(s) => *s, + Text::Plural { one, few, many, other } => match plural(l.code, n) { + Plural::One => one.unwrap_or(other), + Plural::Few => few.unwrap_or(other), + Plural::Many => many.unwrap_or(other), + Plural::Other => other, + }, + }) + }) + .unwrap_or(key) +} + +#[derive(Debug, PartialEq, Eq)] +enum Plural { + One, + Few, + Many, + Other, +} + +/// CLDR integer rules. Existing one/other catalogs may omit the extra forms. +fn plural(code: &str, n: usize) -> Plural { + match code { + "ru" if n % 10 == 1 && n % 100 != 11 => Plural::One, + "ru" if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) => Plural::Few, + "ru" => Plural::Many, + "ja" | "ko" | "zh-Hans" => Plural::Other, + "fr" | "pt-BR" if n <= 1 => Plural::One, + _ if n == 1 => Plural::One, + _ => Plural::Other, + } +} + +// CLDR's million-count "many" in French, Spanish and Portuguese still reads their existing +// "other" wording. Only Russian currently supplies separate few/many forms. + /// Whether count `n` takes the "one" form: 0 and 1 in French and Brazilian Portuguese, never in /// Japanese, Korean and Chinese (no plural forms: always "other"), 1 elsewhere (CLDR's integer rules). -// shortcut: CLDR's "many" (French, Spanish and Portuguese millions) reads as "other" here; add it -// when a language needs a third form. +#[cfg(test)] fn plural_one(lang: usize, n: usize) -> bool { - match LANGS.get(lang).map(|l| l.code) { - Some("ja" | "ko" | "zh-Hans") => false, - Some("fr" | "pt-BR") => n <= 1, - _ => n == 1, - } + plural(LANGS.get(lang).map_or("en", |l| l.code), n) == Plural::One } /// Replace each `{name}` in `text` with its argument, in one pass (an argument's own braces stay). @@ -186,8 +224,8 @@ pub fn menu_item<'a>(id: &str, english: &'a str) -> Cow<'a, str> { fn label<'a>(menu: bool, id: &str, english: &'a str) -> Cow<'a, str> { let text = chain(CURRENT.load(Ordering::Relaxed)).find_map(|l| menu.then(|| find(l.menu, id)).flatten().or_else(|| find(l.commands, id))); match text { - Some(Text::Plain(s) | Text::Plural(_, s)) if english.ends_with('…') && !s.ends_with('…') => Cow::Owned(format!("{s}…")), - Some(Text::Plain(s) | Text::Plural(_, s)) => Cow::Borrowed(s), + Some(Text::Plain(s) | Text::Plural { other: s, .. }) if english.ends_with('…') && !s.ends_with('…') => Cow::Owned(format!("{s}…")), + Some(Text::Plain(s) | Text::Plural { other: s, .. }) => Cow::Borrowed(s), None => Cow::Borrowed(english), } } diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 768434e34..c8c73202a 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -6,7 +6,7 @@ use std::collections::BTreeSet; use super::*; /// Languages that must translate every UI string and command label. -const COMPLETE: &[&str] = &["es"]; +const COMPLETE: &[&str] = &["es", "ru"]; /// Keys looked up from data rather than written as `tr("…")` in the code: the main menu's /// submenu names (the engine's menu catalog) and labels of engine and document enums. @@ -216,7 +216,7 @@ fn placeholders(s: &str) -> BTreeSet<&str> { fn forms(t: &Text) -> Vec<&'static str> { match t { Text::Plain(s) => vec![s], - Text::Plural(one, other) => one.iter().copied().chain([*other]).collect(), + Text::Plural { one, few, many, other } => one.iter().chain(few).chain(many).copied().chain([*other]).collect(), } } @@ -265,10 +265,10 @@ fn catalogs_have_known_keys_and_matching_placeholders() { for (i, form) in forms(t).iter().enumerate() { let got = placeholders(form); // A "one" form may spell the count out ("una capa"). - let ok = if matches!(t, Text::Plural(Some(_), _)) && i == 0 { got.is_subset(&want) } else { got == want }; + let ok = if matches!(t, Text::Plural { one: Some(_), .. }) && i == 0 { got.is_subset(&want) } else { got == want }; assert!(ok, "{}: {k:?} has placeholders {got:?}, English has {want:?}", lang.code); } - assert_eq!(matches!(t, Text::Plural(..)), matches!(src, Text::Plural(..)), "{}: {k:?} plural or not as in English", lang.code); + assert_eq!(matches!(t, Text::Plural { .. }), matches!(src, Text::Plural { .. }), "{}: {k:?} plural or not as in English", lang.code); } for (id, t) in lang.commands.iter().chain(lang.menu) { assert!(ids.contains(id), "{}: unknown command id {id:?}", lang.code); @@ -369,7 +369,7 @@ fn japanese_korean_and_chinese_have_no_plural_forms() { for code in ["ja", "ko", "zh-Hans"] { let lang = LANGS.iter().position(|l| l.code == code).unwrap(); assert!(!plural_one(lang, 0) && !plural_one(lang, 1) && !plural_one(lang, 2), "{code}"); - let ones: Vec<_> = LANGS[lang].ui.iter().filter(|(_, t)| matches!(t, Text::Plural(Some(_), _))).map(|(k, _)| k).collect(); + let ones: Vec<_> = LANGS[lang].ui.iter().filter(|(_, t)| matches!(t, Text::Plural { one: Some(_), .. })).map(|(k, _)| k).collect(); assert!(ones.is_empty(), "{code}: plural entries give only `other`: {ones:?}"); } } @@ -397,6 +397,50 @@ fn the_key_scanner_reads_rust_literals() { assert_eq!(literals(src), vec!["A \"b\"", "C {n}", "DE"]); } +#[test] +fn russian_language_tags_and_integer_plurals() { + let ru = LANGS.iter().position(|l| l.code == "ru").unwrap(); + assert_eq!(LANGS[ru].name, "Русский"); + for tag in ["ru", "ru-RU", "ru_RU.UTF-8", "RU", "ru-BY", "ru-Cyrl-RU"] { + 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); + 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} слой")); + } + for n in [2, 3, 4, 22, 23, 24, 102, 104, 1_002] { + assert_eq!(plural("ru", n), Plural::Few, "{n}"); + assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слоя")); + } + for n in [0, 5, 10, 11, 12, 13, 14, 19, 20, 25, 100, 111, 112, 114, 1_000_000] { + assert_eq!(plural("ru", n), Plural::Many, "{n}"); + assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слоёв")); + } + for (key, text) in LANGS[ru].ui { + if let Text::Plural { one, few, many, .. } = text { + assert!(one.is_some() && few.is_some() && many.is_some(), "{key}: missing Russian plural forms"); + } + } +} + +#[test] +fn plural_fallback_uses_the_language_of_its_catalog() { + let la = LANGS.iter().position(|l| l.code == "es-419").unwrap(); + assert_eq!(counted(la, "{n} layers", 1), "{n} capa"); + assert_eq!(counted(la, "{n} layers", 21), "{n} capas"); + // Unsupported optional categories use other; existing one/other languages are unchanged. + for code in ["en", "es", "de"] { + for n in [0, 2, 11, 21, 1_000_000] { + assert_eq!(plural(code, n), Plural::Other, "{code}: {n}"); + } + assert_eq!(plural(code, 1), Plural::One, "{code}"); + } + assert_eq!(counted(0, "{n} layers", 21), "{n} layers"); + assert_eq!(counted(0, "unknown {n}", 21), "unknown {n}"); +} + #[test] fn the_language_preference_takes_language_codes() { let mut s = figcraft_engine::Session::new(); diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 956709f45..118d2d6f2 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -111,7 +111,8 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) { ui.painter().rect_filled(ir, CornerRadius::same(6), t.hover); } icons::paint(ui.painter(), Rect::from_center_size(ir.center(), vec2(16.0, 16.0)), icon, if active { t.accent_text } else { t.icon }); - ui.painter().text(pos2(r.center().x, y + 38.0), Align2::CENTER_CENTER, label, regular(10.0), t.text); + let shown = crate::widgets::elide_font(ui, label, (r.width() - 6.0).max(0.0), regular(10.0)); + ui.painter().text(pos2(r.center().x, y + 38.0), Align2::CENTER_CENTER, shown, regular(10.0), t.text); if resp.clicked() { match tab { Some(tb) => { @@ -677,7 +678,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { ui.horizontal(|ui| { ui.add_space(16.0); let hint = if filter.is_empty() { tr("Add a frame (F) or a shape (R) to get started") } else { tr("No matching layers") }; - crate::widgets::label(ui, hint, t.text_secondary); + ui.add(egui::Label::new(egui::RichText::new(hint).font(regular(11.0)).color(t.text_secondary)).wrap()); }); } // Dragging near the list's top or bottom edge scrolls it, faster closer to the edge. diff --git a/docs/gaps.md b/docs/gaps.md index 34c4f4390..9a2153f88 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (`.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -18,7 +18,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [collaboration-parity](collaboration-parity.md) | | 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | | 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | -| 5 | Localization: English plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts | Localization | 29–51 | [localization-parity](localization-parity.md) | +| 5 | Localization: English, reviewed Russian, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts | Localization | 29–51 | [localization-parity](localization-parity.md) | | 6 | Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 8–26 | [target-app-parity](target-app-parity.md) | | 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | | 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | @@ -142,7 +142,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Impact:** Figma users notice the feel within minutes; it decides whether they stay. - **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md). -### 5. Localization: English plus unreviewed drafts +### 5. Localization: English, reviewed Russian, plus unreviewed drafts - **Missing:** native reviews of the Spanish, French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts and the Latin American Spanish overlay, the 25 newest strings in five drafts, Hindi, Arabic, Indonesian and Vietnamese, localized engine messages and number @@ -152,8 +152,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe Preferences ▸ Language, `pref.language`; `en.toml` is complete, and `es.toml`, `fr.toml`, `de.toml`, `pt-BR.toml`, `ja.toml`, `ko.toml` and `zh-Hans.toml` (2026-10-12) are machine drafts (all but `zh-Hans.toml` lack the 25 newest strings), with `es-419.toml` a Latin American overlay - listing only what differs from Spanish (only Spanish is held to completeness by the check; the - others show English for strings added later). + listing only what differs from Spanish (Spanish and Russian are held to completeness by the check; the + others show English for strings added later). Russian adds all 2,165 current entries, + reviewed and approved by VanTan, with one/few/many integer plurals. CJK UI text uses an installed CJK face the desktop app loads when something on screen needs it (`crates/ui-egui/src/cjk.rs`). Engine error messages and Dev Mode values stay English. - **Impact:** non-English designers get machine drafts nobody has reviewed yet, and English @@ -394,6 +395,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Hours and the ranking unchanged | | 2026-10-12 | minor | Gap 2: your online files landed (the app records the files it put on a server with their owner tokens, Home ▸ Online lists them and opens them again as their owner, with sharing, rename, removal and forget from the list; one request per server tells which are still there; tabs mark files in a live session). What is left is unchanged: accounts, teams and per-person permissions, shared comments and versions. Hours unchanged | | 2026-10-12 | minor | Gap 2: sharing with a join code (Share ▸ Online or Local network, one Start sharing button, a short code to give others, one join field for a code or a link; the server names links with codes and limits wrong ones) added to the landed list; usability, so the estimate and ranking are unchanged | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 99f0f1a70..04fc1b4f4 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,10 +1,10 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (reviewed Russian catalog and integer plurals; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog and Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese -drafts (machine-drafted, waiting for native speakers' review). This page lists the twelve key +drafts (machine-drafted, waiting for native speakers' review), plus Russian reviewed and approved by VanTan. This page lists the twelve key languages of the craftrules standard, then the other languages Figma ships. How to add a language: [`translating.md`](translating.md). @@ -17,19 +17,19 @@ browser's page translation. The `.lproj` folders in `/Applications/Figma.app` (6 Electron/Chromium locale packs, not Figma's own UI languages. Text *content* in any script is supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no vertical text. -## What FigCraft has (measured 2026-10-12, from source) +## What FigCraft has (measured 2026-10-14, from source) - **Layer:** one TOML catalog per language in `crates/ui-egui/locales/`, baked into the binary by the UI crate's build script (desktop and web; nothing read at run time). Keys are the English text - (`tr("Fill")`, `trf` for `{named}` placeholders, `trn` for counts with one/other plural forms), so + (`tr("Fill")`, `trf` for `{named}` placeholders, `trn` for counts with one/few/many/other plural forms), so a missing translation shows English, never a raw key. One English text that needs two translations takes a context (`"Fill|sizing"`). -- **Strings:** 1,417 UI keys in `en.toml` (36 with plural forms; ~110 are labels from engine and - document data, such as tool names, blend modes and Dev Mode properties) plus 536 command labels - and 29 menu wordings by command id: 1,982 entries. Command ids, the CLI, MCP and the control +- **Strings:** 1,577 UI keys in `en.toml` (38 with plural forms; ~110 are labels from engine and + document data, such as tool names, blend modes and Dev Mode properties) plus 557 command labels + and 31 menu wordings by command id: 2,165 entries. Command ids, the CLI, MCP and the control channel stay English. Accessibility names come from the same strings. - **Language preference:** Preferences ▸ Language (System, English, Deutsch, Español, Español - (Latinoamérica), Français, 日本語, 한국어, Português (Brasil), 简体中文), applied at once without a + (Latinoamérica), Français, 日本語, 한국어, Português (Brasil), Русский, 简体中文), applied at once without a restart; System takes the first of the OS's preferred languages that has a catalog (Windows' language list, macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional tag falls back to its language, pt-PT or pt to pt-BR, and es-419, es-MX, es-AR, es-CO and the other @@ -42,13 +42,13 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v overlay, then the base language, then English. - **Check:** `cargo test -p figcraft-ui-egui i18n` fails on keys the code uses that `en.toml` lacks, keys no code uses, unknown command ids, placeholders that differ from English, overlay - entries that read as in their base language and, for complete languages (Spanish), anything + entries that read as in their base language and, for complete languages (Spanish and Russian), anything untranslated. The other drafts are not held to it: a string added later shows English there until someone translates it. Every draft translates every string as of 2026-10-12 (checked by adding each to `COMPLETE` locally and running the check). - **Not yet localized:** messages the engine writes (errors, a few toasts, Dev Mode property values - such as "inside"), number formatting (decimal points stay `.`), and plural forms beyond one/other - (French and Brazilian Portuguese take the singular for 0 as well; Japanese, Korean and Chinese + such as "inside"), number formatting (decimal points stay `.`), and additional plural rules for languages other than Russian + (Russian has one/few/many integer forms; French and Brazilian Portuguese take the singular for 0 as well; Japanese, Korean and Chinese have one form; CLDR's "many" for millions reads as the plural). - **Script support in documents:** Unicode bidi with Arabic shaping and mirrored brackets in text layers; right-to-left list markers and indents; right-to-left names in the layers panel, tabs @@ -91,7 +91,13 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Korean | ko | 2,076 / 2,076 (100%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics | no | none | 3–5 | -Other languages: none shipped (Figma ships none beyond the eight above). +Russian (`ru`): all 2,165 entries translated, including dialogs, tooltips, commands and menu wording; +wording reviewed and approved by VanTan. The picker shows Русский, and `ru-RU`, `ru-BY` and +`ru_RU.UTF-8` use it. Russian uses egui's bundled Cyrillic glyphs; no font asset was added. +The catalog keeps Frame, Dev Mode and Smart animate untranslated. Existing languages keep their +one/other behavior; Russian counts use one/few/many. Other languages beyond these: none shipped. +The navigation rail elides long labels with the full wording in its tooltip; the empty-layer hint +wraps to the panel width. Both were checked in Russian in native macOS screenshots. The layer is done. Per-language hours above are agent hours for a catalog of ~1,800 entries (a machine draft, then a pass over the panels for overflow), plus each language's plural rule where @@ -107,6 +113,7 @@ Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; its users | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Russian catalog: 1,577 UI keys (38 with plural forms), 557 command labels and 31 menu wordings (2,165 entries), reviewed by VanTan. Русский in Preferences; ru tags resolve to it. Catalog generation supports optional few/many forms; Russian integer rules distinguish 1/21, 2–4/22–24 and 0/5–20, including 11–14. The catalog supplying a fallback selects its own plural rule. Spanish and Russian are checked for completeness. Estimates unchanged. | | 2026-10-12 | minor | Drafts catch up again, and the counts with them: French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese translate the 71 UI strings and 5 command labels they lacked since sharing with a join code and open hosting (the Share popover, join codes, owner tokens, reports, the .fig export notes), and with Spanish the 26 strings and 3 command labels of your online files (Home ▸ Online, its menu and dialogs, the marks on tabs); machine-drafted in each file's conventions, still drafts. The Latin American overlay adds computadora and agrega where the new Spanish says ordenador and añade (98 entries). 2,076 entries per catalog (1,503 UI strings, 544 command labels, 29 menu wordings) | | 2026-10-12 | minor | Draft catalogs catch up: French, German, Brazilian Portuguese, Japanese and Korean translate the 66 UI strings and 17 command labels added since they were drafted (cursor chat and spotlight, the plugin directory, Rename layers, presentation and snapping keys, list levels, the rotation origin, placing with Enter…), Simplified Chinese the 46 and 12 it lacked; machine-drafted from the English in each file's conventions, still drafts (not in `COMPLETE`). The Latin American overlay adds Control for a gamepad controller and presiona in the plugin directory hint (90 entries, with Enter for the keycap). String counts re-measured: 1,417 UI keys, 536 command labels and 29 menu wordings (1,982 entries), every draft at 100%. Localization grade unchanged | | 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h | diff --git a/docs/mcp.md b/docs/mcp.md index 2b66657c2..6731090c3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -349,7 +349,7 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: nested layers), Escape or a click drops it. - `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's accessibility setting turns it on too). Prototype transitions still play. -- `pref.language {language: system|en|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system": +- `pref.language {language: system|en|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system": the OS or browser language; a language without a catalog shows English). Command ids, labels and every result stay English. diff --git a/docs/roadmap.md b/docs/roadmap.md index 8e2780d68..ad1ae844f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (reviewed Russian catalog and integer plurals; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -17,7 +17,7 @@ are one Opus 5.5 agent; most milestones split across 4–6 agents on disjoint cr 40–60 h, parallel by tool. 4. **Languages** on the landed localization layer: native reviews of the es, fr, de, pt-BR, ja, ko and zh-Hans drafts and the es-419 overlay (people), then the other key languages: 3–7 h per language - plus native review. + plus native review. Russian's 2,165-entry catalog was reviewed and approved by VanTan. 5. **Multiplayer M9**: accounts and teams on top of the live sessions and the standalone server (the WebSocket transport, hosting from the app, invite links that open the app, document sync, live cursors, selections, text carets, avatars and follow, per-user undo, reconnect with offline edits, collision-free ids for styles, variables, collections and comments, joining from the browser, and `figcraft-server` with persistence, publishing from the app and share links to edit or view landed), per [`collab-protocol.md`](collab-protocol.md) and [`server.md`](server.md): 45–90 h. ## Alpha gate @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c34d59169..e15ddaed6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -129,7 +129,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) | | Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) | | Hardware | 3 | 60% | 10–21 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); several windows (screen-reader tree in the main one only); no GPU canvas, no P3 on screen | [hardware-parity](hardware-parity.md) | -| Localization | 3 | 39% | 29–51 | The layer, an English catalog and machine drafts of Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese (none reviewed by a native speaker; every draft covers all current strings), CJK UI text in installed faces loaded on demand (web: only the craft-fonts CJK build); engine messages stay English | [localization-parity](localization-parity.md) | +| Localization | 3 | 39% | 29–51 | The layer, English, Russian reviewed by VanTan, and machine drafts of Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese (none reviewed by a native speaker; every draft covers all current strings), CJK UI text in installed faces loaded on demand (web: only the craft-fonts CJK build); engine messages stay English | [localization-parity](localization-parity.md) | | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | | Ecosystem/plugins | 3 | 38% | 8–26 | Own WASM plugins with a Rust SDK and three examples (#101), plugin panels and settings (ABI v2), widgets that take clicks (API 3), a self-hosted plugin directory on `figcraft-server`; no Figma plugin compatibility, no public catalogue or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | | AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | @@ -628,6 +628,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, spacing by octave folding fitted to measurements at several zoom levels, fixed to the shape); click, Shift-click and drag select and move areas with their points (drawn unselected); a click on an empty spot clears them and stays in edit mode (see [ui-parity](ui-parity.md)) | | 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. | | 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. | diff --git a/docs/translating.md b/docs/translating.md index b7c26a401..4a2431c7e 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -15,6 +15,8 @@ Simplified. 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`) and Simplified Chinese (`zh-Hans`), 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. ## The catalogs @@ -59,12 +61,15 @@ language also needs its script in `cjk::language`. 2. Translate every value; keep the keys. Keep `{placeholders}` exactly, "…" where English has it, brand names and keyboard glyphs. English labels of commands: `figcraft-cli commands`. 3. Plurals: give `one` only if the language has a singular form (Japanese, Korean and Chinese give - only `other`, and `plural_one` returns false for them). `trn` picks `one` for a count of 1, and - also for 0 in French and Brazilian Portuguese (keep `{n}` in their `one` forms); a language with other rules needs its rule in - `plural_one` in [`crates/ui-egui/src/i18n.rs`](../crates/ui-egui/src/i18n.rs). + only `other`). `other` is required; `one`, `few` and `many` are optional. `trn` picks `one` for + 1 (also 0 in French and Brazilian Portuguese). Russian uses `one` for 1, 21, 31… except 11; + `few` for 2–4, 22–24… except 12–14; and `many` for the other integers, including 0. Keep `{n}` + in all Russian forms. Add new rules to `plural` in + [`crates/ui-egui/src/i18n.rs`](../crates/ui-egui/src/i18n.rs). A missing form reads `other`; + a missing key uses the fallback catalog's own plural rule. 4. To require full coverage, add the code to `COMPLETE` in [`crates/ui-egui/src/i18n_tests.rs`](../crates/ui-egui/src/i18n_tests.rs). Every later PR that - adds a string then has to translate it into each complete language, so only Spanish is there: + adds a string then has to translate it into each complete language: Spanish and Russian are there. French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese are registered as drafts, and a string added later shows English in them until it is translated. To list what a draft lacks, add its code to `COMPLETE` locally and run the check. @@ -86,7 +91,7 @@ Wrap every string a user sees or a screen reader hears: `tr` for plain text, `tr placeholders, `trn` for counts, `tr_noop` for literals in `const` tables that are looked up later with `tr(label)`. The key must be a string literal as the first argument (the check reads the source). Don't wrap ids, command ids, JSON, log messages, file extensions or document content. Then -add the key to `en.toml` and to each complete language (Spanish); the drafts may follow later. +add the key to `en.toml` and to each complete language (Spanish and Russian); the drafts may follow later. Labels from the engine and document crates (tool names, blend modes, easing curves…) are looked up at display with `tr(x.label())`; they are listed in `dynamic_labels` in `i18n_tests.rs`. From 06850b89009aaa8f121710a162bd9808961ddf38 Mon Sep 17 00:00:00 2001 From: Yaman Devrim Date: Wed, 14 Oct 2026 10:34:10 +0300 Subject: [PATCH 004/120] M9: Turkish draft catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crates/ui-egui/locales/tr.toml translates every UI string (1,577 keys), command label (557) and menu wording (31), machine-drafted and marked "draft — needs native review". Buttons and menu items use bare imperatives (Kaydet, Sil), instructions the polite -in/-ın (seçin, tıklayın); the percent sign goes before the number (%50). Turkish keeps a noun singular after a count, so plural entries give only `other`. Preferences ▸ Language lists Türkçe; System maps tr and tr-TR to it. The catalog passes the i18n check even when listed as complete. docs/translating.md, docs/localization-parity.md and ROADMAP.md list the new draft. Adds my own contributors/people.toml entry. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 10 +- contributors/people.toml | 4 + crates/ui-egui/locales/tr.toml | 2182 ++++++++++++++++++++++++++++++ crates/ui-egui/src/cjk.rs | 2 +- crates/ui-egui/src/i18n_tests.rs | 1 + docs/localization-parity.md | 8 +- docs/translating.md | 12 +- 7 files changed, 2205 insertions(+), 14 deletions(-) create mode 100644 crates/ui-egui/locales/tr.toml diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29b..ee06645a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Turkish draft catalog; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -63,7 +63,7 @@ native speakers' reviews of the six language drafts need people. | Stability | 55% | 20–40 | [gaps](docs/gaps.md) | | Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | | Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | -| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | +| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Turkish drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | | Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | | Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) | | AI features | 0% | 30–60 | [gaps](docs/gaps.md) | @@ -110,10 +110,11 @@ native speakers' reviews of the six language drafts need people. | German | de | 100% | draft (needs native review) | | Korean | ko | 100% | draft (needs native review) | | Vietnamese | vi | 0% | none | +| Turkish | tr | 100% | draft (needs native review) | No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), -French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸ +French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Turkish drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI text uses an installed CJK font, loaded only when needed; adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, @@ -126,7 +127,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). 1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h. 2. Real-file corpus and measured fidelity: 15–30 h. 3. Tool-by-tool interaction pass against Figma: 40–60 h. -4. Languages: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h). +4. Languages: native reviews of the es, fr, de, pt-BR, ja, ko and tr drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h). 5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h. ## Progress log @@ -312,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Turkish draft catalog: `tr.toml` translates every UI string (1,577 keys; Turkish keeps a noun singular after a count, so plurals give only `other`), command label (557) and menu wording (31), machine-drafted (bare imperatives on buttons, polite -in/-ın in instructions, %50) and marked as a draft awaiting native review; Preferences ▸ Language lists Türkçe and System maps tr and tr-TR to it. Numbers unchanged | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/contributors/people.toml b/contributors/people.toml index e61cfd31a..6072471b1 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -16,3 +16,7 @@ # # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. + +[people.yyamandevrim] +real_name = "Yaman Devrim" +sync_github_name = true diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml new file mode 100644 index 000000000..77db84098 --- /dev/null +++ b/crates/ui-egui/locales/tr.toml @@ -0,0 +1,2182 @@ +# FigCraft interface strings: Turkish. +# +# Draft — needs native review: machine-drafted from the English catalog. Brand names and keyboard +# glyphs stay as they are. Buttons and menu items are bare imperatives (Kaydet, Sil); instructions +# address the user politely (-in/-ın: "seçin", "tıklayın"). The percent sign goes before the number +# (%50). Turkish nouns stay singular after a count, so plural entries give only `other`. See +# docs/translating.md. + +[meta] +name = "Türkçe" +status = "draft — needs native review" + +[ui] +"% of all commits" = "Tüm commit'lerin %'si" +"(no longer published)" = "(artık yayımlanmıyor)" +"({n} warnings: some entries were skipped)" = { other = "({n} uyarı: bazı girdiler atlandı)" } +"+ Create variable" = "+ Değişken oluştur" +".fig (experimental)" = ".fig (deneysel)" +"1…9, 0" = "1…9, 0" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commit, +{added} / −{deleted} satır (Δ {delta}), +{bin_added} / −{bin_deleted} ikili varlık, {first} – {last}" +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Bu dosyanın bu modları ve değişkenleri içeren bir koleksiyonu: değerlerini mod başına geçersiz kılın ve kendi değişkenlerinizi ekleyin" +"A responsive product page: hero, features, figures, a quote and a footer" = "Duyarlı bir ürün sayfası: giriş bölümü, özellikler, rakamlar, bir alıntı ve bir alt bilgi" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "Yay, durulana kadar oynar: kütlesi, sertliği ve sönümü ne kadar süreceğini belirler" +"A title, an agenda, a big figure, a chart and a closing slide" = "Bir başlık, bir gündem, büyük bir rakam, bir grafik ve bir kapanış slaydı" +"A to Z" = "A'dan Z'ye" +"About" = "Hakkında" +"About FigCraft" = "FigCraft hakkında" +"About this server" = "Bu sunucu hakkında" +"Action" = "Eylem" +"Actions" = "Eylemler" +"Actions for {name}" = "{name} için eylemler" +"Actions…" = "Eylemler…" +"Active" = "Etkin" +"Actual size (100%)" = "Gerçek boyut (%100)" +"Add a category" = "Kategori ekle" +"Add a comment" = "Yorum ekle" +"Add a description" = "Açıklama ekle" +"Add a frame (F) or a shape (R) to get started" = "Başlamak için bir çerçeve (F) veya bir şekil (R) ekleyin" +"Add a layer or component to tile first" = "Önce döşenecek bir katman veya bileşen ekleyin" +"Add a note for developers" = "Geliştiriciler için not ekle" +"Add action" = "Eylem ekle" +"Add actions for when no condition holds" = "Hiçbir koşul sağlanmadığında yapılacak eylemleri ekleyin" +"Add an annotation for developers" = "Geliştiriciler için ek açıklama ekle" +"Add another condition" = "Başka bir koşul ekle" +"Add auto layout" = "Otomatik yerleşim ekle" +"Add background behind overlay" = "Bindirmenin arkasına arka plan ekle" +"Add content to this slot" = "Bu yuvaya içerik ekle" +"Add dev resource" = "Geliştirici kaynağı ekle" +"Add effect" = "Efekt ekle" +"Add export setting" = "Dışa aktarma ayarı ekle" +"Add fill" = "Dolgu ekle" +"Add interaction" = "Etkileşim ekle" +"Add layout grid" = "Yerleşim ızgarası ekle" +"Add library" = "Kitaplık ekle" +"Add library file…" = "Kitaplık dosyası ekle…" +"Add max height" = "En büyük yükseklik ekle" +"Add max width" = "En büyük genişlik ekle" +"Add min height" = "En küçük yükseklik ekle" +"Add min width" = "En küçük genişlik ekle" +"Add mode" = "Mod ekle" +"Add new page" = "Yeni sayfa ekle" +"Add or update collections from a variables JSON" = "Bir değişken JSON'undan koleksiyon ekleyin veya güncelleyin" +"Add properties to make instances configurable" = "Örnekleri yapılandırılabilir kılmak için özellik ekleyin" +"Add reaction" = "Tepki ekle" +"Add starting point" = "Başlangıç noktası ekle" +"Add stroke" = "Kontur ekle" +"Add variant" = "Varyant ekle" +"Add variant with a new value" = "Yeni değerle varyant ekle" +"Added" = "Eklendi" +"Added the library \"{name}\"" = "“{name}” kitaplığı eklendi" +"Adjust" = "Ayarla" +"Adjustment presets" = "Ayar ön ayarları" +"Advanced" = "Gelişmiş" +"Advanced stroke" = "Gelişmiş kontur" +"After delay" = "Gecikmeden sonra" +"After delay {ms}ms" = "{ms} ms gecikmeden sonra" +"Align" = "Hizala" +"Align bottom" = "Alta hizala" +"Align center" = "Ortaya hizala" +"Align horizontal centers" = "Yatay merkezleri hizala" +"Align left" = "Sola hizala" +"Align middle" = "Dikey ortaya hizala" +"Align right" = "Sağa hizala" +"Align text baselines" = "Metin taban çizgilerini hizala" +"Align top" = "Üste hizala" +"Align vertical centers" = "Dikey merkezleri hizala" +"Alignment" = "Hizalama" +"All" = "Tümü" +"All categories" = "Tüm kategoriler" +"All comments" = "Tüm yorumlar" +"All components" = "Tüm bileşenler" +"All fonts" = "Tüm yazı tipleri" +"All supported properties" = "Desteklenen tüm özellikler" +"All variables" = "Tüm değişkenler" +"Alpha" = "Alfa" +"Alphabetical" = "Alfabetik" +"Alt+drag" = "Alt+sürükle" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Izgara üzerinde bir analiz ekranı: rakamlar, vektörlerden çizilmiş grafikler ve bir tablo" +"An invite link that opens FigCraft and joins" = "FigCraft'ı açıp katılan bir davet bağlantısı" +"Anchor" = "Bağlantı noktası" +"Android" = "Android" +"Angular" = "Açısal" +"Animation" = "Animasyon" +"Annotations" = "Ek açıklamalar" +"Annotations shown on the canvas" = "Tuvalde gösterilen ek açıklamalar" +"Another server, or one that needs its key. The usual server is set in Preferences." = "Başka bir sunucu veya anahtar gerektiren bir sunucu. Olağan sunucu Tercihler'de ayarlanır." +"Any status" = "Herhangi bir durum" +"Anyone on your network with this code can edit the file, while this app stays open." = "Bu uygulama açık kaldığı sürece ağınızda bu koda sahip herkes dosyayı düzenleyebilir." +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Düzenleme bağlantısına sahip herkes dosyayı değiştirebilir; görüntüleme bağlantısı yalnızca bakmaya izin verir. Bağlantılar gizli anahtarlarını taşır: onları parola gibi paylaşın. `figcraft-server links` yenilerini oluşturur ve iptal eder." +"Anyone with this code can edit the file." = "Bu koda sahip herkes dosyayı düzenleyebilir." +"Anyone with this code can look at the file, not change it." = "Bu koda sahip herkes dosyaya bakabilir, ancak değiştiremez." +"Appearance" = "Görünüm" +"Apply a component property" = "Bir bileşen özelliği uygula" +"Apply variable" = "Değişken uygula" +"Apply variable to text" = "Metne değişken uygula" +"Apply variable to visibility" = "Görünürlüğe değişken uygula" +"Apps" = "Uygulamalar" +"Apr" = "Nis" +"Arc" = "Yay" +"Arc ratio" = "Yay oranı" +"Arc start" = "Yay başlangıcı" +"Arc sweep" = "Yay açıklığı" +"Archive" = "Arşivle" +"Archived" = "Arşivlendi" +"Arrange" = "Konumlandır" +"Arrow" = "Ok" +"As typed" = "Yazıldığı gibi" +"Asking the server…" = "Sunucuya soruluyor…" +"Assets" = "Varlıklar" +"Aug" = "Ağu" +"Auto" = "Otomatik" +"Auto (space around)" = "Otomatik (etrafında boşluk)" +"Auto (space between)" = "Otomatik (aralarında boşluk)" +"Auto (space evenly)" = "Otomatik (eşit boşluk)" +"Auto ({mode})" = "Otomatik ({mode})" +"Auto columns" = "Otomatik sütunlar" +"Auto gap" = "Otomatik aralık" +"Auto height" = "Otomatik yükseklik" +"Auto layout" = "Otomatik yerleşim" +"Auto rows" = "Otomatik satırlar" +"Auto width" = "Otomatik genişlik" +"Back to the FigCraft server" = "FigCraft sunucusuna dön" +"Back to the font's value" = "Yazı tipinin değerine dön" +"Back" = "Geri" +"Back to Ready for dev" = "Geliştirmeye hazır durumuna dön" +"Back to Ready for dev: the next change compares with how it is now" = "Geliştirmeye hazır durumuna dön: sonraki değişiklik şimdiki hâliyle karşılaştırılır" +"Back to assets" = "Varlıklara dön" +"Back to current" = "Geçerli sürüme dön" +"Back to styles" = "Stillere dön" +"Back to the canvas from a focused region" = "Odaklanılan bölgeden tuvale dön" +"Back to the current version" = "Geçerli sürüme dön" +"Back to the values the font style implies" = "Yazı tipi stilinin öngördüğü değerlere dön" +"Background" = "Arka plan" +"Background blur" = "Arka plan bulanıklığı" +"Basics" = "Temel bilgiler" +"Before" = "Önce" +"Bend" = "Bük" +"Bevel" = "Eğimli" +"Binary assets added" = "Eklenen ikili varlıklar" +"Binary assets removed" = "Kaldırılan ikili varlıklar" +"Black & white" = "Siyah beyaz" +"Bleed" = "Taşma payı" +"Bleed marks" = "Taşma payı işaretleri" +"Bleed marks need a bleed" = "Taşma payı işaretleri için taşma payı gerekir" +"Blend" = "Karışım" +"Blend mode" = "Karışım modu" +"Blue" = "Mavi" +"Blur" = "Bulanıklık" +"Boolean" = "Boolean" +"Boolean groups" = "Boolean grupları" +"Both directions" = "İki yönde" +"Bottom" = "Alt" +"Bottom left radius" = "Sol alt yarıçap" +"Bottom right radius" = "Sağ alt yarıçap" +"Branch" = "Dal" +"Branch archived" = "Dal arşivlendi" +"Branch merged" = "Dal birleştirildi" +"Branch name" = "Dal adı" +"Branch of {file}" = "{file} dalı" +"Branch of {file} · merged or archived" = "{file} dalı · birleştirildi veya arşivlendi" +"Branch updated from main" = "Dal ana dosyadan güncellendi" +"Branch {name}" = "Dal: {name}" +"Branches" = "Dallar" +"Bound to {name}. Click to detach the variable." = "{name} değişkenine bağlı. Değişkeni ayırmak için tıklayın." +"Bring forward" = "Öne getir" +"Bring the main file's changes into the branch" = "Ana dosyanın değişikliklerini dala getir" +"Bring to front" = "En öne getir" +"Bristle" = "Kıl" +"Browse" = "Gözat" +"Brush" = "Fırça" +"Brush rotation jitter" = "Fırça dönüş titreşimi" +"Brush scatter" = "Fırça saçılması" +"Brush size jitter" = "Fırça boyut titreşimi" +"Brush spacing" = "Fırça aralığı" +"Bulleted list" = "Madde işaretli liste" +"C or Shift+C" = "C veya Shift+C" +"C or ⇧C" = "C veya ⇧C" +"CMYK colour" = "CMYK rengi" +"Can view" = "Görüntüleyebilir" +"Checking the server…" = "Sunucu denetleniyor…" +"Clear from this list" = "Bu listeden kaldır" +"Code or invite link" = "Kod veya davet bağlantısı" +"Color profile: {name}" = "Renk profili: {name}" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Renk ve metin stilleri, Açık ve Koyu değişkenler, düğmeler, giriş alanları ve kartlar" +"CSS custom properties…" = "CSS özel özellikleri…" +"Can edit" = "Düzenleyebilir" +"Can't read this library: {e}" = "Bu kitaplık okunamıyor: {e}" +"Cancel" = "İptal" +"Canvas" = "Tuval" +"Canvas stacking" = "Tuval yığınlaması" +"Cap height to baseline" = "Büyük harf yüksekliğinden taban çizgisine" +"Case" = "Harf durumu" +"Case-sensitive forms" = "Büyük/küçük harfe duyarlı biçimler" +"Categories" = "Kategoriler" +"Center" = "Orta" +"Change alias" = "Takma adı değiştir" +"Change resolved" = "Değişiklik çözüldü" +"Change style" = "Stili değiştir" +"Change to" = "Şuna değiştir" +"Change to needs a layer inside a variant" = "“Şuna değiştir” için bir varyantın içinde katman gerekir" +"Changed" = "Değiştirildi" +"Changed on both sides: keep one" = "İki tarafta da değiştirildi: birini koruyun" +"Changes on main" = "Ana dosyadaki değişiklikler" +"Changes on the branch" = "Daldaki değişiklikler" +"Changes unknown" = "Değişiklikler bilinmiyor" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Bu bileşeni koduna bağlamak için yukarıdan React, Leptos, SwiftUI veya Compose seçin." +"Choose another profile to change it." = "Değiştirmek için başka bir profil seçin." +"Choose collection" = "Koleksiyon seç" +"Choose image" = "Görüntü seç" +"Choose mode" = "Mod seç" +"Choose variable" = "Değişken seç" +"Circle" = "Daire" +"Clear recent" = "Son kullanılanları temizle" +"Click + to replace mixed content" = "Karışık içeriği değiştirmek için + simgesine tıklayın" +"Click anywhere on the canvas to leave one." = "Yorum bırakmak için tuvalde herhangi bir yere tıklayın." +"Click or drag to merge · {alt} removes" = "Birleştirmek için tıklayın veya sürükleyin · {alt} kaldırır" +"Click or drag to remove · {alt} merges" = "Kaldırmak için tıklayın veya sürükleyin · {alt} birleştirir" +"Click to record a key" = "Tuş kaydetmek için tıklayın" +"Clip content" = "İçeriği kırp" +"Clip to shape" = "Şekle göre kırp" +"Close" = "Kapat" +"Close overlay" = "Bindirmeyi kapat" +"Close preview" = "Önizlemeyi kapat" +"Close tab" = "Sekmeyi kapat" +"Close version history" = "Sürüm geçmişini kapat" +"Close when clicking outside" = "Dışarı tıklanınca kapat" +"Close without saving" = "Kaydetmeden kapat" +"Code" = "Kod" +"Code syntax" = "Kod söz dizimi" +"Collapse" = "Daralt" +"Collapse panel" = "Paneli daralt" +"Collection" = "Koleksiyon" +"Collections" = "Koleksiyonlar" +"Color" = "Renk" +"Color burn" = "Renk yanması" +"Color dodge" = "Renk soldurma" +"Color format" = "Renk biçimi" +"Color profile" = "Renk profili" +"Color styles" = "Renk stilleri" +"Color values in this file are in this color space." = "Bu dosyadaki renk değerleri bu renk uzayındadır." +"Color variables" = "Renk değişkenleri" +"Colour" = "Renk" +"Column" = "Sütun" +"Column gap" = "Sütun aralığı" +"Column sizes" = "Sütun boyutları" +"Column span" = "Sütun yayılımı" +"Columns" = "Sütunlar" +"Columns {count}" = "Sütunlar {count}" +"Comment" = "Yorum" +"Comments" = "Yorumlar" +"Commits" = "Commit'ler" +"Company" = "Şirket" +"Compare changes" = "Değişiklikleri karşılaştır" +"Completed" = "Tamamlandı" +"Component" = "Bileşen" +"Component name" = "Bileşen adı" +"Component property" = "Bileşen özelliği" +"Component set" = "Bileşen kümesi" +"Component sets" = "Bileşen kümeleri" +"Components" = "Bileşenler" +"Conditional" = "Koşullu" +"Confetti" = "Konfeti" +"Conflicts" = "Çakışmalar" +"Connect to code" = "Koda bağla" +"Connected" = "Bağlandı" +"Connected to code: {from}" = "Koda bağlı: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "Bağlantı koptu, yeniden bağlanılıyor (deneme {attempt})…" +"Constrain proportions" = "Oranları koru" +"Constraints" = "Kısıtlamalar" +"Content" = "İçerik" +"Contents hidden" = "İçerik gizli" +"Contents only" = "Yalnızca içerik" +"Contextual alternates" = "Bağlamsal alternatifler" +"Contrast" = "Kontrast" +"Contributors" = "Katkıda bulunanlar" +"Contributors: {people} · Commits: {commits}" = "Katkıda bulunanlar: {people} · Commit'ler: {commits}" +"Controller" = "Oyun kumandası" +"Cool" = "Soğuk" +"Copied as CSS" = "CSS olarak kopyalandı" +"Copied as PNG" = "PNG olarak kopyalandı" +"Copied as SVG" = "SVG olarak kopyalandı" +"Copied as text" = "Metin olarak kopyalandı" +"Copied {value}" = "Kopyalandı: {value}" +"Copy" = "Kopyala" +"Copy as" = "Farklı kopyala" +"Copy as CSS" = "CSS olarak kopyala" +"Copy as PNG" = "PNG olarak kopyala" +"Copy as SVG" = "SVG olarak kopyala" +"Copy code" = "Kodu kopyala" +"Copy link" = "Bağlantıyı kopyala" +"Copy the {label}" = "Kopyala: {label}" +"Copy {value}" = "Kopyala: {value}" +"Corner radius" = "Köşe yarıçapı" +"Corner smoothing" = "Köşe yumuşatma" +"Couldn't reach {server}: showing what this computer remembers." = "{server} sunucusuna ulaşılamadı: bu bilgisayarın hatırladıkları gösteriliyor." +"Count" = "Sayı" +"Create" = "Oluştur" +"Create a variable collection first" = "Önce bir değişken koleksiyonu oluşturun" +"Create a variable first" = "Önce bir değişken oluşturun" +"Create alias" = "Takma ad oluştur" +"Create branch" = "Dal oluştur" +"Create collection" = "Koleksiyon oluştur" +"Create component" = "Bileşen oluştur" +"Create component property" = "Bileşen özelliği oluştur" +"Create components, styles or variables to publish them as a library." = "Kitaplık olarak yayımlamak için bileşen, stil veya değişken oluşturun." +"Create property…" = "Özellik oluştur…" +"Create style" = "Stil oluştur" +"Create style from the selected text" = "Seçili metinden stil oluştur" +"Created" = "Oluşturuldu" +"Crop" = "Kırp" +"Crop marks" = "Kesim işaretleri" +"Ctrl+arrows" = "Ctrl+oklar" +"Ctrl+click" = "Ctrl+tıklama" +"Ctrl+scroll" = "Ctrl+kaydırma" +"Current name" = "Geçerli ad" +"Current version" = "Geçerli sürüm" +"Cursor chat" = "İmleç sohbeti" +"Custom" = "Özel" +"Cut" = "Kes" +"Dark" = "Koyu" +"Darken" = "Koyulaştır" +"Dash" = "Kesik çizgi" +"Dashboard" = "Pano" +"Date created" = "Oluşturulma tarihi" +"Dec" = "Ara" +"Decoration style" = "Süsleme stili" +"Decoration" = "Süsleme" +"Deep select" = "Derin seçim" +"Default" = "Varsayılan" +"Default content" = "Varsayılan içerik" +"Default value (the top-left variant)" = "Varsayılan değer (sol üstteki varyant)" +"Delay" = "Gecikme" +"Delete" = "Sil" +"Delete category (its annotations stay)" = "Kategoriyi sil (ek açıklamaları kalır)" +"Delete collection" = "Koleksiyonu sil" +"Delete group" = "Grubu sil" +"Delete mode" = "Modu sil" +"Delete page" = "Sayfayı sil" +"Delete preset" = "Ön ayarı sil" +"Delete property" = "Özelliği sil" +"Delete the style (layers keep their values)" = "Stili sil (katmanlar değerlerini korur)" +"Delete thread" = "Konuyu sil" +"Delete variable" = "Değişkeni sil" +"Delete “{name}”? This can't be undone." = "“{name}” silinsin mi? Bu işlem geri alınamaz." +"Density" = "Yoğunluk" +"Depth" = "Derinlik" +"Describe how and when to use this component" = "Bu bileşenin nasıl ve ne zaman kullanılacağını açıklayın" +"Describe what changed" = "Neyin değiştiğini açıklayın" +"Description" = "Açıklama" +"Description (optional)" = "Açıklama (isteğe bağlı)" +"Design" = "Tasarım" +"Design system starter" = "Tasarım sistemi başlangıç seti" +"Desktop" = "Masaüstü" +"Destination" = "Hedef" +"Detach alias" = "Takma adı ayır" +"Detach instance" = "Örneği ayır" +"Detach property" = "Özelliği ayır" +"Detach style" = "Stili ayır" +"Detach styles" = "Stilleri ayır" +"Detach variable" = "Değişkeni ayır" +"Details" = "Ayrıntılar" +"Dev Mode" = "Geliştirici Modu" +"Dev resources" = "Geliştirici kaynakları" +"Development" = "Geliştirme" +"Device" = "Cihaz" +"Device at 100%" = "Cihaz %100'de" +"Diamond" = "Elmas" +"Difference" = "Fark" +"Dimensions" = "Boyutlar" +"Direction" = "Yön" +"Disconnect from code" = "Kod bağlantısını kes" +"Dispersion" = "Dağılım" +"Display name" = "Görünen ad" +"Dissolve" = "Çözülme" +"Distribute and tidy" = "Dağıt ve düzenle" +"Distribute horizontal spacing" = "Yatay aralığı dağıt" +"Distribute vertical spacing" = "Dikey aralığı dağıt" +"Divider" = "Ayırıcı" +"Don't truncate" = "Kısaltma yapma" +"Done" = "Bitti" +"Done editing" = "Düzenlemeyi bitir" +"Dots" = "Noktalar" +"Dotted" = "Noktalı" +"Down" = "Aşağı" +"Drafts" = "Taslaklar" +"Drag the stroke to add a width point" = "Genişlik noktası eklemek için konturu sürükleyin" +"Draw" = "Çiz" +"Draw a frame" = "Çerçeve çiz" +"Drawing tools" = "Çizim araçları" +"Drop shadow" = "Gölge" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "MIT OR Apache-2.0 çift lisanslıdır. ArtCraft ekibi ve topluluğu tarafından yapılmıştır." +"Duo" = "İkili" +"Duplicate" = "Çoğalt" +"Duplicate by dragging" = "Sürükleyerek çoğalt" +"Duplicate mode" = "Modu çoğalt" +"Duplicate page" = "Sayfayı çoğalt" +"Edit" = "Düzenle" +"Edit as an expression" = "İfade olarak düzenle" +"Edit colour" = "Rengi düzenle" +"Edit comment" = "Yorumu düzenle" +"Edit dev resource" = "Geliştirici kaynağını düzenle" +"Edit link" = "Düzenleme bağlantısı" +"Edit link copied" = "Düzenleme bağlantısı kopyalandı" +"Edit style" = "Stili düzenle" +"Edit variable" = "Değişkeni düzenle" +"Edit with the builder" = "Oluşturucuyla düzenle" +"Edited {when}" = "Düzenlendi: {when}" +"Effect settings" = "Efekt ayarları" +"Effect styles" = "Efekt stilleri" +"Effects" = "Efektler" +"Ellipse" = "Elips" +"Ellipses" = "Elipsler" +"Else" = "Değilse" +"Else if" = "Değilse ve eğer" +"Embedded profile" = "Gömülü profil" +"Empty" = "Boş" +"End" = "Bitiş" +"End point" = "Bitiş noktası" +"Ends the session; everyone keeps their copy" = "Oturumu sonlandırır; herkes kendi kopyasını saklar" +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Bir figcraft-server adresi girin ve sunduğu eklentileri görmek için Gözat'a basın." +"Enter to save · Esc to cancel" = "Kaydetmek için Enter · İptal için Esc" +"Eraser" = "Silgi" +"Esc" = "Esc" +"Essential" = "Temel" +"Every conflict has a choice." = "Her çakışma için bir seçim yapıldı." +"Every top-level frame already starts a flow" = "Her üst düzey çerçeve zaten bir akış başlatıyor" +"Exact opacity (4 then 5: 45%)" = "Tam opaklık (4 ardından 5: %45)" +"Exclude" = "Hariç tut" +"Excluded" = "Hariç tutuldu" +"Excluded from layout" = "Yerleşimden hariç" +"Excludes" = "Hariç tutar" +"Exclusion" = "Dışlama" +"Exit focus view (Esc)" = "Odak görünümünden çık (Esc)" +"Expand" = "Genişlet" +"Export" = "Dışa aktar" +"Export all" = "Tümünü dışa aktar" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Çerçeveleri ve katmanları PNG, JPG, SVG veya PDF olarak dışa aktarın" +"Export page as PNG" = "Sayfayı PNG olarak dışa aktar" +"Export settings" = "Dışa aktarma ayarları" +"Export variables" = "Değişkenleri dışa aktar" +"Export {name}" = "Dışa aktar: {name}" +"Export {n} layers" = { other = "{n} katmanı dışa aktar" } +"Exported {file}" = "Dışa aktarıldı: {file}" +"Exported {name}" = "Dışa aktarıldı: {name}" +"Exported {name}; {n} kinds of content were left out" = { other = "Dışa aktarıldı: {name}; {n} tür içerik dışarıda bırakıldı" } +"Exported {n} files" = { other = "{n} dosya dışa aktarıldı" } +"Export…" = "Dışa aktar…" +"Expose properties to instances" = "Özellikleri örneklere aç" +"Exposure" = "Pozlama" +"Expression" = "İfade" +"Extend collection" = "Koleksiyonu genişlet" +"Extends \"{name}\": overridden values are marked" = "“{name}” koleksiyonunu genişletir: geçersiz kılınan değerler işaretlidir" +"Eye" = "Göz" +"F" = "F" +"Fade" = "Solma" +"False" = "Yanlış" +"Feb" = "Şub" +"Fetching from the server…" = "Sunucudan getiriliyor…" +"File" = "Dosya" +"File name" = "Dosya adı" +"File not found" = "Dosya bulunamadı" +"File published" = "Dosya yayımlandı" +"Files aren't end-to-end encrypted: the server's operator can read them." = "Dosyalar uçtan uca şifrelenmez: sunucunun işletmecisi onları okuyabilir." +"Files nobody opens for {n} days are deleted." = { other = "{n} gün boyunca kimsenin açmadığı dosyalar silinir." } +"Fill" = "Dolgu" +"Fill (fr)" = "Doldur (fr)" +"Fill container" = "Kapsayıcıyı doldur" +"Fill window" = "Pencereyi doldur" +"Fills" = "Dolgular" +"Fill|sizing" = "Doldur" +"Filter and sort" = "Filtrele ve sırala" +"Find" = "Bul" +"Find and replace" = "Bul ve değiştir" +"Find layers" = "Katman bul" +"First" = "İlk" +"First commit" = "İlk commit" +"First on top" = "İlki üstte" +"Fit" = "Sığdır" +"Fit device to window" = "Cihazı pencereye sığdır" +"Fit to contents" = "İçeriğe sığdır" +"Fit to window" = "Pencereye sığdır" +"Fit width" = "Genişliğe sığdır" +"Fixed" = "Sabit" +"Fixed gap" = "Sabit aralık" +"Fixed size" = "Sabit boyut" +"Flatten" = "Düzleştir" +"Flip" = "Çevir" +"Flip horizontal" = "Yatay çevir" +"Flip text to the other side" = "Metni diğer tarafa çevir" +"Flip vertical" = "Dikey çevir" +"Flip width profile" = "Genişlik profilini çevir" +"Flow starting point" = "Akış başlangıç noktası" +"Flow starting points" = "Akış başlangıç noktaları" +"Flows" = "Akışlar" +"Focus view" = "Odak görünümü" +"Fold autosaves" = "Otomatik kayıtları katla" +"Folder: click to collapse" = "Klasör: daraltmak için tıklayın" +"Folder: click to expand" = "Klasör: genişletmek için tıklayın" +"Follow" = "Takip et" +"Follow {name}" = "{name} adlı kişiyi takip et" +"Following" = "Takip ediliyor" +"Following {name} · {key} to stop" = "{name} takip ediliyor · durdurmak için {key}" +"Font" = "Yazı tipi" +"Font not installed" = "Yazı tipi yüklü değil" +"Font size" = "Yazı tipi boyutu" +"Font style" = "Yazı tipi stili" +"Forget on this computer" = "Bu bilgisayarda unut" +"Four screens of a trail app with components and a prototype flow" = "Bileşenleri ve bir prototip akışıyla bir doğa yürüyüşü uygulamasının dört ekranı" +"Fractions" = "Kesirler" +"Frame" = "Çerçeve" +"Frame selection" = "Seçimi çerçevele" +"Frame tools" = "Çerçeve araçları" +"Frames" = "Çerçeveler" +"From the library \"{name}\": read-only" = "“{name}” kitaplığından: salt okunur" +"From {name}" = "Kaynak: {name}" +"From {server}" = "Kaynak: {server}" +"Frost" = "Buzlu" +"Gap" = "Aralık" +"Gap between columns" = "Sütunlar arası aralık" +"Gap between items" = "Öğeler arası aralık" +"Gap between rows" = "Satırlar arası aralık" +"Getting started" = "Başlarken" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Herkese açık bir sunucunun adresini ya da bir sunucunun adresini anahtarıyla birlikte verin (`figcraft-server key` anahtarı yazdırır)." +"Glass" = "Cam" +"Go to main component" = "Ana bileşene git" +"Go to {name}" = "{name} öğesine git" +"Grab bag" = "Karışık torba" +"Gradient" = "Gradyan" +"Gray" = "Gri" +"Green" = "Yeşil" +"Greyscale components and two example screens to sketch a flow with" = "Bir akış taslağı çizmek için gri tonlamalı bileşenler ve iki örnek ekran" +"Grid" = "Izgara" +"Grid ({size})" = "Izgara ({size})" +"Grid styles" = "Izgara stilleri" +"Grid {size}" = "Izgara {size}" +"Group" = "Grupla" +"Group selection" = "Seçimi grupla" +"Groups" = "Gruplar" +"Gutter" = "Oluk" +"Hand tool" = "El aracı" +"Hanging list" = "Asılı liste" +"Hanging punctuation" = "Asılı noktalama" +"Hard light" = "Sert ışık" +"Height" = "Yükseklik" +"Help" = "Yardım" +"Help and resources" = "Yardım ve kaynaklar" +"Help page" = "Yardım sayfası" +"Here: {names}" = "Burada: {names}" +"Hidden" = "Gizli" +"Hide contents" = "İçeriği gizle" +"Hide from publishing" = "Yayımlamadan gizle" +"Hide section contents" = "Bölüm içeriğini gizle" +"Hide these tips (Getting started in the Help menu shows them again)" = "Bu ipuçlarını gizle (Yardım menüsündeki Başlarken onları yeniden gösterir)" +"Hide when publishing" = "Yayımlarken gizle" +"Highlight layers on hover" = "Üzerine gelince katmanları vurgula" +"Highlights" = "Açık tonlar" +"Hold Alt" = "Alt tuşunu basılı tutun" +"Hold Alt+Z" = "Alt+Z tuşlarını basılı tutun" +"Hold Ctrl" = "Ctrl tuşunu basılı tutun" +"Hold Z" = "Z tuşunu basılı tutun" +"Hold ⌃ or ⌘" = "⌃ veya ⌘ tuşunu basılı tutun" +"Hold ⌥" = "⌥ tuşunu basılı tutun" +"Hold ⌥Z" = "⌥Z tuşlarını basılı tutun" +"Home" = "Ana sayfa" +"Horizontal" = "Yatay" +"Horizontal align" = "Yatay hizalama" +"Horizontal hex" = "Yatay altıgen" +"Horizontal padding" = "Yatay iç boşluk" +"Horizontal spacing" = "Yatay aralık" +"Hosted by {operator}" = "Barındıran: {operator}" +"Hover a layer to see its properties" = "Özelliklerini görmek için bir katmanın üzerine gelin" +"Hover other layers to measure the distance" = "Mesafeyi ölçmek için diğer katmanların üzerine gelin" +"Hue" = "Ton" +"Hug" = "Sar" +"Hug contents" = "İçeriği sar" +"Icon components on a 24 px grid with keylines and a size preview" = "Anahat çizgileri ve boyut önizlemesiyle 24 px ızgara üzerinde simge bileşenleri" +"Icon set starter" = "Simge seti başlangıcı" +"If" = "Eğer" +"If {condition}" = "Eğer {condition}" +"Ignore auto layout (absolute position)" = "Otomatik yerleşimi yok say (mutlak konum)" +"Image" = "Görüntü" +"Import" = "İçe aktar" +"Import path" = "İçe aktarma yolu" +"Import plugin from manifest" = "Eklentiyi manifestten içe aktar" +"Import plugin from manifest…" = "Eklentiyi manifestten içe aktar…" +"Import variables" = "Değişkenleri içe aktar" +"Imported {name}" = "İçe aktarıldı: {name}" +"Imported {n} new variables, updated {updated}" = { other = "{n} yeni değişken içe aktarıldı, {updated} güncellendi" } +"Import…" = "İçe aktar…" +"In an instance, this comes from the main component" = "Bir örnekte bu, ana bileşenden gelir" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "Tarayıcıda, masaüstü uygulamasının barındırdığı oturumlara ve bir FigCraft sunucusundaki dosyalara katılabilirsiniz. https üzerinden açılan bir sayfa diğer bilgisayarlara yalnızca wss:// bağlantılarıyla (TLS arkasındaki bir sunucu) ulaşır." +"Include bounding box" = "Sınırlayıcı kutuyu dahil et" +"Include id attribute" = "id özniteliğini dahil et" +"Included" = "Dahil" +"Included in layout" = "Yerleşime dahil" +"Individual corners" = "Ayrı köşeler" +"Individual strokes" = "Ayrı konturlar" +"Ink" = "Mürekkep" +"Inner shadow" = "İç gölge" +"Inside" = "İç" +"Inspect" = "İncele" +"Inspect the layer under the pointer: size, colours, typography" = "İşaretçinin altındaki katmanı inceleyin: boyut, renkler, tipografi" +"Install plugin" = "Eklenti yükle" +"Install" = "Yükle" +"Installed {name}" = "Yüklendi: {name}" +"Installed" = "Yüklü" +"Installing…" = "Yükleniyor…" +"Instance" = "Örnek" +"Instance properties" = "Örnek özellikleri" +"Instance swap" = "Örnek değiştirme" +"Instance swap (no other component)" = "Örnek değiştirme (başka bileşen yok)" +"Instances" = "Örnekler" +"Instant" = "Anında" +"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ı." +"Intersect" = "Kesiştir" +"Intersects" = "Kesişir" +"Invite link" = "Davet bağlantısı" +"Invite link copied" = "Davet bağlantısı kopyalandı" +"It asks permission to:" = "Şunlar için izin istiyor:" +"Jan" = "Oca" +"Join" = "Katıl" +"Join code copied" = "Katılma kodu kopyalandı" +"Join code or invite link" = "Katılma kodu veya davet bağlantısı" +"Join live session" = "Canlı oturuma katıl" +"Join us on Discord" = "Discord'da bize katılın" +"Join with a code" = "Kodla katıl" +"Joined" = "Katıldı" +"Joined {file}" = "Katıldınız: {file}" +"Joining…" = "Katılınıyor…" +"Join|stroke" = "Birleşim" +"Jul" = "Tem" +"Jun" = "Haz" +"Just now" = "Az önce" +"Justify" = "İki yana yasla" +"Keep appearance" = "Görünümü koru" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Görünümü koru, renkler aynı görünsün diye her renk değerini {profile} profiline dönüştürür. Değerleri koru, sayıları olduğu gibi bırakır; bu yüzden renkler kayar." +"Keep tool selected after use" = "Kullanımdan sonra aracı seçili tut" +"Keep values" = "Değerleri koru" +"Key/Gamepad" = "Tuş/Oyun kumandası" +"Key/Gamepad {key}" = "Tuş/Oyun kumandası {key}" +"Keyboard shortcuts" = "Klavye kısayolları" +"Kits" = "Setler" +"Landing page" = "Açılış sayfası" +"Language" = "Dil" +"Lasso" = "Kement" +"Last" = "Son" +"Last change: {note}" = "Son değişiklik: {note}" +"Last commit" = "Son commit" +"Last modified" = "Son değiştirilme" +"Last on top" = "Sonuncusu üstte" +"Layer blur" = "Katman bulanıklığı" +"Layer name" = "Katman adı" +"Layers" = "Katmanlar" +"Layers in an instance take their position from the main component" = "Bir örnekteki katmanlar konumlarını ana bileşenden alır" +"Layout" = "Yerleşim" +"Layout grid" = "Yerleşim ızgarası" +"Layout grid settings" = "Yerleşim ızgarası ayarları" +"Layout grids" = "Yerleşim ızgaraları" +"Leave session" = "Oturumdan ayrıl" +"Leaves" = "Yapraklar" +"Left" = "Sol" +"Left & right" = "Sol ve sağ" +"Left side" = "Sol taraf" +"Left side width" = "Sol taraf genişliği" +"Letter case" = "Harf durumu" +"Letter spacing" = "Harf aralığı" +"Libraries" = "Kitaplıklar" +"Libraries in this file" = "Bu dosyadaki kitaplıklar" +"Library updates available" = "Kitaplık güncellemeleri var" +"Library: click to collapse" = "Kitaplık: daraltmak için tıklayın" +"Library: click to expand" = "Kitaplık: genişletmek için tıklayın" +"Library: its variables are read-only here; edit them in the library file and publish it" = "Kitaplık: değişkenleri burada salt okunurdur; onları kitaplık dosyasında düzenleyip yayımlayın" +"Light" = "Açık" +"Lighten" = "Açıklaştır" +"Light|glass" = "Işık" +"Line" = "Çizgi" +"Line arrow" = "Çizgi ok" +"Line delta" = "Satır farkı" +"Line height" = "Satır yüksekliği" +"Linear" = "Doğrusal" +"Lines" = "Satırlar" +"Lines +/−" = "Satırlar +/−" +"Lines added" = "Eklenen satırlar" +"Lines deleted" = "Silinen satırlar" +"Link" = "Bağlantı" +"Link copied" = "Bağlantı kopyalandı" +"Link docs, code or tickets for developers." = "Geliştiriciler için belge, kod veya iş kaydı bağlantısı verin." +"Link frames on the Prototype tab, then present the flow" = "Prototip sekmesinde çerçeveleri bağlayın, sonra akışı sunun" +"Link this component to its code component: instances then show your real snippet with their values." = "Bu bileşeni kod bileşenine bağlayın: örnekler sizin gerçek kod parçanızı kendi değerleriyle gösterir." +"List spacing" = "Liste aralığı" +"List style" = "Liste stili" +"Live session" = "Canlı oturum" +"Live · {people}" = "Canlı · {people}" +"Local components" = "Yerel bileşenler" +"Local network" = "Yerel ağ" +"Local styles" = "Yerel stiller" +"Local variables" = "Yerel değişkenler" +"Lock/Unlock" = "Kilitle/Kilidi aç" +"Locked" = "Kilitli" +"Lowercase" = "Küçük harf" +"Luminance" = "Parlaklık" +"Luminosity" = "Işıklılık" +"Main" = "Ana" +"Main component deleted" = "Ana bileşen silindi" +"Main menu" = "Ana menü" +"Make a component" = "Bileşen yap" +"Make slot" = "Yuva yap" +"Mar" = "Mar" +"Margin" = "Kenar boşluğu" +"Mark all as read" = "Tümünü okundu olarak işaretle" +"Mark as resolved" = "Çözüldü olarak işaretle" +"Mark as unread" = "Okunmadı olarak işaretle" +"Mark as unresolved" = "Çözülmedi olarak işaretle" +"Mask" = "Maske" +"Mass · Stiffness · Damping" = "Kütle · Sertlik · Sönüm" +"Match (empty: the whole name)" = "Eşleşme (boş: adın tamamı)" +"Max H" = "En büyük Y" +"Max W" = "En büyük G" +"Max lines" = "En fazla satır" +"Maximize" = "Ekranı kapla" +"May" = "May" +"Measure to the selection" = "Seçime kadar ölç" +"Merge into main" = "Ana dosyaya birleştir" +"Merge regions" = "Bölgeleri birleştir" +"Merge the branch into the main file (one undo step) and archive it" = "Dalı ana dosyaya birleştir (tek geri alma adımı) ve arşivle" +"Merged" = "Birleştirildi" +"Merged PRs" = "Birleştirilen PR'ler" +"Min H" = "En küçük Y" +"Min W" = "En küçük G" +"Minimize" = "Simge durumuna küçült" +"Mirror angle" = "Açıyı yansıt" +"Mirror angle and length" = "Açıyı ve uzunluğu yansıt" +"Mirroring" = "Yansıtma" +"Missing" = "Eksik" +"Missing layer" = "Eksik katman" +"Missing style" = "Eksik stil" +"Missing variable" = "Eksik değişken" +"Miter" = "Gönye" +"Miter angle" = "Gönye açısı" +"Mixed" = "Karışık" +"Mixed fonts" = "Karışık yazı tipleri" +"Mixed interactions: click + to replace them" = "Karışık etkileşimler: değiştirmek için + simgesine tıklayın" +"Mobile app" = "Mobil uygulama" +"Mode" = "Mod" +"Model" = "Model" +"Models" = "Modeller" +"Mono" = "Tek renk" +"More" = "Daha fazla" +"More instance actions" = "Daha fazla örnek eylemi" +"Mouse down" = "Fare basılı" +"Mouse enter" = "Fare girişi" +"Mouse leave" = "Fare çıkışı" +"Mouse up" = "Fare bırakıldı" +"Move along the toolbar or the layer rows" = "Araç çubuğunda veya katman satırlarında gezinin" +"Move" = "Taşı" +"Move column left" = "Sütunu sola taşı" +"Move column right" = "Sütunu sağa taşı" +"Move down" = "Aşağı taşı" +"Move in" = "İçeri taşı" +"Move out" = "Dışarı taşı" +"Move tools" = "Taşıma araçları" +"Move up" = "Yukarı taşı" +"Moved to {name}" = "Şuraya taşındı: {name}" +"Multi" = "Çoklu" +"Multiply" = "Çarp" +"Muted" = "Soluk" +"Name" = "Ad" +"Name (A–Z)" = "Ad (A–Z)" +"Named instance" = "Adlandırılmış örnek" +"Navigate to" = "Şuraya git" +"Nested instance" = "İç içe örnek" +"New design file" = "Yeni tasarım dosyası" +"Newest first" = "Önce en yeni" +"Next or previous control in the focused region" = "Odaklanılan bölgedeki sonraki veya önceki denetim" +"Next ready for dev" = "Sonraki geliştirmeye hazır" +"Next size option" = "Sonraki boyut seçeneği" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "Varlık yok: dışa aktarma ayarı, simge veya görüntü yok. Tasarım sekmesinde dışa aktarma ayarı ekleyin." +"No boolean variables fit here." = "Buraya uyan boolean değişken yok." +"No boolean variables yet. Create them in Local variables." = "Henüz boolean değişken yok. Onları Yerel değişkenler'de oluşturun." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Dal yok. Dal, değişiklikleri denemek, sonra gözden geçirip birleştirmek için dosyanın bir kopyasıdır." +"No category" = "Kategori yok" +"No changes" = "Değişiklik yok" +"No colour variables fit here." = "Buraya uyan renk değişkeni yok." +"No colour variables yet. Create them in Local variables." = "Henüz renk değişkeni yok. Onları Yerel değişkenler'de oluşturun." +"No comments yet" = "Henüz yorum yok" +"No components yet. Select layers and press {keys}." = "Henüz bileşen yok. Katmanları seçip {keys} tuşlarına basın." +"No conflicts." = "Çakışma yok." +"No contributor data was built into this copy." = "Bu kopyaya katkıda bulunan verisi eklenmemiş." +"No fonts match" = "Eşleşen yazı tipi yok" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Burada katılma kodu yok (sunucu katılma kodlarından eski veya bu adres bir koda sığmıyor): bunun yerine bağlantıyı paylaşın." +"No layer changes to show." = "Gösterilecek katman değişikliği yok." +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Henüz kitaplık yok. Kitaplık olarak yayımlanmış bir .figcraft dosyası ekleyin veya bir ekip kitaplığının bağlantısını yapıştırın." +"No list" = "Liste yok" +"No matching comments" = "Eşleşen yorum yok" +"No matching components" = "Eşleşen bileşen yok" +"No matching layers" = "Eşleşen katman yok" +"No matching styles" = "Eşleşen stil yok" +"No matching variables" = "Eşleşen değişken yok" +"No mirroring" = "Yansıtma yok" +"No model credits were built into this copy." = "Bu kopyaya model emeği bilgisi eklenmemiş." +"No number variables fit here." = "Buraya uyan sayı değişkeni yok." +"No number variables yet. Create them in Local variables." = "Henüz sayı değişkeni yok. Onları Yerel değişkenler'de oluşturun." +"No paints" = "Boya yok" +"No plugins on {server}." = "{server} üzerinde eklenti yok." +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Henüz eklenti yok. Bir geliştirme eklentisini figcraft-plugin.json dosyasından içe aktarın." +"No preferred components" = "Tercih edilen bileşen yok" +"No preset" = "Ön ayar yok" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "Henüz kaydedilmiş sürüm yok. İşinizde geri dönebileceğiniz bir noktayı işaretlemek için bir sürüm kaydedin." +"No scrolling" = "Kaydırma yok" +"No shortcuts match" = "Eşleşen kısayol yok" +"No status" = "Durum yok" +"No string variables fit here." = "Buraya uyan metin değişkeni yok." +"No string variables yet. Create them in Local variables." = "Henüz metin değişkeni yok. Onları Yerel değişkenler'de oluşturun." +"No styles yet." = "Henüz stil yok." +"No styles yet. Click + to create one from the selection." = "Henüz stil yok. Seçimden oluşturmak için + simgesine tıklayın." +"No templates match your search" = "Aramanızla eşleşen şablon yok" +"No text styles yet. Click + to create one from the selected text." = "Henüz metin stili yok. Seçili metinden oluşturmak için + simgesine tıklayın." +"No variables fit here." = "Buraya uyan değişken yok." +"No variables yet. Create them in Local variables." = "Henüz değişken yok. Onları Yerel değişkenler'de oluşturun." +"Nobody else is here yet" = "Burada henüz başka kimse yok" +"Noise" = "Gürültü" +"None" = "Yok" +"None." = "Yok." +"Normal" = "Normal" +"Not bound" = "Bağlı değil" +"Not in a live session" = "Canlı oturumda değil" +"Not in this font" = "Bu yazı tipinde yok" +"Not properties, kept as written: {names}" = "Özellik değil, yazıldığı gibi korundu: {names}" +"Not read yet" = "Henüz okunmadı" +"Not shared" = "Paylaşılmadı" +"Note {n}" = "Not {n}" +"Nothing changed since this was marked ready for dev." = "Geliştirmeye hazır olarak işaretlendiğinden beri hiçbir şey değişmedi." +"Nothing here yet. Set a frame's status to list it." = "Burada henüz bir şey yok. Burada listelemek için bir çerçevenin durumunu ayarlayın." +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Henüz çevrimiçi bir şey yok. Bir dosyayı çevrimiçi yapmak için Paylaş'ı kullanın, burada listelenir." +"Nothing to export" = "Dışa aktarılacak bir şey yok" +"Nov" = "Kas" +"Now" = "Şimdi" +"Nudge (Shift: big nudge)" = "Kaydır (Shift: büyük kaydırma)" +"Nudge amount" = "Kaydırma miktarı" +"Number" = "Sayı" +"Number ↑" = "Sayı ↑" +"Number ↓" = "Sayı ↓" +"Numbered list" = "Numaralı liste" +"Object" = "Nesne" +"Oct" = "Eki" +"Off" = "Kapalı" +"Offered first when filling the slot (any content is allowed)" = "Yuvayı doldururken önce önerilir (her içeriğe izin verilir)" +"Offered first when swapping the instance (any component is allowed)" = "Örneği değiştirirken önce önerilir (her bileşene izin verilir)" +"Offset" = "Kaydırma" +"Oldest first" = "Önce en eski" +"On click" = "Tıklanınca" +"On drag" = "Sürüklenince" +"On this page" = "Bu sayfada" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Beş yaygın boyutta tek bir duyuru: kare, dikey, hikâye, bağlantı ve afiş" +"Online" = "Çevrimiçi" +"Online files" = "Çevrimiçi dosyalar" +"Online sharing is off: files are shared on the local network only." = "Çevrimiçi paylaşım kapalı: dosyalar yalnızca yerel ağda paylaşılır." +"Only the first changed layers are listed." = "Yalnızca ilk değişen katmanlar listelenir." +"Only the first {n} changed layers are listed." = "Yalnızca ilk {n} değişen katman listelenir." +"Only your threads" = "Yalnızca sizin konularınız" +"Opacity" = "Opaklık" +"Opacity & blend" = "Opaklık ve karışım" +"Opacity 10%…90%, 0: 100%" = "Opaklık %10…%90, 0: %100" +"Open" = "Aç" +"Open .figcraft, .svg or images" = ".figcraft, .svg veya görüntü aç" +"Open branch" = "Dalı aç" +"Open file" = "Dosya aç" +"Open files" = "Dosyaları aç" +"Open in focus view" = "Odak görünümünde aç" +"Open in new window" = "Yeni pencerede aç" +"Open invite links in FigCraft" = "Davet bağlantılarını FigCraft'ta aç" +"Open link" = "Bağlantıyı aç" +"Open link {url}" = "Bağlantıyı aç: {url}" +"Open overlay" = "Bindirmeyi aç" +"Open prototype preview" = "Prototip önizlemesini aç" +"Open variables ({count})" = "Açık değişkenler ({count})" +"Opened “{name}” in a new tab" = "“{name}” yeni sekmede açıldı" +"Open|comments" = "Açık" +"Options" = "Seçenekler" +"Orange" = "Turuncu" +"Order" = "Sıra" +"Other apps may not open every .fig file exported here." = "Diğer uygulamalar burada dışa aktarılan her .fig dosyasını açamayabilir." +"Others lose access and keep their copy; the file stays on the server for you" = "Diğerleri erişimini kaybeder ve kopyalarını saklar; dosya sizin için sunucuda kalır" +"Others type this under Share ▸ Join with a code" = "Diğerleri bunu Paylaş ▸ Kodla katıl altına yazar" +"Outline stroke" = "Konturu ana hatlara dönüştür" +"Outline text" = "Metni ana hatlara dönüştür" +"Outlines" = "Ana hatlar" +"Outside" = "Dış" +"Overflow" = "Taşma" +"Overlay" = "Bindirme" +"Overlay position" = "Bindirme konumu" +"Overridden here (the collection it extends has another value)" = "Burada geçersiz kılındı (genişlettiği koleksiyonda başka bir değer var)" +"Overrides" = "Geçersiz kılmalar" +"Owner token" = "Sahip belirteci" +"Owner token copied" = "Sahip belirteci kopyalandı" +"PDF print options" = "PDF baskı seçenekleri" +"PDF print settings" = "PDF baskı ayarları" +"Padding" = "İç boşluk" +"Page" = "Sayfa" +"Pages" = "Sayfalar" +"Pages: {pages}" = "Sayfalar: {pages}" +"Paint bucket" = "Boya kovası" +"Pan the canvas" = "Tuvali kaydır" +"Pan with nothing selected (Shift: farther)" = "Hiçbir şey seçili değilken kaydır (Shift: daha uzağa)" +"Panels" = "Paneller" +"Paper" = "Kâğıt" +"Paragraph indent" = "Paragraf girintisi" +"Paragraph indent applies to left-aligned text" = "Paragraf girintisi sola hizalı metne uygulanır" +"Paragraph spacing" = "Paragraf aralığı" +"Pass through" = "Geçiş" +"Paste a link (https://…)" = "Bağlantı yapıştırın (https://…)" +"Paste a team library link (a view link)" = "Bir ekip kitaplığı bağlantısı yapıştırın (görüntüleme bağlantısı)" +"Paste here" = "Buraya yapıştır" +"Paste to replace" = "Değiştirmek için yapıştır" +"Pattern" = "Desen" +"Pattern alignment" = "Desen hizalaması" +"Pattern scale" = "Desen ölçeği" +"Pattern source" = "Desen kaynağı" +"Pen" = "Kalem" +"Pencil" = "Kurşun kalem" +"People in this file" = "Bu dosyadaki kişiler" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "Kişiler her yerden bir kodla katılır. Siz paylaşırken dosya {server} üzerinde tutulur." +"People on your network join with a code. Nothing goes through a server." = "Ağınızdaki kişiler bir kodla katılır. Hiçbir şey bir sunucudan geçmez." +"Phone" = "Telefon" +"Pick color" = "Renk seç" +"Pick color (I)" = "Renk seç (I)" +"Pick one font family first" = "Önce tek bir yazı tipi ailesi seçin" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Çerçeve aracını seçin, sonra tuvalde sürükleyin veya bir cihaz boyutu seçin" +"Pin a property" = "Bir özelliği sabitle" +"Pinch" = "Sıkıştır" +"Pixel grid" = "Piksel ızgarası" +"Pixel preview 1x" = "Piksel önizlemesi 1x" +"Pixel preview 2x" = "Piksel önizlemesi 2x" +"Place a shape or frame (its tool chosen)" = "Bir şekil veya çerçeve yerleştir (aracı seçiliyken)" +"Place automatically" = "Otomatik yerleştir" +"Place image" = "Görüntü yerleştir" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Eklentiler FigCraft'ın korumalı alanında dosya, ağ veya saat olmadan çalışır. Yalnızca güvendiğiniz eklentileri yükleyin." +"Plugins" = "Eklentiler" +"Plus darker" = "Artı koyu" +"Plus lighter" = "Artı açık" +"Point" = "Nokta" +"Points" = "Noktalar" +"Polygon" = "Çokgen" +"Polygons" = "Çokgenler" +"Port" = "Bağlantı noktası" +"Position" = "Konum" +"Post" = "Gönder" +"Preferences" = "Tercihler" +"Preferred" = "Tercih edilen" +"Present" = "Sun" +"Present or preview" = "Sun veya önizle" +"Present this flow" = "Bu akışı sun" +"Presentation" = "Sunum" +"Presentation slides" = "Sunum slaytları" +"Presenting: comments" = "Sunum: yorumlar" +"Presenting: cycle the size options" = "Sunum: boyut seçenekleri arasında geçiş" +"Presenting: full screen" = "Sunum: tam ekran" +"Presenting: next frame" = "Sunum: sonraki çerçeve" +"Presenting: previous frame" = "Sunum: önceki çerçeve" +"Presenting: restart" = "Sunum: yeniden başlat" +"Preserve scroll position" = "Kaydırma konumunu koru" +"Presets" = "Ön ayarlar" +"Press a key…" = "Bir tuşa basın…" +"Playground" = "Deneme alanı" +"Preview" = "Önizleme" +"Preview this flow" = "Bu akışı önizle" +"Preview this frame" = "Bu çerçeveyi önizle" +"Previous ready for dev" = "Önceki geliştirmeye hazır" +"Progressive" = "Aşamalı" +"Properties" = "Özellikler" +"Prototype" = "Prototip" +"Prototype preview" = "Prototip önizlemesi" +"Prototype settings" = "Prototip ayarları" +"Publish" = "Yayımla" +"Publish changes" = "Değişiklikleri yayımla" +"Publish to another server…" = "Başka bir sunucuya yayımla…" +"Publish to server" = "Sunucuya yayımla" +"Published version {v}" = "{v} sürümü yayımlandı" +"Published version {v} to the server" = "{v} sürümü sunucuya yayımlandı" +"Published version {v}: save the file so other files can use it" = "{v} sürümü yayımlandı: diğer dosyaların kullanabilmesi için dosyayı kaydedin" +"Published · {people}" = "Yayımlandı · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Bir sunucuya yayımlamak için masaüstü uygulaması gerekir; tarayıcıda bir sunucunun dosyalarına katılabilirsiniz" +"Publishing with the server's key." = "Sunucunun anahtarıyla yayımlanıyor." +"Publishing…" = "Yayımlanıyor…" +"Push" = "İt" +"Push changes to main component" = "Değişiklikleri ana bileşene gönder" +"Put text on path" = "Metni yola yerleştir" +"R" = "R" +"Radial" = "Dairesel" +"Radius" = "Yarıçap" +"Ratio" = "Oran" +"Reading the library…" = "Kitaplık okunuyor…" +"Ready for dev" = "Geliştirmeye hazır" +"Real name" = "Gerçek ad" +"Recent" = "Son kullanılan" +"Recent files" = "Son dosyalar" +"Recents" = "Son kullanılanlar" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { other = "Uygulama beklenmedik şekilde kapandıktan sonra kaydedilmemiş {n} dosya kurtarıldı: saklamak için onları kaydedin" } +"Rectangle" = "Dikdörtgen" +"Rectangles" = "Dikdörtgenler" +"Red" = "Kırmızı" +"Reduce motion" = "Hareketi azalt" +"Refraction" = "Kırılma" +"Regular expression" = "Düzenli ifade" +"Remove" = "Kaldır" +"Remove all" = "Tümünü kaldır" +"Remove annotation" = "Ek açıklamayı kaldır" +"Remove auto layout" = "Otomatik yerleşimi kaldır" +"Remove dev resource" = "Geliştirici kaynağını kaldır" +"Remove export setting" = "Dışa aktarma ayarını kaldır" +"Remove from recents" = "Son kullanılanlardan kaldır" +"Remove from server" = "Sunucudan kaldır" +"Remove from server…" = "Sunucudan kaldır…" +"Remove guide" = "Kılavuzu kaldır" +"Remove interaction" = "Etkileşimi kaldır" +"Remove library from this file" = "Kitaplığı bu dosyadan kaldır" +"Remove min and max" = "En küçük ve en büyüğü kaldır" +"Remove min and max height" = "En küçük ve en büyük yüksekliği kaldır" +"Remove min and max width" = "En küçük ve en büyük genişliği kaldır" +"Remove regions" = "Bölgeleri kaldır" +"Remove slot" = "Yuvayı kaldır" +"Remove starting point" = "Başlangıç noktasını kaldır" +"Remove stop" = "Durağı kaldır" +"Remove text from path" = "Metni yoldan kaldır" +"Remove the else and its actions" = "Değilse dalını ve eylemlerini kaldır" +"Remove this condition and its actions" = "Bu koşulu ve eylemlerini kaldır" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Bu dosya sunucudan kaldırılsın mı? Oradan bir daha paylaşılamaz veya açılamaz. Kopyanız burada açık kalır." +"Remove {name} from {server}?" = "{name}, {server} üzerinden kaldırılsın mı?" +"Removed" = "Kaldırıldı" +"Removed from the server" = "Sunucudan kaldırıldı" +"Removed since it was marked" = "İşaretlendiğinden beri kaldırıldı" +"Rename" = "Yeniden adlandır" +"Rename duplicated layers" = "Çoğaltılan katmanları yeniden adlandır" +"Rename folder" = "Klasörü yeniden adlandır" +"Rename group" = "Grubu yeniden adlandır" +"Rename layers" = "Katmanları yeniden adlandır" +"Rename mode" = "Modu yeniden adlandır" +"Rename on the server" = "Sunucuda yeniden adlandır" +"Rename on the server…" = "Sunucuda yeniden adlandır…" +"Rename page" = "Sayfayı yeniden adlandır" +"Rename to" = "Yeni ad" +"Replace all" = "Tümünü değiştir" +"Replace mixed export settings" = "Karışık dışa aktarma ayarlarını değiştir" +"Replace with" = "Şununla değiştir" +"Replace with a new interaction" = "Yeni bir etkileşimle değiştir" +"Reply" = "Yanıtla" +"Report" = "Bildir" +"Report this file…" = "Bu dosyayı bildir…" +"Reset" = "Sıfırla" +"Reset adjustments" = "Ayarlamaları sıfırla" +"Reset all changes" = "Tüm değişiklikleri sıfırla" +"Reset all overrides on this layer" = "Bu katmandaki tüm geçersiz kılmaları sıfırla" +"Reset overrides" = "Geçersiz kılmaları sıfırla" +"Reset slot" = "Yuvayı sıfırla" +"Reset this override" = "Bu geçersiz kılmayı sıfırla" +"Reset to style" = "Stile sıfırla" +"Reset to the inherited value" = "Devralınan değere sıfırla" +"Resize by the nudge amount" = "Kaydırma miktarı kadar yeniden boyutlandır" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Çerçeveleri cihazın ekranına (veya cihaz yoksa pencereye) göre yeniden boyutlandır: kısıtlamaları ve otomatik yerleşimleri tepki verir" +"Resize to fit" = "Sığacak şekilde yeniden boyutlandır" +"Resolve" = "Çöz" +"Resolve change" = "Değişikliği çöz" +"Resolved" = "Çözüldü" +"Resources" = "Kaynaklar" +"Responsive" = "Duyarlı" +"Restart" = "Yeniden başlat" +"Restore" = "Geri yükle" +"Restore main component" = "Ana bileşeni geri yükle" +"Restore this version" = "Bu sürümü geri yükle" +"Restored “{name}”" = "“{name}” geri yüklendi" +"Reverse the order" = "Sırayı ters çevir" +"Reversed triangle" = "Ters üçgen" +"Review and merge" = "Gözden geçir ve birleştir" +"Ribbon" = "Şerit" +"Right" = "Sağ" +"Right side" = "Sağ taraf" +"Right side width" = "Sağ taraf genişliği" +"Rotate 90°" = "90° döndür" +"Rotate 90° right" = "90° sağa döndür" +"Rotate to landscape" = "Yataya döndür" +"Rotate to portrait" = "Dikeye döndür" +"Rotation" = "Döndürme" +"Rotation jitter" = "Döndürme titreşimi" +"Round" = "Yuvarlak" +"Rounded" = "Yuvarlatılmış" +"Row" = "Satır" +"Row gap" = "Satır aralığı" +"Row sizes" = "Satır boyutları" +"Row span" = "Satır yayılımı" +"Rows" = "Satırlar" +"Rows {count}" = "Satırlar {count}" +"Rulers" = "Cetveller" +"Run" = "Çalıştır" +"Run {label}" = "Çalıştır: {label}" +"Search templates" = "Şablonlarda ara" +"See all templates" = "Tüm şablonları gör" +"Select layers and they space themselves as their content changes" = "Katmanları seçin; içerikleri değiştikçe kendi aralıklarını ayarlarlar" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "Katmanla aynı" +"Saturation" = "Doygunluk" +"Save" = "Kaydet" +"Save as" = "Farklı kaydet" +"Save as preset…" = "Ön ayar olarak kaydet…" +"Save version {key}" = "Sürümü kaydet {key}" +"Saved" = "Kaydedildi" +"Saved {name}" = "Kaydedildi: {name}" +"Say something" = "Bir şey söyleyin" +"Scale" = "Ölçek" +"Scatter" = "Saçılma" +"Scoping: where pickers offer it" = "Kapsam: seçicilerin onu sunduğu yerler" +"Screen" = "Ekran" +"Scroll behavior" = "Kaydırma davranışı" +"Scroll to" = "Şuraya kaydır" +"Scroll to layer" = "Katmana kaydır" +"Scroll with parent" = "Üst öğeyle birlikte kaydır" +"Search plugins" = "Eklentilerde ara" +"Search" = "Ara" +"Search actions, layers and tools" = "Eylem, katman ve araç ara" +"Search assets" = "Varlıklarda ara" +"Search comments" = "Yorumlarda ara" +"Search fonts" = "Yazı tiplerinde ara" +"Search layers" = "Katmanlarda ara" +"Search shortcuts" = "Kısayollarda ara" +"Section" = "Bölüm" +"Section color" = "Bölüm rengi" +"Sections" = "Bölümler" +"Select a frame and add a starting point to present it as a flow." = "Bir akış olarak sunmak için bir çerçeve seçin ve bir başlangıç noktası ekleyin." +"Select a frame to preview it" = "Önizlemek için bir çerçeve seçin" +"Select a layer to see its properties and code" = "Özelliklerini ve kodunu görmek için bir katman seçin" +"Select all" = "Tümünü seç" +"Select all with same" = "Aynı olanların tümünü seç" +"Select layer" = "Katman seç" +"Select layers to apply the style to" = "Stilin uygulanacağı katmanları seçin" +"Select overlapping shapes" = "Çakışan şekilleri seç" +"Select points to edit their position, radius and mirroring" = "Konumlarını, yarıçaplarını ve yansıtmalarını düzenlemek için noktaları seçin" +"Select slot" = "Yuvayı seç" +"Selection" = "Seçim" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Seçim kutusu, {x}, {y} konumunda {w} × {h}: oklar onu taşır, Shift+oklar veya {cmd}+oklar boyutlandırır, Enter seçer, Escape iptal eder" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Seçim kutusu: oklar onu taşır, Shift+oklar veya {cmd}+oklar boyutlandırır, Enter seçer, Escape iptal eder" +"Send backward" = "Geriye gönder" +"Send report" = "Raporu gönder" +"Send to back" = "En arkaya gönder" +"Sep" = "Eyl" +"Server address (ws://host:47900/)" = "Sunucu adresi (ws://host:47900/)" +"Server address" = "Sunucu adresi" +"Server for online sharing" = "Çevrimiçi paylaşım sunucusu" +"Set as default" = "Varsayılan yap" +"Set variable" = "Değişken ayarla" +"Set variable mode" = "Değişken modunu ayarla" +"Set variable {name}" = "{name} değişkenini ayarla" +"Set {name} to {value}" = "{name} değerini {value} yap" +"Shadows" = "Gölgeler" +"Shape" = "Şekil" +"Shape builder" = "Şekil oluşturucu" +"Shape tools" = "Şekil araçları" +"Share" = "Paylaş" +"Share this file" = "Bu dosyayı paylaş" +"Share with a code" = "Kodla paylaş" +"Share: live session, export" = "Paylaş: canlı oturum, dışa aktarma" +"Shared" = "Paylaşıldı" +"Shared: copy its code from the file's menu" = "Paylaşıldı: kodunu dosyanın menüsünden kopyalayın" +"Sharing a file needs the desktop app; here you can join one." = "Bir dosyayı paylaşmak için masaüstü uygulaması gerekir; burada birine katılabilirsiniz." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Yerel ağda paylaşılıyor (çevrimiçi paylaşım kapalı: Tercihler'de sunucu ayarlanmamış)." +"Sharing on your network · {people}" = "Ağınızda paylaşılıyor · {people}" +"Sharing online · {people}" = "Çevrimiçi paylaşılıyor · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "Paylaşım durduruldu: dosya sunucuda kalır, yalnızca size açık" +"Show" = "Göster" +"Show all templates" = "Tüm şablonları göster" +"Show as grid" = "Izgara olarak göster" +"Show as list" = "Liste olarak göster" +"Show autosaves" = "Otomatik kayıtları göster" +"Show behind transparent areas" = "Saydam alanların arkasında göster" +"Show comments (Shift+C)" = "Yorumları göster (Shift+C)" +"Show device frame" = "Cihaz çerçevesini göster" +"Show dimensions on objects" = "Nesnelerde boyutları göster" +"Show hotspot hints" = "Etkin nokta ipuçlarını göster" +"Show in Explorer" = "Explorer'da göster" +"Show in Finder" = "Finder'da göster" +"Show in folder" = "Klasörde göster" +"Show only this on the canvas" = "Tuvalde yalnızca bunu göster" +"Show open" = "Açık olanları göster" +"Show section contents" = "Bölüm içeriğini göster" +"Show this layer" = "Bu katmanı göster" +"Show usage" = "Kullanımı göster" +"Show/Hide" = "Göster/Gizle" +"Show/Hide UI" = "Arayüzü göster/gizle" +"Side by side" = "Yan yana" +"Sidebar" = "Kenar çubuğu" +"Simplify stroke" = "Konturu sadeleştir" +"Size" = "Boyut" +"Size jitter" = "Boyut titreşimi" +"Skip ink" = "Mürekkebi atla" +"Slashed zero" = "Eğik çizgili sıfır" +"Slice" = "Dilim" +"Slices" = "Dilimler" +"Slide in" = "Kayarak gir" +"Slide out" = "Kayarak çık" +"Slot" = "Yuva" +"Slot content" = "Yuva içeriği" +"Small capitals" = "Küçük büyük harfler" +"Small caps" = "Küçük büyük harf" +"Smart animate" = "Akıllı canlandırma" +"Snap to pixel grid" = "Piksel ızgarasına yapıştır" +"Social and slides" = "Sosyal medya ve slaytlar" +"Social media" = "Sosyal medya" +"Social post sizes" = "Sosyal medya gönderi boyutları" +"Soft light" = "Yumuşak ışık" +"Solid" = "Düz" +"Some variants have the same property values: give each one a unique combination" = "Bazı varyantların özellik değerleri aynı: her birine benzersiz bir birleşim verin" +"Sort" = "Sırala" +"Sort by" = "Sıralama ölçütü" +"Sort by date" = "Tarihe göre sırala" +"Sort by page" = "Sayfaya göre sırala" +"Sort by {column}" = "{column} sütununa göre sırala" +"Space around" = "Etrafında boşluk" +"Space between" = "Aralarında boşluk" +"Space drag" = "Boşlukla sürükle" +"Space evenly" = "Eşit boşluk" +"Space+drag" = "Boşluk+sürükle" +"Spacing" = "Aralık" +"Spam, stolen work, abuse…" = "İstenmeyen içerik, çalıntı iş, kötüye kullanım…" +"Splay" = "Açılma" +"Spotlight me" = "Beni öne çıkar" +"Spread" = "Yayılma" +"Square" = "Kare" +"Standard" = "Standart" +"Standard ligatures" = "Standart bitişik harfler" +"Star" = "Yıldız" +"Stars" = "Yıldızlar" +"Start" = "Başlangıç" +"Start ascending from / stop descending at" = "Artan sıralamada buradan başla / azalan sıralamada burada dur" +"Start from a blank canvas" = "Boş bir tuvalle başla" +"Start from a template" = "Bir şablonla başla" +"Start on path" = "Yoldaki başlangıç" +"Start point" = "Başlangıç noktası" +"Start sharing" = "Paylaşmaya başla" +"Start sharing with the Share button, then pass on the code it gives" = "Paylaş düğmesiyle paylaşmaya başlayın, sonra verdiği kodu iletin" +"Starting to share…" = "Paylaşım başlatılıyor…" +"Status" = "Durum" +"Sticky" = "Yapışkan" +"Stop" = "Durdur" +"Stop following {name}" = "{name} takibini bırak" +"Stop session" = "Oturumu durdur" +"Stop sharing" = "Paylaşmayı durdur" +"Stop spotlighting" = "Öne çıkarmayı durdur" +"Stop {n} {hex}" = "Durak {n} {hex}" +"Stretch" = "Uzat" +"Strikethrough" = "Üstü çizili" +"String" = "Metin" +"Stroke" = "Kontur" +"Stroke style" = "Kontur stili" +"Stroke weight" = "Kontur kalınlığı" +"Strokes" = "Konturlar" +"Style" = "Stil" +"Styles" = "Stiller" +"Stylistic set 1" = "Stil seti 1" +"Stylistic set 2" = "Stil seti 2" +"Stylistic set 3" = "Stil seti 3" +"Stylistic set 4" = "Stil seti 4" +"Stylistic set 5" = "Stil seti 5" +"Subtract" = "Çıkar" +"Subtracts" = "Çıkarır" +"Suffix" = "Son ek" +"Suspend snapping while dragging" = "Sürüklerken yapışmayı askıya al" +"Swap instance" = "Örneği değiştir" +"Swap library: use another library's assets of the same names" = "Kitaplığı değiştir: aynı adlara sahip başka bir kitaplığın varlıklarını kullanın" +"Swap orientation" = "Yönü değiştir" +"Swap overlay" = "Bindirmeyi değiştir" +"Swap to {name}" = "{name} ile değiştir" +"Swap width and height" = "Genişlik ve yüksekliği değiştir" +"Swapped instance" = "Değiştirilmiş örnek" +"Swapped {n} assets" = { other = "{n} varlık değiştirildi" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { other = "{n} varlık değiştirildi; {missed} için eşleşme bulunamadı ve hâlâ eski kitaplığı kullanıyor" } +"Sweep" = "Tarama" +"System" = "Sistem" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "Tablo" +"Tablet" = "Tablet" +"Tabular numbers" = "Tablo rakamları" +"Take the values from the first selected layer" = "Değerleri ilk seçili katmandan al" +"Taper" = "İncelme" +"Taper both ends" = "İki ucu incelt" +"Tell the server's operator what is wrong with it" = "Sunucunun işletmecisine sorunun ne olduğunu söyleyin" +"Temperature" = "Sıcaklık" +"Template" = "Şablon" +"Templates" = "Şablonlar" +"Terms" = "Koşullar" +"Text" = "Metin" +"Text align" = "Metin hizalaması" +"Text content" = "Metin içeriği" +"Text style" = "Metin stili" +"Text styles" = "Metin stilleri" +"Texts" = "Metinler" +"Texture" = "Doku" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Tuval renkleri sRGB olarak önizler; dışa aktarılan PNG, JPG ve WebP dosyaları Display P3 olarak etiketlenir." +"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." = "Dosya sunucudan silinir: artık kimse onu oradan açamaz veya ona katılamaz ve bu işlem geri alınamaz. Açık olan kopyalar, kimdeyse onda kalır." +"The file now holds another library" = "Dosya artık başka bir kitaplık içeriyor" +"The file now holds another library (\"{name}\")" = "Dosya artık başka bir kitaplık içeriyor (“{name}”)" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "Bu bilgisayardan çevrimiçi paylaştığınız veya yayımladığınız dosyalar. Siz kaldırana kadar sunucularında kalırlar." +"The link carries the session's secret: share it only with people you invite." = "Bağlantı oturumun gizli anahtarını taşır: yalnızca davet ettiğiniz kişilerle paylaşın." +"The name in your online files and on the server. The document inside keeps its own name." = "Çevrimiçi dosyalarınızda ve sunucudaki ad. İçerideki belge kendi adını korur." +"The selection uses several text styles: click to apply one" = "Seçim birden fazla metin stili kullanıyor: birini uygulamak için tıklayın" +"The server deletes this file if nobody opens it before {date}." = "{date} tarihinden önce kimse açmazsa sunucu bu dosyayı siler." +"The whole file" = "Dosyanın tamamı" +"Theme" = "Tema" +"There's no destination for that action" = "Bu eylem için bir hedef yok" +"Thickness" = "Kalınlık" +"This branch can't be reviewed: {e}" = "Bu dal gözden geçirilemiyor: {e}" +"This branch was merged or archived." = "Bu dal birleştirildi veya arşivlendi." +"This computer only" = "Yalnızca bu bilgisayar" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Bu bilgisayar sahip belirtecini hatırlar, böylece dosya ana ekrandaki Çevrimiçi listesinden yönetilebilir. Dosyayı başka bir yerden yönetmek için kopyalayın: bu, dosyanın parolasıdır ve bir daha gösterilmez." +"This condition is edited as an expression" = "Bu koşul bir ifade olarak düzenleniyor" +"This file" = "Bu dosya" +"This file has unsaved changes" = "Bu dosyada kaydedilmemiş değişiklikler var" +"This file is on {server}, open only to you. Share it to let others in with a new code." = "Bu dosya {server} üzerinde ve yalnızca size açık. Başkalarını yeni bir kodla almak için paylaşın." +"This frame is new on the branch." = "Bu çerçeve dalda yeni." +"This frame isn't on the branch." = "Bu çerçeve dalda yok." +"This server is open to the public: no key needed." = "Bu sunucu herkese açık: anahtar gerekmez." +"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Bu sunucu anahtarını istiyor: adrese ?key=… ekleyin (`figcraft-server key` anahtarı sunucunun makinesinde yazdırır)." +"This server takes files only with its key: see Advanced." = "Bu sunucu dosyaları yalnızca anahtarıyla kabul ediyor: Gelişmiş'e bakın." +"Tidy up" = "Toparla" +"Tile" = "Döşe" +"Tile type" = "Döşeme türü" +"Tint" = "Renk tonu" +"Title" = "Başlık" +"Title case" = "Başlık biçimi" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Bu kitaplığı ekibinizle paylaşmak için dosyayı bir sunucuya yayımlayın (Paylaş ▸ Sunucuya yayımla…), sonra kitaplığı orada yayımlayın." +"Toggle" = "Aç/Kapat" +"Toggle light/dark theme" = "Açık/koyu temayı değiştir" +"Toggle visibility" = "Görünürlüğü değiştir" +"Tools" = "Araçlar" +"Top" = "Üst" +"Top & bottom" = "Üst ve alt" +"Top left radius" = "Sol üst yarıçap" +"Top right radius" = "Sağ üst yarıçap" +"Transform" = "Dönüştür" +"Triangle arrow" = "Üçgen ok" +"Trigger" = "Tetikleyici" +"True" = "Doğru" +"Truncate text" = "Metni kısalt" +"Truncate with an ellipsis" = "Üç noktayla kısalt" +"Try again" = "Yeniden dene" +"Try another search or filter." = "Başka bir arama veya filtre deneyin." +"Try another word, or look at every kind." = "Başka bir sözcük deneyin veya tüm türlere bakın." +"Turn a layer into a component, reuse it, and change every copy at once" = "Bir katmanı bileşene dönüştürün, yeniden kullanın ve tüm kopyalarını tek seferde değiştirin" +"Two digits" = "İki basamak" +"Type" = "Tür" +"Type settings" = "Yazı ayarları" +"Type the join code you were given, or paste an invite link." = "Size verilen katılma kodunu yazın veya bir davet bağlantısı yapıştırın." +"Typography" = "Tipografi" +"Underline" = "Altı çizili" +"Undo takes back only your own changes; what others changed since stays." = "Geri al yalnızca kendi değişikliklerinizi geri alır; o zamandan beri başkalarının yaptıkları kalır." +"Ungroup" = "Grubu çöz" +"Uniform" = "Tekdüze" +"Union" = "Birleştir" +"Unions" = "Birleşimler" +"Unknown — treated as sRGB" = "Bilinmiyor — sRGB olarak işlenir" +"Unread" = "Okunmamış" +"Unresolve" = "Çözülmedi yap" +"Unsaved changes" = "Kaydedilmemiş değişiklikler" +"Unsaved changes in {names}. Close the window and lose them?" = "{names} içinde kaydedilmemiş değişiklikler var. Pencere kapatılıp bunlar kaybedilsin mi?" +"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Kaydedilmemiş değişiklikler: önce kaydedin ({save}) veya atmak için sekmeyi {alt}-tıklamayla kapatın" +"Unsaved changes: save first, or close again with {key} to discard" = "Kaydedilmemiş değişiklikler: önce kaydedin veya atmak için {key} ile yeniden kapatın" +"Up" = "Yukarı" +"Update plugin" = "Eklentiyi güncelle" +"Update" = "Güncelle" +"Update from main" = "Ana dosyadan güncelle" +"Update from selection" = "Seçimden güncelle" +"Updated {components} and {styles}" = "{components} ve {styles} güncellendi" +"Updated {name}" = "Güncellendi: {name}" +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Bu dosyayı bir FigCraft sunucusuna yükler ve bu sekmeyi onunla eşitlenmiş tutar. Bir düzenleme ve bir görüntüleme bağlantısı alırsınız." +"Uppercase" = "Büyük harf" +"Use a fixed value" = "Sabit bir değer kullan" +"Use an expression, e.g. count + 1" = "Bir ifade kullanın, ör. count + 1" +"Use as mask" = "Maske olarak kullan" +"Use auto layout" = "Otomatik yerleşim kullan" +"Use number keys for opacity" = "Opaklık için rakam tuşlarını kullan" +"Use the layer's fill" = "Katmanın dolgusunu kullan" +"Used in {files} · {instances}" = "Kullanıldığı yer: {files} · {instances}" +"Username" = "Kullanıcı adı" +"Value" = "Değer" +"Uses variables: each layer shows them in its own mode" = "Değişken kullanır: her katman onları kendi modunda gösterir" +"Variable" = "Değişken" +"Variable collections" = "Değişken koleksiyonları" +"Variable modes" = "Değişken modları" +"Variable width" = "Değişken genişlik" +"Variables" = "Değişkenler" +"Variables JSON…" = "Değişken JSON'u…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "Değişkenler yeniden kullanılabilir değerleri (renk, sayı, metin, boolean) Açık ve Koyu gibi modlarla saklar." +"Variant" = "Varyant" +"Vector" = "Vektör" +"Vectors" = "Vektörler" +"Version" = "Sürüm" +"Version history" = "Sürüm geçmişi" +"Version {version}" = "Sürüm {version}" +"Version {v} · {components} · {styles} · {variables}" = "Sürüm {v} · {components} · {styles} · {variables}" +"Vertical" = "Dikey" +"Vertical align" = "Dikey hizalama" +"Vertical hex" = "Dikey altıgen" +"Vertical padding" = "Dikey iç boşluk" +"Vertical spacing" = "Dikey aralık" +"Vertical trim" = "Dikey kırpma" +"View" = "Görünüm" +"View link" = "Görüntüleme bağlantısı" +"View link copied" = "Görüntüleme bağlantısı kopyalandı" +"View only" = "Yalnızca görüntüleme" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Yalnızca görüntüleme: her değişikliği olduğu anda görürsünüz ama düzenleyemezsiniz. Düzenleyebileceğiniz bir kopya saklamak için ayrılın." +"Viewing “{name}”" = "“{name}” görüntüleniyor" +"Violet" = "Mor" +"Visibility" = "Görünürlük" +"Visible" = "Görünür" +"Vivid" = "Canlı" +"Warm" = "Sıcak" +"Watch" = "İzle" +"Wavy" = "Dalgalı" +"Web" = "Web" +"Websites" = "Web siteleri" +"WebSocket URL" = "WebSocket URL'si" +"WebSocket URL copied" = "WebSocket URL'si kopyalandı" +"Wedge" = "Kama" +"Weight" = "Kalınlık" +"What you try here changes this preview and the code below, not the file" = "Burada denedikleriniz dosyayı değil, bu önizlemeyi ve aşağıdaki kodu değiştirir" +"What changed since it was marked ready for dev" = "Geliştirmeye hazır olarak işaretlendiğinden beri neler değişti" +"What is wrong with this file? The server's operator gets your report." = "Bu dosyada sorun ne? Raporunuz sunucunun işletmecisine iletilir." +"What it's for" = "Ne için" +"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." = "Bunun neden değiştiği bilinmiyor: FigCraft geliştirmeye hazır çerçevelerin bir kopyasını saklamaya başlamadan önce değişti. Bundan sonra karşılaştırmak için değişikliği çözün (veya yeniden hazır olarak işaretleyin)." +"While hovering" = "Üzerine gelindiğinde" +"While pressing" = "Basılıyken" +"White" = "Beyaz" +"Who runs it, its terms and how long it keeps files" = "Kimin işlettiği, koşulları ve dosyaları ne kadar sakladığı" +"Why it changed" = "Neden değişti" +"Widgets" = "Widget'lar" +"Width" = "Genişlik" +"Width point" = "Genişlik noktası" +"Width profile" = "Genişlik profili" +"Wireframe kit" = "Tel kafes seti" +"With the layers' export settings" = "Katmanların dışa aktarma ayarlarıyla" +"Wrap" = "Kaydır" +"Wrap in new section" = "Yeni bölüme sar" +"Yellow" = "Sarı" +"Yesterday" = "Dün" +"You" = "Siz" +"You'll get the shared file as a new tab and edit it together." = "Paylaşılan dosyayı yeni bir sekme olarak alacak ve birlikte düzenleyeceksiniz." +"You're spotlighting: everyone follows your view" = "Öne çıkarıyorsunuz: herkes görünümünüzü takip ediyor" +"Your copy of the file stays open" = "Dosyanın kopyanız açık kalır" +"Your copy stays open; the file stays on the server" = "Kopyanız açık kalır; dosya sunucuda kalır" +"Your name" = "Adınız" +"Your name in comments" = "Yorumlardaki adınız" +"Your system asks for reduced motion: animations stay off." = "Sisteminiz hareketin azaltılmasını istiyor: animasyonlar kapalı kalır." +"Z" = "Z" +"Z to A" = "Z'den A'ya" +"Zoom" = "Yakınlaştırma" +"Zoom around the pointer" = "İşaretçinin çevresinde yakınlaştır" +"Zoom device to fill window" = "Cihazı pencereyi dolduracak şekilde yakınlaştır" +"Zoom in" = "Yakınlaştır" +"Zoom out" = "Uzaklaştır" +"Zoom to 100%" = "%100'e yakınlaştır" +"Zoom to 200%" = "%200'e yakınlaştır" +"Zoom to 50%" = "%50'ye yakınlaştır" +"Zoom to fit" = "Sığdırmak için yakınlaştır" +"Zoom to selection" = "Seçime yakınlaştır" +"Zoom tool: click to zoom in, drag an area" = "Yakınlaştırma aracı: yakınlaştırmak için tıklayın, bir alan sürükleyin" +"Zoom tool: zoom out" = "Yakınlaştırma aracı: uzaklaştır" +"and {n} more" = { other = "ve {n} tane daha" } +"bleed marks" = "taşma payı işaretleri" +"by {author}" = "yazan: {author}" +"call any command its access allows" = "erişiminin izin verdiği her komutu çağırmak" +"call only: {commands}" = "yalnızca şunları çağırmak: {commands}" +"can edit the document" = "belgeyi düzenleyebilir" +"crop marks" = "kesim işaretleri" +"development: {path}" = "geliştirme: {path}" +"e.g. count + 1" = "ör. count + 1" +"e.g. count >= 3 and not done" = "ör. count >= 3 and not done" +"figcraft:// links from a browser, chat or email open this app and join the session." = "Bir tarayıcıdan, sohbetten veya e-postadan gelen figcraft:// bağlantıları bu uygulamayı açar ve oturuma katılır." +"from {server}" = "kaynak: {server}" +"hidden" = "gizli" +"iOS" = "iOS" +"just now" = "az önce" +"locked" = "kilitli" +"no bleed" = "taşma payı yok" +"nobody else here yet" = "burada henüz başka kimse yok" +"now" = "şimdi" +"or" = "veya" +"put a widget on the canvas" = "tuvale bir widget koymak" +"read and edit the document" = "belgeyi okumak ve düzenlemek" +"read the document" = "belgeyi okumak" +"reads the document" = "belgeyi okur" +"the plugin" = "eklenti" +"this session only" = "yalnızca bu oturum" +"yesterday" = "dün" +"{instances} in {files}" = "{instances}, {files} içinde" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: taslak çeviri, ana dili konuşan birinin incelemesini bekliyor." +"{length} bleed" = "{length} taşma payı" +"{month} {day}, {year}" = "{day} {month} {year}" +"{name} cleared from the list" = "{name} listeden kaldırıldı" +"{name} is forgotten on this computer; the file stays on {server}" = "{name} bu bilgisayarda unutuldu; dosya {server} üzerinde kalır" +"{name} is on the server." = "{name} sunucuda." +"{name} is spotlighting" = "{name} öne çıkarıyor" +"{name}\nClick to apply to the selection" = "{name}\nSeçime uygulamak için tıklayın" +"{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}\nTuvale sürükleyin veya görünümün ortasına yerleştirmek için tıklayın.\nBir örneği değiştirmek için üzerine bırakırken Alt (Option) tuşunu basılı tutun." +"{name} (from {library})" = "{name} ({library} kitaplığından)" +"{name} (you)" = "{name} (siz)" +"{name} ({layers} layers)" = "{name} ({layers} katman)" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, varsayılan {default}. Stilin değerine dönmek için kaydırıcıya çift tıklayın; sürüklerken Escape iptal eder." +"{name} from {path}" = "{name}, kaynak: {path}" +"{name} was removed from the server" = "{name} sunucudan kaldırıldı" +"{name} · deleted on main" = "{name} · ana dosyada silindi" +"{name} · deleted on the branch" = "{name} · dalda silindi" +"{name} · not published" = "{name} · yayımlanmadı" +"{name} · on a server" = "{name} · bir sunucuda" +"{name} · published version {v}" = "{name} · yayımlanan sürüm {v}" +"{name} · version {v} on the server" = "{name} · sunucuda sürüm {v}" +"{name}, in a live session" = "{name}, canlı oturumda" +"{name}, online, open only to you" = "{name}, çevrimiçi, yalnızca size açık" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, seçili" +"{n} added" = { other = "{n} eklendi" } +"{n} autosave versions" = { other = "{n} otomatik kayıt sürümü" } +"{n} changed" = { other = "{n} değişti" } +"{n} columns" = { other = "{n} sütun" } +"{n} components" = { other = "{n} bileşen" } +"{n} components and styles have newer versions" = { other = "{n} bileşen ve stilin daha yeni sürümü var" } +"{n} conflicts to resolve" = { other = "Çözülecek {n} çakışma" } +"{n} days ago" = { other = "{n} gün önce" } +"{n} files" = { other = "{n} dosya" } +"{n} hours ago" = { other = "{n} saat önce" } +"{n} instances" = { other = "{n} örnek" } +"{n} layers" = { other = "{n} katman" } +"{n} layers as one file" = { other = "Tek dosya olarak {n} katman" } +"{n} layers selected" = { other = "{n} katman seçili" } +"{n} layers · {parts}" = { other = "{n} katman · {parts}" } +"{n} matches" = { other = "{n} eşleşme" } +"{n} minutes ago" = { other = "{n} dakika önce" } +"{n} months ago" = { other = "{n} ay önce" } +"{n} of {total}" = "{n} / {total}" +"{n} people joined" = { other = "{n} kişi katıldı" } +"{n} preferred components" = { other = "{n} tercih edilen bileşen" } +"{n} ready" = { other = "{n} hazır" } +"{n} removed" = { other = "{n} kaldırıldı" } +"{n} replies" = { other = "{n} yanıt" } +"{n} rows" = { other = "{n} satır" } +"{n} styles" = { other = "{n} stil" } +"{n} variables" = { other = "{n} değişken" } +"{n} weeks ago" = { other = "{n} hafta önce" } +"{n} years ago" = { other = "{n} yıl önce" } +"{n}d" = "{n} g" +"{n}h" = "{n} sa" +"{n}m" = "{n} dk" +"{n}w" = "{n} hf" +"{page}, nothing selected" = "{page}, hiçbir şey seçili değil" +"{profile} (current)" = "{profile} (geçerli)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} onun değeridir; {property ? \"a\" : \"b\"} bir metin seçer. Diğer {…} yazıldığı gibi kalır." +"{server} can't say what became of your files (it is older than this app)." = "{server}, dosyalarınıza ne olduğunu söyleyemiyor (bu uygulamadan eski)." +"{server} · No longer on the server" = "{server} · Artık sunucuda değil" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · Açılma: {date}" +"{tool} hold {key}" = "{tool} {key} basılı tutun" +"{value} isn't a colour" = "{value} bir renk değil" +"{when} · edited" = "{when} · düzenlendi" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"←" = "←" +"→, Space or N" = "→, Boşluk veya N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ oklar" +"⌘ click" = "⌘ tıklama" +"⌘ scroll" = "⌘ kaydırma" +"⌥ drag" = "⌥ sürükle" + +[commands] +"align.bottom" = "Alta hizala" +"align.hcenter" = "Yatay merkezleri hizala" +"align.left" = "Sola hizala" +"align.right" = "Sağa hizala" +"align.top" = "Üste hizala" +"align.vcenter" = "Dikey merkezleri hizala" +"annotation.add" = "Ek açıklama ekle" +"annotation.addCategory" = "Ek açıklama kategorisi ekle" +"annotation.categories" = "Ek açıklama kategorileri" +"annotation.edit" = "Ek açıklamayı düzenle" +"annotation.editCategory" = "Ek açıklama kategorisini düzenle" +"annotation.list" = "Ek açıklamaları listele" +"annotation.remove" = "Ek açıklamayı kaldır" +"annotation.removeCategory" = "Ek açıklama kategorisini sil" +"app.actions" = "Eylemler" +"app.preferences" = "Tercihler" +"arrange.back" = "En arkaya gönder" +"arrange.backward" = "Geriye gönder" +"arrange.forward" = "Öne getir" +"arrange.front" = "En öne getir" +"arrange.packH" = "Yatay sıkıştır" +"arrange.packV" = "Dikey sıkıştır" +"arrange.roundToPixels" = "Piksellere yuvarla" +"arrange.tidy" = "Toparla" +"bool.exclude" = "Seçimi hariç tut" +"bool.flatten" = "Boolean'ı düzleştir" +"bool.intersect" = "Seçimi kesiştir" +"bool.subtract" = "Seçimi çıkar" +"bool.union" = "Seçimi birleştir" +"branch.archive" = "Dalı arşivle" +"branch.create" = "Dal oluştur" +"branch.list" = "Dalları listele" +"branch.merge" = "Dalı birleştir" +"branch.open" = "Dalı aç" +"branch.review" = "Dalı gözden geçir" +"branch.updateFromMain" = "Ana dosyadan güncelle" +"codeConnect.export" = "Kod bağlantılarını dışa aktar" +"codeConnect.import" = "Kod bağlantılarını içe aktar" +"codeConnect.remove" = "Kod bağlantısını kes" +"codeConnect.set" = "Koda bağla" +"collab.cursor" = "İmleci şurada göster" +"collab.cursorChat" = "İmleç sohbeti" +"collab.diff" = "Eşitleme değişikliklerini göster" +"collab.files" = "Çevrimiçi dosyalarınız" +"collab.follow" = "Takip et" +"collab.forgetFile" = "Çevrimiçi dosyayı unut" +"collab.host" = "Canlı oturum başlat" +"collab.join" = "Canlı oturuma katıl" +"collab.leave" = "Canlı oturumdan ayrıl" +"collab.manageFile" = "Yayımlanan dosyayı yönet" +"collab.openFile" = "Çevrimiçi dosya aç" +"collab.peers" = "Bu dosyadaki kişiler" +"collab.publish" = "Sunucuya yayımla" +"collab.recoverFile" = "Çevrimiçi dosyayı kurtar" +"collab.registerLinks" = "Davet bağlantılarını FigCraft'ta aç" +"collab.report" = "Dosyayı sunucu işletmecisine bildir" +"collab.serverInfo" = "Sunucu bilgisi" +"collab.share" = "Katılma koduyla paylaş" +"collab.spotlight" = "Beni öne çıkar" +"collab.stop" = "Canlı oturumu durdur" +"command.batch" = "Birkaç komut çalıştır" +"comment.add" = "Yorum ekle" +"comment.delete" = "Yorumu sil" +"comment.edit" = "Yorumu düzenle" +"comment.link" = "Yorum bağlantısını kopyala" +"comment.list" = "Yorumları listele" +"comment.markRead" = "Yorumu okundu olarak işaretle" +"comment.move" = "Yorumu taşı" +"comment.next" = "Sonraki yorum" +"comment.open" = "Yorumu aç" +"comment.people" = "Yorumlardaki kişiler" +"comment.react" = "Yoruma tepki ver" +"comment.reply" = "Yorumu yanıtla" +"comment.resolve" = "Yorumu çöz" +"comment.zoom" = "Yorumlara yakınlaştır" +"component.addProperty" = "Bileşen özelliği ekle" +"component.addVariant" = "Varyant ekle" +"component.bindProperty" = "Katmanı özelliğe bağla" +"component.combineVariants" = "Varyant olarak birleştir" +"component.create" = "Bileşen oluştur" +"component.createMultiple" = "Birden çok bileşen oluştur" +"component.deleteProperty" = "Bileşen özelliğini sil" +"component.detach" = "Örneği ayır" +"component.editProperty" = "Bileşen özelliğini düzenle" +"component.exposeInstance" = "İç içe örneği aç" +"component.goToMain" = "Ana bileşene git" +"component.hideWhenPublishing" = "Yayımlarken gizle" +"component.list" = "Bileşenleri listele" +"component.makeSlot" = "Yuva yap" +"component.playground" = "Bileşen deneme alanı" +"component.properties" = "Bileşen özellikleri" +"component.pushChanges" = "Değişiklikleri ana bileşene gönder" +"component.removeSlot" = "Yuvayı kaldır" +"component.resetInstance" = "Tüm değişiklikleri sıfırla" +"component.resetOverride" = "Geçersiz kılmayı sıfırla" +"component.restoreMain" = "Ana bileşeni geri yükle" +"dev.compare" = "Değişiklikleri karşılaştır" +"dev.focus" = "Odak görünümü" +"dev.resolveChange" = "Değişikliği çöz" +"dev.unfocus" = "Odak görünümünden çık" +"devResource.add" = "Geliştirici kaynağı ekle" +"devResource.edit" = "Geliştirici kaynağını düzenle" +"devResource.list" = "Geliştirici kaynaklarını listele" +"devResource.remove" = "Geliştirici kaynağını kaldır" +"distribute.horizontal" = "Yatay aralığı dağıt" +"distribute.vertical" = "Dikey aralığı dağıt" +"doc.home" = "Ana sayfa" +"doc.list" = "Belgeleri listele" +"doc.nextTab" = "Sonraki sekme" +"doc.previousTab" = "Önceki sekme" +"doc.switch" = "Belge değiştir" +"edit.copy" = "Kopyala" +"edit.copyAsCss" = "CSS olarak kopyala" +"edit.copyAsPng" = "PNG olarak kopyala" +"edit.copyAsSvg" = "SVG olarak kopyala" +"edit.copyAsText" = "Metin olarak kopyala" +"edit.copyLink" = "Seçimin bağlantısını kopyala" +"edit.copyProperties" = "Özellikleri kopyala" +"edit.cut" = "Kes" +"edit.delete" = "Sil" +"edit.duplicate" = "Çoğalt" +"edit.findReplace" = "Bul ve değiştir" +"edit.paste" = "Yapıştır" +"edit.pasteOver" = "Seçimin üzerine yapıştır" +"edit.pasteProperties" = "Özellikleri yapıştır" +"edit.pasteReplace" = "Değiştirmek için yapıştır" +"edit.pickColor" = "Renk seç" +"edit.redo" = "Yinele" +"edit.repeat" = "Son komutu tekrarla" +"edit.setDefaultProperties" = "Varsayılan özellikleri ayarla" +"edit.undo" = "Geri al" +"effect.add" = "Efekt ekle" +"effect.move" = "Efekti taşı" +"effect.remove" = "Efekti kaldır" +"effect.set" = "Efekti ayarla" +"export.add" = "Dışa aktarma ayarı ekle" +"export.remove" = "Dışa aktarma ayarını kaldır" +"export.update" = "Dışa aktarma ayarını düzenle" +"file.close" = "Sekmeyi kapat" +"file.colorProfile" = "Renk profili" +"file.duplicate" = "Dosyayı çoğalt" +"file.export" = "Dışa aktar" +"file.exportFramesPdf" = "Çerçeveleri PDF olarak dışa aktar" +"file.exportLayers" = "Katmanları dışa aktar" +"file.exportSvg" = "Seçimi SVG olarak dışa aktar" +"file.import" = "İçe aktar" +"file.new" = "Yeni tasarım dosyası" +"file.newFromTemplate" = "Şablondan yeni" +"file.open" = "Aç" +"file.openRecent" = "Son kullanılanı aç" +"file.placeImage" = "Görüntü yerleştir" +"file.recover" = "Dosyayı kurtar" +"file.rename" = "Dosyayı yeniden adlandır" +"file.revert" = "Kaydedilene geri dön" +"file.save" = "Kaydet" +"file.saveAs" = "Farklı kaydet" +"file.saveVersion" = "Sürüm geçmişine kaydet" +"file.versionHistory" = "Sürüm geçmişini göster" +"fill.add" = "Dolgu ekle" +"fill.move" = "Dolguyu taşı" +"fill.pattern" = "Desen dolgusu" +"fill.remove" = "Dolguyu kaldır" +"fill.set" = "Dolguyu ayarla" +"frame.create" = "Çerçeve oluştur" +"frame.presets" = "Çerçeve ön ayarlarını listele" +"frame.swapOrientation" = "Yönü değiştir" +"grid.add" = "Yerleşim ızgarası ekle" +"grid.move" = "Yerleşim ızgarasını taşı" +"grid.remove" = "Yerleşim ızgarasını kaldır" +"guide.add" = "Kılavuz ekle" +"guide.clear" = "Tüm kılavuzları kaldır" +"guide.move" = "Kılavuzu taşı" +"guide.remove" = "Kılavuzu kaldır" +"guide.select" = "Kılavuzu seç" +"help.gettingStarted" = "Başlarken" +"help.about" = "FigCraft hakkında" +"help.page" = "Yardım sayfası" +"help.shortcuts" = "Klavye kısayolları" +"image.adjust" = "Görüntüyü ayarla" +"image.deletePreset" = "Görüntü ön ayarını sil" +"image.place" = "Görüntü yerleştir" +"image.presets" = "Görüntü ön ayarları" +"image.savePreset" = "Görüntü ön ayarını kaydet" +"inspect.assets" = "Varlıkları incele" +"inspect.code" = "Katmanın kodu" +"inspect.componentCode" = "Bileşen kodu" +"inspect.properties" = "Özellikleri incele" +"inspect.readyForDev" = "Geliştirmeye hazır" +"instance.create" = "Örnek oluştur" +"instance.exposed" = "Açılan iç içe örnekler" +"instance.place" = "Örnek yerleştir" +"instance.resetSlot" = "Yuvayı sıfırla" +"instance.setProperties" = "Örnek özelliklerini ayarla" +"instance.slotInsert" = "Yuvaya ekle" +"instance.swap" = "Örneği değiştir" +"instance.swapAt" = "Noktadaki örneği değiştir" +"layers.collapseAll" = "Katmanları daralt" +"layout.add" = "Otomatik yerleşim ekle" +"layout.remove" = "Otomatik yerleşimi kaldır" +"layout.setTrack" = "Izgara izi boyutunu ayarla" +"layout.suggest" = "Otomatik yerleşim öner" +"libraries.open" = "Kitaplıklar" +"libraries.publish" = "Kitaplığı yayımla…" +"library.acceptUpdates" = "Kitaplık güncellemelerini kabul et" +"library.add" = "Kitaplık ekle" +"library.aliasVariable" = "Kitaplık değişkenine takma ad ver" +"library.applyStyle" = "Kitaplık stilini uygula" +"library.bindVariable" = "Kitaplık değişkenini bağla" +"library.extendCollection" = "Kitaplık koleksiyonunu genişlet" +"library.import" = "Kitaplık bileşenini içe aktar" +"library.importVariable" = "Kitaplık değişkenini içe aktar" +"library.list" = "Kitaplıkları listele" +"library.place" = "Kitaplık bileşenini yerleştir" +"library.publish" = "Kitaplığı yayımla" +"library.publishToServer" = "Kitaplığı sunucuya yayımla" +"library.remove" = "Kitaplığı kaldır" +"library.setVariableMode" = "Kitaplık değişken modunu ayarla" +"library.swap" = "Kitaplığı değiştir" +"library.swapAt" = "Örneği kitaplık bileşeniyle değiştir" +"library.updates" = "Kitaplık güncellemeleri" +"library.usage" = "Kitaplık kullanımı" +"measure.distances" = "Mesafeleri ölç" +"measure.padding" = "Otomatik yerleşim iç boşluğunu ölç" +"node.create" = "Katman oluştur" +"node.get" = "Katmanı getir" +"node.reorder" = "Katmanı yeniden sırala" +"node.reparent" = "Katmanı içine taşı" +"node.set" = "Özellikleri ayarla" +"object.batchRename" = "Toplu yeniden adlandır" +"object.convertToFrame" = "Çerçeveye dönüştür" +"object.convertToGroup" = "Gruba dönüştür" +"object.flipH" = "Yatay çevir" +"object.flipV" = "Dikey çevir" +"object.frameSelection" = "Seçimi çerçevele" +"object.group" = "Seçimi grupla" +"object.hideOthers" = "Diğer katmanları gizle" +"object.mask" = "Maske olarak kullan" +"object.moveToPage" = "Sayfaya taşı" +"object.opacityKeys" = "Rakam tuşlarıyla opaklık" +"object.rasterize" = "Seçimi rasterleştir" +"object.releaseMask" = "Maskeyi bırak" +"object.removeFill" = "Dolguyu kaldır" +"object.removeFrame" = "Çerçeveyi kaldır" +"object.removeStroke" = "Konturu kaldır" +"object.rename" = "Yeniden adlandır" +"object.resizeToFit" = "Sığacak şekilde yeniden boyutlandır" +"object.rotate180" = "180° döndür" +"object.rotateLeft" = "90° sola döndür" +"object.rotateRight" = "90° sağa döndür" +"object.setThumbnail" = "Küçük resim olarak ayarla" +"object.swapFillStroke" = "Dolgu ve konturu değiştir" +"object.toggleLock" = "Seçimi kilitle/kilidini aç" +"object.toggleVisible" = "Seçimi göster/gizle" +"object.ungroup" = "Seçimin grubunu çöz" +"object.wrapInSection" = "Yeni bölüme sar" +"page.add" = "Sayfa ekle" +"page.delete" = "Sayfayı sil" +"page.duplicate" = "Sayfayı çoğalt" +"page.list" = "Sayfaları listele" +"page.move" = "Sayfayı taşı" +"page.rename" = "Sayfayı yeniden adlandır" +"page.select" = "Sayfaya git" +"page.setBackground" = "Sayfa arka planı" +"paint.documentColors" = "Belge renkleri" +"plugin.close" = "Eklenti panelini kapat" +"plugin.directory.install" = "Dizinden eklenti yükle" +"plugin.directory.list" = "Eklenti dizinine gözat" +"plugin.directory.publish" = "Eklentiyi dizine yayımla" +"plugin.directory.unpublish" = "Eklentiyi dizinden kaldır" +"plugin.event" = "Eklenti paneli denetimini kullan" +"plugin.import" = "Eklentiyi manifestten içe aktar…" +"plugin.list" = "Eklentileri listele" +"plugin.remove" = "Eklentiyi kaldır" +"plugin.run" = "Eklentiyi çalıştır" +"plugin.ui" = "Eklenti paneli" +"plugins.manage" = "Eklentileri yönet…" +"plugins.runLast" = "Son eklentiyi çalıştır" +"pref.highlightHover" = "Üzerine gelince katmanları vurgula" +"pref.keepTool" = "Kullanımdan sonra aracı seçili tut" +"pref.language" = "Arayüz dili" +"pref.nudge" = "Kaydırma miktarı" +"pref.numberKeysOpacity" = "Opaklık için rakam tuşlarını kullan" +"pref.publishServer" = "Çevrimiçi paylaşım sunucusu" +"pref.reduceMotion" = "Hareketi azalt" +"pref.renameDuplicates" = "Çoğaltılan katmanları yeniden adlandır" +"pref.showDimensions" = "Nesnelerde boyutları göster" +"pref.userName" = "Yorumlardaki adınız" +"proto.action.add" = "Prototip eylemi ekle" +"proto.action.move" = "Prototip eylemini yeniden sırala" +"proto.action.remove" = "Prototip eylemini kaldır" +"proto.action.set" = "Prototip eylemini düzenle" +"proto.closePreview" = "Prototip önizlemesini kapat" +"proto.conditional.addBlock" = "Değilse ve eğer / değilse ekle" +"proto.conditional.removeBlock" = "Eğer / değilse ve eğer / değilse kaldır" +"proto.conditional.setCondition" = "Koşulu düzenle" +"proto.evaluate" = "Prototip ifadesini değerlendir" +"proto.flow.add" = "Akış başlangıç noktası ekle" +"proto.flow.remove" = "Akışı kaldır" +"proto.flow.rename" = "Akışı yeniden adlandır" +"proto.interaction.add" = "Etkileşim ekle" +"proto.interaction.clear" = "Tüm etkileşimleri kaldır" +"proto.interaction.remove" = "Etkileşimi kaldır" +"proto.interaction.set" = "Etkileşimi düzenle" +"proto.present" = "Sun" +"proto.presentOptions" = "Sunum seçenekleri" +"proto.preview" = "Prototip önizlemesini aç" +"proto.settings" = "Prototip ayarları" +"section.toggleContents" = "Bölüm içeriğini gizle/göster" +"select.add" = "Seçime ekle" +"select.all" = "Tümünü seç" +"select.area" = "Alan seç" +"select.byName" = "Katmanları ada göre seç" +"select.children" = "Alt öğeleri seç" +"select.inverse" = "Seçimi tersine çevir" +"select.layersAt" = "Bir noktadaki katmanlar" +"select.matching" = "Eşleşen katmanları seç" +"select.next" = "Sonraki kardeşi seç" +"select.none" = "Seçimi kaldır" +"select.parent" = "Üst öğeyi seç" +"select.previous" = "Önceki kardeşi seç" +"select.remove" = "Seçimden çıkar" +"select.sameEffect" = "Aynı efekti seç" +"select.sameFill" = "Aynı dolguyu seç" +"select.sameFont" = "Aynı yazı tipini seç" +"select.sameInstance" = "Aynı örneği seç" +"select.sameProperties" = "Aynı özellikleri seç" +"select.sameStroke" = "Aynı konturu seç" +"select.sameText" = "Aynı metin özelliklerini seç" +"select.set" = "Seç" +"shape.arrow" = "Ok" +"shape.ellipse" = "Elips" +"shape.line" = "Çizgi" +"shape.polygon" = "Çokgen" +"shape.rectangle" = "Dikdörtgen" +"shape.star" = "Yıldız" +"shapeBuilder.merge" = "Bölgeleri birleştir" +"shapeBuilder.mode" = "Şekil oluşturucu modu" +"shapeBuilder.regions" = "Şekil oluşturucu bölgeleri" +"shapeBuilder.remove" = "Bölgeleri kaldır" +"stroke.add" = "Kontur ekle" +"stroke.brush" = "Fırça" +"stroke.move" = "Konturu taşı" +"stroke.remove" = "Konturu kaldır" +"stroke.set" = "Konturu ayarla" +"stroke.widthProfile" = "Genişlik profili" +"style.apply" = "Stili uygula" +"style.bind" = "Stilde değişken bağla" +"style.create" = "Stil oluştur" +"style.delete" = "Stili sil" +"style.detach" = "Stili ayır" +"style.duplicate" = "Stili çoğalt" +"style.list" = "Stilleri listele" +"style.move" = "Stilleri taşı" +"style.renameFolder" = "Stil klasörünü yeniden adlandır" +"style.update" = "Stili düzenle" +"text.alignCenter" = "Ortaya hizala" +"text.alignJustify" = "İki yana yasla" +"text.alignLeft" = "Sola hizala" +"text.alignRight" = "Sağa hizala" +"text.bold" = "Kalın" +"text.bulletList" = "Madde işaretli liste" +"text.case" = "Harf durumu" +"text.caseLower" = "Küçük harf" +"text.caseOriginal" = "Özgün harf durumu" +"text.caseSmallCaps" = "Küçük büyük harf" +"text.caseTitle" = "Başlık biçimi" +"text.caseUpper" = "Büyük harf" +"text.create" = "Metin" +"text.decoration" = "Süsleme" +"text.edit.begin" = "Metni düzenle" +"text.edit.caret" = "İmleci konumlandır" +"text.edit.end" = "Metin düzenlemeyi bitir" +"text.edit.insert" = "Metin yaz" +"text.edit.key" = "Metin tuşu" +"text.edit.preedit" = "Metin oluştur (IME)" +"text.edit.selectLine" = "Satırı seç" +"text.edit.selectWord" = "Sözcüğü seç" +"text.edit.selection" = "Metin seçimi" +"text.endPreview" = "Metin önizlemesini bitir" +"text.fonts" = "Yazı tiplerini listele" +"text.italic" = "İtalik" +"text.lineHeightDown" = "Satır yüksekliğini azalt" +"text.lineHeightUp" = "Satır yüksekliğini artır" +"text.link" = "Bağlantı oluştur" +"text.listIndent" = "Listeyi girintile" +"text.listOutdent" = "Liste girintisini azalt" +"text.listStyle" = "Liste stili" +"text.maxLines" = "En fazla satır" +"text.numberList" = "Numaralı liste" +"text.onPath" = "Yoldaki metin" +"text.paragraphIndent" = "Paragraf girintisi" +"text.paragraphSpacing" = "Paragraf aralığı" +"text.pathOptions" = "Metin yolu seçenekleri" +"text.preview" = "Metin değişikliğini önizle" +"text.removeFromPath" = "Metni yoldan kaldır" +"text.set" = "Metni ayarla" +"text.setFeatures" = "OpenType özellikleri" +"text.setVariations" = "Değişken yazı tipi eksenleri" +"text.sizeDown" = "Yazı tipi boyutunu küçült" +"text.sizeUp" = "Yazı tipi boyutunu büyüt" +"text.strikethrough" = "Üstü çizili" +"text.style" = "Metin aralığına stil uygula" +"text.trackingDown" = "Harf aralığını azalt" +"text.trackingUp" = "Harf aralığını artır" +"text.underline" = "Altı çizili" +"text.weightDown" = "Yazı tipi kalınlığını azalt" +"text.weightUp" = "Yazı tipi kalınlığını artır" +"tool.arrow" = "Ok" +"tool.comment" = "Yorum" +"tool.ellipse" = "Elips" +"tool.frame" = "Çerçeve" +"tool.hand" = "El" +"tool.line" = "Çizgi" +"tool.move" = "Taşı" +"tool.pen" = "Kalem" +"tool.pencil" = "Kurşun kalem" +"tool.pencilBrush" = "Kurşun kalem fırçası" +"tool.place" = "Araçla yerleştir" +"tool.polygon" = "Çokgen" +"tool.rectangle" = "Dikdörtgen" +"tool.scale" = "Ölçekle" +"tool.scaleAnchor" = "Ölçekleme bağlantı noktası" +"tool.section" = "Bölüm" +"tool.set" = "Araç seç" +"tool.shapeBuilder" = "Şekil oluşturucu" +"tool.slice" = "Dilim" +"tool.star" = "Yıldız" +"tool.text" = "Metin" +"transform.move" = "Taşı" +"transform.moveTo" = "Şuraya taşı" +"transform.nudge" = "Kaydır" +"transform.nudgeSize" = "Kaydırma ile yeniden boyutlandır" +"transform.resize" = "Yeniden boyutlandır" +"transform.rotate" = "Döndür" +"transform.rotationOrigin" = "Döndürme merkezini taşı" +"transform.scale" = "Ölçekle" +"transform.setRotationOrigin" = "Döndürme merkezini ayarla" +"variable.addMode" = "Mod ekle" +"variable.bind" = "Değişken bağla" +"variable.create" = "Değişken oluştur" +"variable.createCollection" = "Değişken koleksiyonu oluştur" +"variable.delete" = "Değişkeni sil" +"variable.deleteCollection" = "Koleksiyonu sil" +"variable.duplicate" = "Değişkeni çoğalt" +"variable.export" = "Değişkenleri dışa aktar" +"variable.import" = "Değişkenleri içe aktar" +"variable.list" = "Değişkenleri listele" +"variable.moveMode" = "Modu taşı" +"variable.removeMode" = "Modu sil" +"variable.renameCollection" = "Koleksiyonu yeniden adlandır" +"variable.renameMode" = "Modu yeniden adlandır" +"variable.resetOverride" = "Değişken geçersiz kılmasını sıfırla" +"variable.set" = "Değişken değerini ayarla" +"variable.setMode" = "Değişken modunu ayarla" +"variable.update" = "Değişkeni düzenle" +"vector.create" = "Vektör" +"vector.deleteHeal" = "Seçimi sil ve onar" +"vector.edit.addVertex" = "Nokta ekle" +"vector.edit.begin" = "Vektörü düzenle" +"vector.edit.bend" = "Parçayı bük" +"vector.edit.bounds" = "Seçili noktaların sınırları" +"vector.edit.connect" = "Noktaları bağla" +"vector.edit.cornerRadius" = "Nokta köşe yarıçapı" +"vector.edit.cut" = "Yolu kes" +"vector.edit.delete" = "Noktaları sil" +"vector.edit.end" = "Düzenlemeyi bitir" +"vector.edit.erase" = "Sil" +"vector.edit.lasso" = "Kement seçimi" +"vector.edit.mirroring" = "Tutamaç yansıtma" +"vector.edit.moveHandles" = "Tutamaçları taşı" +"vector.edit.moveVertices" = "Noktaları taşı" +"vector.edit.nudge" = "Noktaları kaydır" +"vector.edit.paint" = "Boya kovası" +"vector.edit.pen" = "Kalem noktası" +"vector.edit.penEnd" = "Kalem yolunu bitir" +"vector.edit.position" = "Nokta konumu" +"vector.edit.resize" = "Noktaları yeniden boyutlandır" +"vector.edit.rotate" = "Noktaları döndür" +"vector.edit.select" = "Noktaları seç" +"vector.edit.selectAll" = "Tüm noktaları seç" +"vector.edit.setHandle" = "Tutamacı ayarla" +"vector.edit.smooth" = "Noktaları yumuşat veya keskinleştir" +"vector.edit.state" = "Vektör düzenleme durumu" +"vector.edit.strokeCap" = "Nokta kontur ucu" +"vector.edit.strokeJoin" = "Nokta kontur birleşimi" +"vector.edit.tool" = "Vektör düzenleme aracı" +"vector.edit.transform" = "Noktaları dönüştür" +"vector.edit.widthPoint" = "Genişlik noktası" +"vector.flatten" = "Düzleştir" +"vector.join" = "Seçimi birleştir" +"vector.offset" = "Yolu ötele" +"vector.outlineStroke" = "Konturu ana hatlara dönüştür" +"vector.reverse" = "Yol yönünü ters çevir" +"vector.simplify" = "Vektörü sadeleştir" +"vector.smoothJoin" = "Seçimi yumuşak birleştir" +"version.autosave" = "Otomatik kayıt sürümü" +"version.delete" = "Sürümü sil" +"version.duplicate" = "Sürümü çoğalt" +"version.exitPreview" = "Geçerli sürüme dön" +"version.list" = "Sürümleri listele" +"version.preview" = "Sürümü görüntüle" +"version.rename" = "Sürümü yeniden adlandır" +"version.restore" = "Sürümü geri yükle" +"view.assets" = "Varlıklar paneli" +"view.comments" = "Yorumlar" +"view.contextMenu" = "Bağlam menüsünü göster" +"view.design" = "Tasarım paneli" +"view.designPrototype" = "Tasarım ve Prototip arasında geçiş yap" +"view.devMode" = "Geliştirici Modu" +"view.frameOutlines" = "Çerçeve ana hatları" +"view.get" = "Görünümü getir" +"view.layers" = "Katmanlar paneli" +"view.layoutGrids" = "Yerleşim ızgaraları" +"view.leftSidebar" = "Sol kenar çubuğunu göster/gizle" +"view.maskOutlines" = "Maske ana hatları" +"view.multiplayerCursors" = "Çok oyunculu imleçler" +"view.nextFrame" = "Sonraki çerçeveye yakınlaştır" +"view.nextPage" = "Sonraki sayfa" +"view.nextRegion" = "Sonraki bölgeye odaklan" +"view.outlines" = "Ana hatlar" +"view.outlinesHiddenLayers" = "Ana hatlara gizli katmanları dahil et" +"view.outlinesObjectBounds" = "Ana hatlara nesne sınırlarını dahil et" +"view.outlinesSelection" = "Seçimin ana hatları" +"view.pan" = "Görünümü kaydır" +"view.panelWidths" = "Panel genişliklerini sıfırla" +"view.pixelGrid" = "Piksel ızgarası" +"view.pixelPreview" = "Piksel önizlemesi" +"view.prevFrame" = "Önceki çerçeveye yakınlaştır" +"view.prevPage" = "Önceki sayfa" +"view.previousRegion" = "Önceki bölgeye odaklan" +"view.proofSrgb" = "sRGB ekran provası" +"view.fullscreen" = "Tam ekran" +"view.prototype" = "Prototip paneli" +"view.rulers" = "Cetveller" +"view.set" = "Görünümü ayarla" +"view.slices" = "Dilimler" +"view.snapGeometry" = "Geometriye yapıştır" +"view.snapObjects" = "Nesnelere yapıştır" +"view.snapPixel" = "Piksel ızgarasına yapıştır" +"view.themeDark" = "Koyu tema" +"view.themeLight" = "Açık tema" +"view.themeSystem" = "Sistem temasını izle" +"view.toggleUi" = "Arayüzü göster/gizle" +"view.zoom100" = "%100'e yakınlaştır" +"view.zoom200" = "%200'e yakınlaştır" +"view.zoom50" = "%50'ye yakınlaştır" +"view.zoomAt" = "Yakınlaştırma aracı" +"view.zoomFit" = "Sığdırmak için yakınlaştır" +"view.zoomIn" = "Yakınlaştır" +"view.zoomOut" = "Uzaklaştır" +"view.zoomSelection" = "Seçime yakınlaştır" +"widget.click" = "Widget'a tıkla" +"widget.insert" = "Widget ekle" +"widget.list" = "Widget'ları listele" +"window.focus" = "Pencereyi öne getir" +"window.new" = "Yeni pencere" +"template.list" = "Şablonları listele" + +[menu] +"bool.exclude" = "Hariç tut" +"bool.flatten" = "Düzleştir" +"bool.intersect" = "Kesiştir" +"bool.subtract" = "Çıkar" +"bool.union" = "Birleştir" +"file.duplicate" = "Çoğalt" +"file.import" = "Dosyadan içe aktar…" +"file.rename" = "Yeniden adlandır" +"libraries.open" = "Kitaplıkları aç" +"select.sameEffect" = "Efekt" +"select.sameFill" = "Dolgu" +"select.sameFont" = "Yazı tipi" +"select.sameInstance" = "Örnek" +"select.sameProperties" = "Özellikler" +"select.sameStroke" = "Kontur" +"select.sameText" = "Metin özellikleri" +"text.alignCenter" = "Metni ortala" +"text.alignJustify" = "Metni iki yana yasla" +"text.alignLeft" = "Metni sola hizala" +"text.alignRight" = "Metni sağa hizala" +"view.assets" = "Varlıklar" +"view.design" = "Tasarım" +"view.layers" = "Katmanlar" +"view.outlines" = "Ana hatları göster" +"view.outlinesHiddenLayers" = "Gizli katmanları dahil et" +"view.outlinesObjectBounds" = "Nesne sınırlarını dahil et" +"view.outlinesSelection" = "Seçimin ana hatlarını göster" +"view.prototype" = "Prototip" +"view.themeDark" = "Koyu" +"view.themeLight" = "Açık" +"view.themeSystem" = "Sistemi izle" diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs index 354d0e605..e1833885a 100644 --- a/crates/ui-egui/src/cjk.rs +++ b/crates/ui-egui/src/cjk.rs @@ -128,7 +128,7 @@ mod tests { assert_eq!(language("zh-Hant"), HAN); assert_eq!(language("zh-Hans"), HAN); assert_eq!(language("fr"), 0); - assert!(["en", "es", "es-419", "fr", "de", "pt-BR"].iter().all(|c| can_show(c))); + assert!(["en", "es", "es-419", "fr", "de", "pt-BR", "tr"].iter().all(|c| can_show(c))); } #[test] diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 768434e34..6b545de93 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -309,6 +309,7 @@ fn system_languages_pick_the_first_one_with_a_catalog() { assert_eq!(code("system", Some("fr-CA")), "fr"); assert_eq!(code("system", Some("de_CH.UTF-8")), "de"); assert_eq!(code("system", Some("pt-BR")), "pt-BR"); + assert_eq!(code("system", Some("tr_TR.UTF-8")), "tr"); // 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"); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 99f0f1a70..7f1af8c60 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,10 +1,10 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Turkish draft catalog; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog and Spanish (with a Latin -American overlay), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese -drafts (machine-drafted, waiting for native speakers' review). This page lists the twelve key +American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and +Turkish drafts (machine-drafted, waiting for native speakers' review). This page lists the twelve key languages of the craftrules standard, then the other languages Figma ships. How to add a language: [`translating.md`](translating.md). @@ -29,7 +29,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v and 29 menu wordings by command id: 1,982 entries. Command ids, the CLI, MCP and the control channel stay English. Accessibility names come from the same strings. - **Language preference:** Preferences ▸ Language (System, English, Deutsch, Español, Español - (Latinoamérica), Français, 日本語, 한국어, Português (Brasil), 简体中文), applied at once without a + (Latinoamérica), Français, 日本語, 한국어, Português (Brasil), Türkçe, 简体中文), applied at once without a restart; System takes the first of the OS's preferred languages that has a catalog (Windows' language list, macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional tag falls back to its language, pt-PT or pt to pt-BR, and es-419, es-MX, es-AR, es-CO and the other diff --git a/docs/translating.md b/docs/translating.md index b7c26a401..e7ad6442b 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -13,8 +13,8 @@ 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`) -and Simplified Chinese (`zh-Hans`), machine-drafted and waiting for native speakers' review. +`es-419`), French (`fr`), German (`de`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), Korean (`ko`), +Simplified Chinese (`zh-Hans`) and Turkish (`tr`), machine-drafted and waiting for native speakers' review. ## The catalogs @@ -43,7 +43,9 @@ Enter for the key, control for a gamepad, resorte) and keeps Spanish's tú. Japa concise, without です/ます endings, with full-width ()「」:、。 where Japanese UI text has them; Korean labels end without 합니다 (instructions in -세요). Simplified Chinese is concise, with full-width ():,、。“” in Chinese text and a space between Chinese and Latin words or numbers. -None of the three has plural forms. +None of the three has plural forms. Turkish uses bare imperatives for buttons and menu items +(Kaydet, Sil) and the polite -in/-ın for instructions (seçin, tıklayın), puts the percent sign before +the number (%50) and keeps a noun singular after a count, so its plural entries give only `other`. CJK text needs a CJK font, which egui and Inter lack: the desktop app adds an installed one when something on screen needs it (see `crates/ui-egui/src/cjk.rs`). The web build has no system fonts, @@ -65,8 +67,8 @@ language also needs its script in `cjk::language`. 4. To require full coverage, add the code to `COMPLETE` in [`crates/ui-egui/src/i18n_tests.rs`](../crates/ui-egui/src/i18n_tests.rs). Every later PR that adds a string then has to translate it into each complete language, so only Spanish is there: - French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese are registered as - drafts, and a string added later shows English in them until it is translated. To list what a draft lacks, + French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Turkish are + registered as drafts, and a string added later shows English in them until it is translated. To list what a draft lacks, add its code to `COMPLETE` locally and run the check. 5. Run the check and look at the panels in the app (longer words must fit the right panel): From 4964910cc9a854d47978fb9c36c0620ac35d912c Mon Sep 17 00:00:00 2001 From: VanTan <77483789+VanTan666@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:34:14 +0300 Subject: [PATCH 005/120] M7: follow system theme from the first frame --- ROADMAP.md | 3 +- contributors/people.toml | 3 + crates/engine/src/lib.rs | 3 +- crates/ui-egui/src/lib.rs | 7 +- crates/ui-egui/src/theme_startup_tests.rs | 85 +++++++++++++++++++++++ docs/ui-parity.md | 5 +- 6 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/theme_startup_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29b..c7a49edda 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -312,6 +312,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/contributors/people.toml b/contributors/people.toml index e61cfd31a..30482465b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -16,3 +16,6 @@ # # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. + +[people.VanTan666] +display_name = "VanTan" diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a906..7669ab470 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -552,6 +552,7 @@ pub struct Prefs { pub highlight_on_hover: bool, pub number_keys_opacity: bool, pub show_dimensions: bool, + /// "system" by default; an explicit "light" or "dark" choice stays saved. pub theme: String, /// The widths of the main window's left and right panels (the interface keeps them within its /// limits; other windows have their own while they are open). @@ -611,7 +612,7 @@ impl Default for Prefs { highlight_on_hover: true, number_keys_opacity: true, show_dimensions: true, - theme: "light".into(), + theme: "system".into(), panel_widths: (240.0, 240.0), language: "system".into(), user_name: cmd::comment::DEFAULT_AUTHOR.into(), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f98..29be33aba 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -63,6 +63,9 @@ pub mod versions; pub mod widgets; pub mod windows; +#[cfg(test)] +mod theme_startup_tests; + #[cfg(test)] mod a11y_tests; #[cfg(test)] @@ -888,7 +891,9 @@ impl FigApp { } let dark = match self.session.prefs.theme.as_str() { "dark" => true, - "system" => ctx.global_style().visuals.dark_mode, + // The active visuals were set by us last frame (possibly for a manual theme). + // Read the integration's OS theme instead; without one, keep the light fallback. + "system" => ctx.system_theme() == Some(egui::Theme::Dark), _ => false, }; self.ui.dark = dark; diff --git a/crates/ui-egui/src/theme_startup_tests.rs b/crates/ui-egui/src/theme_startup_tests.rs new file mode 100644 index 000000000..6f2b13e24 --- /dev/null +++ b/crates/ui-egui/src/theme_startup_tests.rs @@ -0,0 +1,85 @@ +//! Startup and system-theme changes through the real UI frame, before any settling frames. + +use egui::{Context, RawInput, Theme}; +use figcraft_engine::{Prefs, Session}; +use serde_json::json; + +use crate::{FigApp, NoServices, theme::Tokens}; + +fn frame(ctx: &Context, app: &mut FigApp, system: Option) { + let mut output = ctx.run_ui(RawInput { system_theme: system, ..Default::default() }, |ui| app.ui(ui)); + output.textures_delta.clear(); +} + +#[test] +fn fresh_app_uses_system_dark_theme_on_its_first_frame() { + let ctx = Context::default(); + let mut app = FigApp::new(Session::empty(), Box::new(NoServices)); + frame(&ctx, &mut app, Some(Theme::Dark)); + assert!(app.ui.dark, "a first run must use the OS dark theme before drawing"); + assert_eq!(Tokens::get(&ctx), Tokens::dark()); + assert_eq!(app.session.prefs.theme, "system", "following the OS must remain a preference, not a saved snapshot"); +} + +#[test] +fn first_run_and_preferences_without_a_theme_follow_system_light() { + for prefs in [Prefs::default(), serde_json::from_value(json!({"nudge_small": 2})).unwrap()] { + let ctx = Context::default(); + let mut session = Session::empty(); + session.prefs = prefs; + let mut app = FigApp::new(session, Box::new(NoServices)); + frame(&ctx, &mut app, Some(Theme::Light)); + assert_eq!(app.session.prefs.theme, "system"); + assert_eq!(Tokens::get(&ctx), Tokens::light()); + } +} + +#[test] +fn saved_manual_themes_survive_startup_and_os_changes() { + for (saved, expected) in [("light", Tokens::light()), ("dark", Tokens::dark())] { + let ctx = Context::default(); + let mut session = Session::empty(); + session.prefs = serde_json::from_value(json!({"theme": saved})).unwrap(); + let mut app = FigApp::new(session, Box::new(NoServices)); + for system in [Theme::Dark, Theme::Light, Theme::Dark] { + frame(&ctx, &mut app, Some(system)); + assert_eq!(Tokens::get(&ctx), expected); + assert_eq!(app.session.prefs.theme, saved); + } + } +} + +#[test] +fn returning_to_system_reads_the_os_not_the_previously_drawn_palette() { + for (manual, os) in [("view.themeLight", Theme::Dark), ("view.themeDark", Theme::Light)] { + let ctx = Context::default(); + let mut app = FigApp::new(Session::empty(), Box::new(NoServices)); + app.session.execute(manual, &json!({})).unwrap(); + frame(&ctx, &mut app, Some(os)); + app.session.execute("view.themeSystem", &json!({})).unwrap(); + frame(&ctx, &mut app, Some(os)); + assert_eq!(app.ui.dark, os == Theme::Dark); + assert_eq!(Tokens::get(&ctx).dark, os == Theme::Dark); + } +} + +#[test] +fn system_theme_changes_apply_in_the_same_frame() { + let ctx = Context::default(); + let mut app = FigApp::new(Session::empty(), Box::new(NoServices)); + app.session.execute("view.themeSystem", &json!({})).unwrap(); + for os in [Theme::Dark, Theme::Light, Theme::Dark] { + frame(&ctx, &mut app, Some(os)); + assert_eq!(Tokens::get(&ctx).dark, os == Theme::Dark); + assert_eq!(app.session.prefs.theme, "system"); + } +} + +#[test] +fn unavailable_system_theme_uses_the_existing_light_fallback() { + let ctx = Context::default(); + let mut app = FigApp::new(Session::empty(), Box::new(NoServices)); + app.session.execute("view.themeSystem", &json!({})).unwrap(); + frame(&ctx, &mut app, None); + assert_eq!(Tokens::get(&ctx), Tokens::light()); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6de538e7a..ae01bac92 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (system theme on first run; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -16,7 +16,7 @@ interaction feel not yet compared. | Item | Status | Notes | |---|---|---| | UI3 layout: tab strip as title bar, icon rail, file header, pages + layers, floating bottom toolbar with flyouts, Design / Prototype / Inspect panels | done | frameless window on Windows/Linux with drawn buttons | -| Light and dark themes | done | colours from `theme::Tokens`; a file or a page made while the dark theme shows gets a `#1E1E1E` page (`#F5F5F5` in the light theme), its own from then on: changing the theme repaints no page and opened files keep theirs; the file the app opens with takes the theme once it is known; the CLI, MCP and exports have no theme and keep `#F5F5F5`; text drawn with the Text tool is white on a dark page and black on a light one (so is text a command makes in the app without naming a fill; headless sessions keep black) (`theme_pages_tests`, `dark_page_text_tests`) | +| Light and dark themes | done | first runs and preferences without a theme follow the OS on the first frame; saved Light/Dark choices stay explicit. System reads the integration's OS appearance, including when returning from a manual choice and on OS changes; unavailable appearance falls back to Light (`theme_startup_tests`). Colours from `theme::Tokens`; a file or a page made while the dark theme shows gets a `#1E1E1E` page (`#F5F5F5` in the light theme), its own from then on: changing the theme repaints no page and opened files keep theirs; the file the app opens with takes the theme once it is known; the CLI, MCP and exports have no theme and keep `#F5F5F5`; text drawn with the Text tool is white on a dark page and black on a light one (so is text a command makes in the app without naming a fill; headless sessions keep black) (`theme_pages_tests`, `dark_page_text_tests`) | | Minimize UI (Cmd+\\) | done | | | Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points | | Actions palette (Cmd+K, Cmd+/) | done | commands; no assets search parity | @@ -164,6 +164,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | System theme on first run: default System, OS appearance on the first frame and when returning from a manual theme; saved Light/Dark choices stay explicit; Light fallback without OS appearance (`theme_startup_tests`). | | 2026-10-14 | minor | Home screen recent files as cards with thumbnails on their page colour, a sortable table view, selection, keyboard and a context menu | | 2026-10-13 | minor | Vector edit mode regions: with the Move tool the filled area under the pointer is hatched with "/" lines in the selection colour (about 1.4 px), clipped by the area's own fill rule so holes stay clear, and the hatch goes when the pointer is 3.5 px from a point or 2 px from a segment (screen pixels at any zoom); the spacing follows octave folding, fitted to measurements at several zoom levels (8 px at every power-of-two zoom, the gap growing with the zoom and stepping back at √2 · 2ⁿ), anchored to the vector so it doesn't slide when panning, one continuous pattern across selected areas; a click selects an area with its points (they move with it but keep the unselected look), Shift adds an area or takes a selected one out, a press on a selected area drags everything selected, drags show no hover hatch, a click on an empty spot (such as a hole) clears the areas and stays in edit mode, the last area listed wins where areas overlap, only filled vectors have areas; `vector.edit.select {regions}` and `vector.edit.state` report them (`regions_tests`, `vector_regions_tests`) | | 2026-10-13 | minor | Home screen: a Templates tab (eight starter templates as cards with thumbnails, search and a kind filter; click or Enter opens a new document) replaces the Community placeholder, the Drafts placeholder is removed, Recents offers four templates while there are no recent files and shows a Getting started card (closable; Help ▸ Getting started brings it back), File ▸ New from template…, and a first run opens on the home screen | From dde193cbf44ebe1bdd74af043ed728a7cd17d9c3 Mon Sep 17 00:00:00 2001 From: ahmed-226 Date: Wed, 14 Oct 2026 09:36:52 +0300 Subject: [PATCH 006/120] M7: a slice's export preview shows the page content under it A slice paints nothing itself, so thumbnail() rendered an empty pixmap of the slice's own box: expanding Preview for a slice showed only the transparency checkerboard while exporting the same slice wrote the artwork correctly. Give the preview the export's slice rule (drawn_layers + export_area in cmd/file.rs): draw every top-level layer of the slice's page over its world bounds. Regression tests cover a slice over artwork, a slice with nothing under it, and a plain layer. Signed-off-by: Ahmed Mahmoud --- crates/ui-egui/src/assets_panel.rs | 20 +++++++-- crates/ui-egui/src/export_preview_tests.rs | 52 ++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + 3 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/export_preview_tests.rs diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index 374855682..5ea19375b 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -190,15 +190,27 @@ fn same_layers(a: &Document, b: &Document, id: NodeId) -> bool { } pub(crate) fn thumbnail(ctx: &egui::Context, doc: &Document, id: NodeId, px: f32) -> Option { - let b = figcraft_render::export_bounds(doc, id)?; + let pix = thumbnail_pixmap(doc, id, px)?; + let img = crate::render_worker::screen_image(&pix, doc.color_profile); + Some(ctx.load_texture(format!("asset-thumb-{}", id.0), img, egui::TextureOptions::LINEAR)) +} + +/// The pixels a thumbnail shows. A slice paints nothing itself, so it shows the page content +/// under it, exactly as exporting one does (`figcraft_engine::cmd::file::drawn_layers`): without +/// this a slice's preview is a blank checkerboard next to a correct export. +pub(crate) fn thumbnail_pixmap(doc: &Document, id: NodeId, px: f32) -> Option { + let slice = doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Slice)); + let b = if slice { doc.world_bounds(id)? } else { figcraft_render::export_bounds(doc, id)? }; let side = b.width().max(b.height()); if !(side.is_finite() && side > 0.0) { return None; } let scale = (f64::from(px) / side).min(2.0); - let pix = figcraft_render::render_node(doc, id, scale)?; - let img = crate::render_worker::screen_image(&pix, doc.color_profile); - Some(ctx.load_texture(format!("asset-thumb-{}", id.0), img, egui::TextureOptions::LINEAR)) + if slice { + let layers = doc.page_of(id).and_then(|p| doc.node(p)).map(|p| p.children.clone()).unwrap_or_default(); + return figcraft_render::render_area(doc, &layers, b, scale); + } + figcraft_render::render_node(doc, id, scale) } fn matches(name: &str, q: &str) -> bool { diff --git a/crates/ui-egui/src/export_preview_tests.rs b/crates/ui-egui/src/export_preview_tests.rs new file mode 100644 index 000000000..3d536a017 --- /dev/null +++ b/crates/ui-egui/src/export_preview_tests.rs @@ -0,0 +1,52 @@ +//! The Export section's Preview row: a slice paints nothing itself, so its preview must show the +//! page content under it — the same thing exporting the slice writes — and not a blank +//! transparency checkerboard next to a correct export. + +use figcraft_doc::{Document, NodeId}; +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::assets_panel::thumbnail_pixmap; + +/// Run `commands`, returning the document and the id each one made. +fn doc_after(commands: &[(&str, Value)]) -> (Document, Vec) { + let mut s = Session::new(); + let ids = commands.iter().map(|(cmd, params)| NodeId(s.execute(cmd, params).unwrap()["id"].as_u64().unwrap())).collect(); + (s.doc().unwrap().clone(), ids) +} + +/// Whether the pixmap paints anything (any pixel not fully transparent). +fn paints(pix: &figcraft_render::Pixmap) -> bool { + figcraft_render::to_rgba8(pix).as_chunks::<4>().0.iter().any(|p| p.get(3).copied().unwrap_or(0) > 0) +} + +#[test] +fn a_slice_previews_the_content_under_it() { + let (doc, ids) = doc_after(&[ + ("shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100})), + ("node.create", json!({"type": "slice", "x": 0, "y": 0, "width": 200, "height": 100})), + ]); + let slice = ids[1]; + let pix = thumbnail_pixmap(&doc, slice, 200.0).expect("the slice previews"); + assert_eq!([pix.width(), pix.height()], [200, 100], "the slice's area, not an empty node"); + assert!(paints(&pix), "the rectangle under the slice shows"); +} + +#[test] +fn a_slice_with_nothing_under_it_is_blank() { + let (doc, ids) = doc_after(&[("node.create", json!({"type": "slice", "x": 0, "y": 0, "width": 200, "height": 100}))]); + let pix = thumbnail_pixmap(&doc, ids[0], 200.0).expect("the preview still has the slice's area"); + assert_eq!([pix.width(), pix.height()], [200, 100]); + assert!(!paints(&pix), "nothing under it stays transparent"); +} + +#[test] +fn other_layers_preview_themselves() { + let (doc, ids) = doc_after(&[ + ("shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100})), + ("node.create", json!({"type": "slice", "x": 0, "y": 0, "width": 200, "height": 100})), + ]); + let pix = thumbnail_pixmap(&doc, ids[0], 200.0).expect("the rectangle previews"); + assert_eq!([pix.width(), pix.height()], [200, 100], "the layer's own area"); + assert!(paints(&pix)); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f98..0da9a5767 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -78,6 +78,8 @@ mod cursors_tests; #[cfg(test)] mod dev_mode_tests; #[cfg(test)] +mod export_preview_tests; +#[cfg(test)] mod field_tests; #[cfg(test)] mod frame_label_tests; From 5f3e1e1e6c862d67e35fc415087a4811e60f772e Mon Sep 17 00:00:00 2001 From: Yaman Devrim Date: Wed, 14 Oct 2026 10:47:57 +0300 Subject: [PATCH 007/120] M9: Turkish catalog reviewed by a native speaker Drops the draft status from tr.toml (the Preferences window no longer calls it a draft) and marks Turkish reviewed in ROADMAP.md, docs/localization-parity.md and docs/translating.md. Turkish stays out of COMPLETE, so new strings don't have to be translated into it. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 12 ++++++------ crates/ui-egui/locales/tr.toml | 3 +-- docs/localization-parity.md | 6 +++--- docs/translating.md | 10 ++++++---- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ee06645a5..95b592f00 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Turkish draft catalog; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (reviewed Turkish catalog; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -63,7 +63,7 @@ native speakers' reviews of the six language drafts need people. | Stability | 55% | 20–40 | [gaps](docs/gaps.md) | | Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | | Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | -| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Turkish drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | +| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts, reviewed Turkish; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | | Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | | Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) | | AI features | 0% | 30–60 | [gaps](docs/gaps.md) | @@ -110,11 +110,11 @@ native speakers' reviews of the six language drafts need people. | German | de | 100% | draft (needs native review) | | Korean | ko | 100% | draft (needs native review) | | Vietnamese | vi | 0% | none | -| Turkish | tr | 100% | draft (needs native review) | +| Turkish | tr | 100% | reviewed by a native speaker | No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), -French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Turkish drafts (Preferences ▸ +French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts and reviewed Turkish (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI text uses an installed CJK font, loaded only when needed; adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, @@ -127,7 +127,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). 1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h. 2. Real-file corpus and measured fidelity: 15–30 h. 3. Tool-by-tool interaction pass against Figma: 40–60 h. -4. Languages: native reviews of the es, fr, de, pt-BR, ja, ko and tr drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h). +4. Languages: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h). 5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h. ## Progress log @@ -313,7 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-14 | minor | Turkish draft catalog: `tr.toml` translates every UI string (1,577 keys; Turkish keeps a noun singular after a count, so plurals give only `other`), command label (557) and menu wording (31), machine-drafted (bare imperatives on buttons, polite -in/-ın in instructions, %50) and marked as a draft awaiting native review; Preferences ▸ Language lists Türkçe and System maps tr and tr-TR to it. Numbers unchanged | +| 2026-10-14 | minor | Turkish catalog, reviewed by a native speaker (no longer a draft): `tr.toml` translates every UI string (1,577 keys; Turkish keeps a noun singular after a count, so plurals give only `other`), command label (557) and menu wording (31), machine-drafted (bare imperatives on buttons, polite -in/-ın in instructions, %50); Preferences ▸ Language lists Türkçe and System maps tr and tr-TR to it. Numbers unchanged | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 77db84098..a2be6b99c 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -1,6 +1,6 @@ # FigCraft interface strings: Turkish. # -# Draft — needs native review: machine-drafted from the English catalog. Brand names and keyboard +# Machine-drafted from the English catalog and reviewed by a native speaker. Brand names and keyboard # glyphs stay as they are. Buttons and menu items are bare imperatives (Kaydet, Sil); instructions # address the user politely (-in/-ın: "seçin", "tıklayın"). The percent sign goes before the number # (%50). Turkish nouns stay singular after a count, so plural entries give only `other`. See @@ -8,7 +8,6 @@ [meta] name = "Türkçe" -status = "draft — needs native review" [ui] "% of all commits" = "Tüm commit'lerin %'si" diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 7f1af8c60..6f9247956 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,10 +1,10 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Turkish draft catalog; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (reviewed Turkish catalog; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog and Spanish (with a Latin -American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and -Turkish drafts (machine-drafted, waiting for native speakers' review). This page lists the twelve key +American overlay), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese +drafts (machine-drafted, waiting for native speakers' review) and a reviewed Turkish catalog. This page lists the twelve key languages of the craftrules standard, then the other languages Figma ships. How to add a language: [`translating.md`](translating.md). diff --git a/docs/translating.md b/docs/translating.md index e7ad6442b..255c967ec 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -13,8 +13,9 @@ 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`) and Turkish (`tr`), machine-drafted and waiting for native speakers' review. +`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 +Turkish (`tr`), machine-drafted and reviewed by a native speaker. ## The catalogs @@ -67,8 +68,9 @@ language also needs its script in `cjk::language`. 4. To require full coverage, add the code to `COMPLETE` in [`crates/ui-egui/src/i18n_tests.rs`](../crates/ui-egui/src/i18n_tests.rs). Every later PR that adds a string then has to translate it into each complete language, so only Spanish is there: - French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Turkish are - registered as drafts, and a string added later shows English in them until it is translated. To list what a draft lacks, + French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese are registered as + drafts, Turkish is reviewed but not required, and a string added later shows English in them until + it is translated. To list what a draft lacks, add its code to `COMPLETE` locally and run the check. 5. Run the check and look at the panels in the app (longer words must fit the right panel): From 64a54ca408d53425686d74270f0193caf1435f0a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 11:11:00 +0200 Subject: [PATCH 008/120] M7: double-clicking a page in the Pages list no longer closes the app (fixes #264) --- crates/ui-egui/src/layers.rs | 15 +++++-- crates/ui-egui/src/layers_behaviour_tests.rs | 43 +++++++++++++++++++- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 956709f45..927e3ae47 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -241,9 +241,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } if renaming { let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0)); - if let Some(new) = - crate::widgets::text_field_inline(ui, Id::new(("pagename", id)), &name, tr, if id == cur { semibold(11.0) } else { regular(11.0) }) - { + let start = ui.data_mut(|d| d.remove_temp::(page_rename_start_id())).unwrap_or(false); + let font = if id == cur { semibold(11.0) } else { regular(11.0) }; + if let (Some(new), _) = crate::widgets::rename_field(ui, Id::new(("pagename", id)), &name, tr, font, start) { rename_done = Some((id, new)); } } else if !divider { @@ -255,7 +255,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } if resp.double_clicked() { app.ui.rename_page = Some((id, name.clone())); - ui.memory_mut(|m| m.request_focus(Id::new(("pagename", id)).with("te"))); + // The field takes the focus next frame, once it's drawn: focusing it now would put a + // node that isn't there yet in the screen reader tree, which AccessKit aborts on. + ui.data_mut(|d| d.insert_temp(page_rename_start_id(), true)); } crate::right::context_popup(&resp).show(|ui| { if ui.button(tr("Rename page")).clicked() { @@ -295,6 +297,11 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { crate::widgets::separator(ui); } +/// A page rename just asked for by a double click: its field takes the focus when drawn (temp data). +fn page_rename_start_id() -> Id { + Id::new("page_rename_start") +} + /// The layer row being dragged in the panel (temp data). fn layer_drag_id() -> Id { Id::new("layer_drag") diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index e1fdecc40..155f0d6d7 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -4,7 +4,7 @@ //! list (dragging pages, dividers). Rows the documentation is silent on say so and pin today's //! behaviour. What the gestures do to the document is in the engine's `panels_behaviour_tests`. -use egui::{Event, Modifiers, Pos2}; +use egui::{Event, Id, Modifiers, Pos2}; use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::json; @@ -226,3 +226,44 @@ fn pages_drag_to_reorder_and_dividers_open_nothing() { assert_eq!(u.app.session.page().unwrap(), p1, "clicking a divider opens nothing"); assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); } + +/// A double click at `p`, with the screen reader tree checked on every frame: the platform adapter +/// (UI Automation on Windows) aborts the app when the focused node isn't in the tree. +fn double_click_checking_focus(u: &mut Ui, p: Pos2) { + for _ in 0..30 { + u.frame(vec![Event::PointerMoved(p)]); + } + let presses = [true, false, true, false].into_iter().map(Some); + for (k, pressed) in presses.chain(std::iter::repeat_n(None, 30)).enumerate() { + let events = + pressed.map(|pressed| Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + let out = u.frame_output(events.into_iter().collect()); + let tree = out.platform_output.accesskit_update.expect("an AccessKit update"); + assert!(tree.nodes.iter().any(|(i, _)| *i == tree.focus), "frame {k}: the focused node {:?} isn't in the tree", tree.focus); + } +} + +#[test] +fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree() { + let mut s = Session::new(); + let p1 = s.page().unwrap(); + let p2 = id(s.execute("page.add", &json!({})).unwrap()); + let mut u = Ui::new(s); + let (r1, r2) = (page_row(&mut u, p1, p2), page_row(&mut u, p2, p1)); + u.ctx.enable_accesskit(); + // Another page: the first click opens it, the second renames it. + double_click_checking_focus(&mut u, r2); + assert_eq!(u.app.session.page().unwrap(), p2); + assert_eq!(u.app.ui.rename_page.as_ref().map(|r| r.0), Some(p2), "a double click renames the page"); + assert!(u.ctx.memory(|m| m.has_focus(Id::new(("pagename", p2)).with("te"))), "with the keyboard in its name"); + // The page that's open. + u.app.ui.rename_page = None; + u.ctx.memory_mut(|m| m.surrender_focus(Id::new(("pagename", p2)).with("te"))); + u.app.session.execute("page.select", &json!({"id": p1})).unwrap(); + for _ in 0..30 { + u.frame(vec![]); + } + double_click_checking_focus(&mut u, r1); + assert_eq!(u.app.ui.rename_page.as_ref().map(|r| r.0), Some(p1)); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); +} From bd482ea6dd007da03dd9908949b40176f3410418 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 11:15:32 +0200 Subject: [PATCH 009/120] fix(ui): scroll a long pages list in its own area so the layers stay in view (fixes #258) --- crates/ui-egui/src/layers.rs | 115 ++++++++++--------- crates/ui-egui/src/layers_behaviour_tests.rs | 44 +++++++ 2 files changed, 106 insertions(+), 53 deletions(-) diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 956709f45..3a3c4ef97 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -215,64 +215,73 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let mut move_to: Option<(NodeId, String)> = None; // Pages are dragged up and down the list to reorder them. let mut reorder = crate::list_drag::ListDrag::in_order(ui, "pages"); - for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag()); - reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); - a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur); - let r = r.shrink2(vec2(8.0, 0.0)); - a11y::ring_around(ui, r, &resp); - let renaming = app.ui.rename_page.as_ref().is_some_and(|(i, _)| *i == id); - if divider && !renaming { - // A divider is a line between pages: it opens nothing and takes no layers. - ui.painter().line_segment([pos2(r.min.x + 10.0, r.center().y), pos2(r.max.x - 10.0, r.center().y)], Stroke::new(1.0, t.border)); - } else if resp.hovered() || id == cur { - ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); - } - if dragging.is_some() - && id != cur - && !divider - // The gap between rows counts too. - && pointer.is_some_and(|p| resp.interact_rect.expand2(vec2(0.0, 3.0)).contains(p)) - { - ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside); - if ui.input(|i| i.pointer.any_released()) { - move_to = Some((id, name.clone())); + // A long list scrolls in its own area, so the layers keep their room below it. + let max_h = (ui.available_height() * 0.4).max(3.0 * ROW); + let moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { + for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag()); + reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); + a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur); + let r = r.shrink2(vec2(8.0, 0.0)); + a11y::ring_around(ui, r, &resp); + let renaming = app.ui.rename_page.as_ref().is_some_and(|(i, _)| *i == id); + if divider && !renaming { + // A divider is a line between pages: it opens nothing and takes no layers. + ui.painter().line_segment([pos2(r.min.x + 10.0, r.center().y), pos2(r.max.x - 10.0, r.center().y)], Stroke::new(1.0, t.border)); + } else if resp.hovered() || id == cur { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } - } - if renaming { - let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0)); - if let Some(new) = - crate::widgets::text_field_inline(ui, Id::new(("pagename", id)), &name, tr, if id == cur { semibold(11.0) } else { regular(11.0) }) + if dragging.is_some() + && id != cur + && !divider + // The gap between rows counts too. + && pointer.is_some_and(|p| resp.interact_rect.expand2(vec2(0.0, 3.0)).contains(p)) { - rename_done = Some((id, new)); + ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside); + if ui.input(|i| i.pointer.any_released()) { + move_to = Some((id, name.clone())); + } } - } else if !divider { - let font = if id == cur { semibold(11.0) } else { regular(11.0) }; - crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 10.0, r.center().y), &name, font, t.text, r.width() - 14.0); - } - if resp.clicked() && !divider { - select = Some(id); - } - if resp.double_clicked() { - app.ui.rename_page = Some((id, name.clone())); - ui.memory_mut(|m| m.request_focus(Id::new(("pagename", id)).with("te"))); - } - crate::right::context_popup(&resp).show(|ui| { - if ui.button(tr("Rename page")).clicked() { + if renaming { + let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0)); + if let Some(new) = crate::widgets::text_field_inline( + ui, + Id::new(("pagename", id)), + &name, + tr, + if id == cur { semibold(11.0) } else { regular(11.0) }, + ) { + rename_done = Some((id, new)); + } + } else if !divider { + let font = if id == cur { semibold(11.0) } else { regular(11.0) }; + crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 10.0, r.center().y), &name, font, t.text, r.width() - 14.0); + } + if resp.clicked() && !divider { + select = Some(id); + } + if resp.double_clicked() { app.ui.rename_page = Some((id, name.clone())); - ui.close(); + ui.memory_mut(|m| m.request_focus(Id::new(("pagename", id)).with("te"))); } - if ui.button(tr("Duplicate page")).clicked() { - app.run("page.duplicate", json!({"id": id})); - ui.close(); - } - if ui.button(tr("Delete page")).clicked() { - app.run("page.delete", json!({"id": id})); - ui.close(); - } - }); - } - if let Some((from, to)) = reorder.finish(ui) + crate::right::context_popup(&resp).show(|ui| { + if ui.button(tr("Rename page")).clicked() { + app.ui.rename_page = Some((id, name.clone())); + ui.close(); + } + if ui.button(tr("Duplicate page")).clicked() { + app.run("page.duplicate", json!({"id": id})); + ui.close(); + } + if ui.button(tr("Delete page")).clicked() { + app.run("page.delete", json!({"id": id})); + ui.close(); + } + }); + } + reorder.finish(ui) + }); + if let Some((from, to)) = moved.inner && let Some((id, ..)) = pages.get(from) { app.run("page.move", json!({"id": id, "index": to})); diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index e1fdecc40..b82e8d7b5 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -226,3 +226,47 @@ fn pages_drag_to_reorder_and_dividers_open_nothing() { assert_eq!(u.app.session.page().unwrap(), p1, "clicking a divider opens nothing"); assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); } + +/// The page opened by a click on each spot from just under the pages header down to `to`. +fn pages_down_to(u: &mut Ui, to: f32) -> Vec { + let mut opened = Vec::new(); + for y in (190..to as i32).step_by(8) { + click(u, Pos2::new(150.0, y as f32), Modifiers::NONE); + opened.push(u.app.session.page().unwrap()); + } + opened +} + +#[test] +fn a_long_pages_list_scrolls_and_leaves_room_for_the_layers() { + let mut s = Session::new(); + let first = s.page().unwrap(); + let rect = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + for _ in 0..59 { + s.execute("page.add", &json!({})).unwrap(); + } + let pages = s.doc().unwrap().pages.clone(); + let last = *pages.last().unwrap(); + s.execute("page.select", &json!({"id": first})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + // The layers list stays on screen under 60 pages. + let layer = u.row_at(rect); + // The wheel over the pages list scrolls it to its last page. + let wheel = Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -400.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }; + for _ in 0..10 { + u.frame(vec![Event::PointerMoved(Pos2::new(150.0, 200.0)), wheel.clone()]); + } + for _ in 0..30 { + u.frame(vec![]); + } + let opened = pages_down_to(&mut u, layer.y - 60.0); + assert!(opened.contains(&last), "the last page can be reached"); + let top = pages.iter().position(|p| Some(p) == opened.first()).unwrap(); + assert!(top > 30, "the list scrolled past its first pages (top row: page {top})"); +} From 3f57a4b76a3fec53b40d9cf33cd8bcd2e81514fc Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Wed, 14 Oct 2026 16:52:00 +0700 Subject: [PATCH 010/120] fix(ui): gradient type icons that show their gradient The colour picker's paint type row borrowed unrelated icons for the gradient kinds (auto layout's bars, a circle, the rotate arrow, the instance diamond). Each kind now has its own icon: the Solid swatch as an outline with that kind's fade inside (linear ramp, radial, a conic sweep from the top, diamond), drawn with a new per-vertex-alpha mesh helper. Test: gradient_icons_fade (each paints a mesh from opaque to clear). Refs #256 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/colorpicker.rs | 8 ++-- crates/ui-egui/src/icons.rs | 71 +++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index 9a1123679..e55130c52 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -438,10 +438,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { ) { let kinds = [ (Icon::Fill, tr("Solid")), - (Icon::LayoutH, tr("Linear")), - (Icon::Ellipse, tr("Radial")), - (Icon::Rotate, tr("Angular")), - (Icon::Instance, tr("Diamond")), + (Icon::GradientLinear, tr("Linear")), + (Icon::GradientRadial, tr("Radial")), + (Icon::GradientAngular, tr("Angular")), + (Icon::GradientDiamond, tr("Diamond")), (Icon::Image, tr("Image")), (Icon::ViewGrid, tr("Pattern")), ]; diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 726f62f44..867b08944 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -120,6 +120,11 @@ icons! { Export, StrokeIcon, Fill, + /// Gradient kinds: a swatch outline holding a fade of that kind. + GradientLinear, + GradientRadial, + GradientAngular, + GradientDiamond, Sun, Moon, Undo, @@ -311,6 +316,32 @@ impl P<'_> { // A hairline around the mesh gives it the same soft edge as the convex fills. self.painter.add(Shape::closed_line(v, Stroke::new(self.px, self.color))); } + /// Triangles `tris` over `verts` (x, y, alpha): the colour fades between the vertices. + fn shade(&self, verts: &[(f32, f32, f32)], tris: &[u32]) { + let mut mesh = Mesh::default(); + for &(x, y, a) in verts { + mesh.colored_vertex(self.raw(x, y), self.color.gamma_multiply(a)); + } + mesh.indices = tris.to_vec(); + self.painter.add(Shape::mesh(mesh)); + } + /// A disc of radius `r` at the centre, as a fan from the top, clockwise: each slice's rim + /// alphas from `rim` and its centre's from `centre`, both of how far round it is (0..1). + fn shade_fan(&self, r: f32, centre: impl Fn(f32) -> f32, rim: impl Fn(f32) -> f32) { + const SLICES: u32 = 32; + let at = |t: f32| { + let a = -FRAC_PI_2 + t * TAU; + (8.0 + r * a.cos(), 8.0 + r * a.sin()) + }; + let mut verts = Vec::new(); + for i in 0..SLICES { + let (t0, t1) = (i as f32 / SLICES as f32, (i + 1) as f32 / SLICES as f32); + let ((x0, y0), (x1, y1)) = (at(t0), at(t1)); + verts.extend([(8.0, 8.0, centre((t0 + t1) / 2.0)), (x0, y0, rim(t0)), (x1, y1, rim(t1))]); + } + let tris: Vec = (0..SLICES * 3).collect(); + self.shade(&verts, &tris); + } fn rect(&self, x0: f32, y0: f32, x1: f32, y1: f32, r: f32) { let rr = Rect::from_min_max(self.sp(x0, y0), self.sp(x1, y1)); self.painter.rect_stroke(rr, egui::CornerRadius::same((r * self.s).round() as u8), self.stroke, egui::StrokeKind::Middle); @@ -833,6 +864,24 @@ fn draw(p: &P, icon: Icon) { } StrokeIcon => p.rect(2.5, 2.5, 13.5, 13.5, 2.0), Fill => p.fill_rect(2.0, 2.0, 14.0, 14.0, 2.0), + GradientLinear => { + p.rect(2.5, 2.5, 13.5, 13.5, 2.0); + p.shade(&[(4.0, 4.0, 1.0), (12.0, 4.0, 0.0), (12.0, 12.0, 0.0), (4.0, 12.0, 1.0)], &[0, 1, 2, 0, 2, 3]); + } + GradientRadial => { + p.rect(2.5, 2.5, 13.5, 13.5, 2.0); + p.shade_fan(5.5, |_| 1.0, |_| 0.0); + } + GradientAngular => { + // Once round from the top: the seam shows where the sweep starts. + p.rect(2.5, 2.5, 13.5, 13.5, 2.0); + p.shade_fan(4.0, |t| 1.0 - t, |t| 1.0 - t); + } + GradientDiamond => { + p.rect(2.5, 2.5, 13.5, 13.5, 2.0); + let v = [(8.0, 8.0, 1.0), (8.0, 2.5, 0.0), (13.5, 8.0, 0.0), (8.0, 13.5, 0.0), (2.5, 8.0, 0.0)]; + p.shade(&v, &[0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1]); + } Moon => { // A crescent: the outer rim, then the shadowing circle's edge back. Both arcs stop short // of where the circles meet, so each tip is two blunt joins (a single cusp join folds). @@ -1102,6 +1151,28 @@ mod tests { assert!(out.shapes.len() > Icon::ALL.len() * 10); } + /// The gradient kinds' icons show a fade: a mesh whose vertices run from opaque to clear. + #[test] + fn gradient_icons_fade() { + let ctx = egui::Context::default(); + for icon in [Icon::GradientLinear, Icon::GradientRadial, Icon::GradientAngular, Icon::GradientDiamond] { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, Vec2::splat(16.0)), icon, Color32::WHITE); + }); + out.textures_delta.clear(); + let alphas: Vec = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + Shape::Mesh(m) => Some(m.vertices.iter().map(|v| v.color.a()).collect::>()), + _ => None, + }) + .flatten() + .collect(); + assert!(alphas.contains(&255) && alphas.contains(&0), "{icon:?} fades from opaque to clear: {alphas:?}"); + } + } + #[test] fn concave_polygons_triangulate() { let arrow = [pos2(4.0, 2.0), pos2(4.0, 13.6), pos2(6.9, 10.9), pos2(8.8, 14.8), pos2(10.6, 14.0), pos2(8.7, 10.2), pos2(12.6, 10.2)]; From 9c0b44111901757eaeb3bf4e4356d4f097d52e16 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Wed, 14 Oct 2026 16:53:33 +0700 Subject: [PATCH 011/120] fix(ui): disable alignment when the selection can't be aligned One layer directly on the page has nothing to align to (one layer aligns to its parent frame), so the six Alignment buttons looked active and did nothing. They are now disabled there, with a hover explaining why. - cmd/arrange.rs: align_target (unchanged rule) and pub can_align. - right.rs: the align rows in add_enabled_ui + on_disabled_hover_text. - The hover text in every catalog. Tests: a_layer_alone_on_the_page_has_nothing_to_align_to (engine), the_align_buttons_are_off_for_a_layer_alone_on_the_page (AccessKit). Refs #256 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/arrange.rs | 29 ++++++++++++++++++----------- crates/engine/src/tests.rs | 21 +++++++++++++++++++++ crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/ja.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + crates/ui-egui/src/a11y_tests.rs | 15 +++++++++++++++ crates/ui-egui/src/right.rs | 11 +++++++---- 12 files changed, 69 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 2c088cfa1..26afec873 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -145,21 +145,28 @@ pub fn rotate_world(doc: &mut Document, id: NodeId, pivot: Point, deg: f64) -> R Ok(()) } +/// Whether aligning `ids` would move anything: a layer alone on the page has nothing to align to. +pub fn can_align(doc: &Document, ids: &[NodeId]) -> bool { + align_target(doc, &roots(doc, ids)).is_some() +} + +/// What `roots` align to: one layer to its parent frame, several to their combined bounds. +fn align_target(doc: &Document, roots: &[NodeId]) -> Option { + match roots { + [] => None, + [id] => { + let parent = doc.node(*id)?.parent.filter(|pp| !doc.node(*pp).is_some_and(Node::is_page))?; + Some(doc.world_bounds(parent).unwrap_or_default()) + } + _ => Some(doc.bounds_of(roots).unwrap_or_default()), + } +} + fn align(s: &mut Session, p: &Value, a: Al) -> Result { let ids = req_targets("align", s, p)?; let doc = s.doc_mut()?; let ids = roots(doc, &ids); - // One layer aligns within its parent frame; several align to their combined bounds. - let target: Rect = if ids.len() == 1 { - let Some(id) = ids.first() else { return ok() }; - let parent = doc.get(*id)?.parent; - match parent.filter(|pp| !doc.node(*pp).is_some_and(Node::is_page)) { - Some(pp) => doc.world_bounds(pp).unwrap_or_default(), - None => return ok(), - } - } else { - doc.bounds_of(&ids).unwrap_or_default() - }; + let Some(target) = align_target(doc, &ids) else { return ok() }; for i in ids { let Some(b) = doc.world_bounds(i) else { continue }; let (dx, dy) = match a { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 40da2f1df..bfcc1ec3c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -253,6 +253,27 @@ fn align_distribute_zorder() { assert_eq!(s.doc().unwrap().node(page).unwrap().children[0], c); } +#[test] +fn a_layer_alone_on_the_page_has_nothing_to_align_to() { + use crate::cmd::arrange::can_align; + let mut s = sess(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()); + let inner = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 30, "width": 10, "height": 10, "parent": f})).unwrap()); + let loose = id(&s.execute("shape.rectangle", &json!({"x": 300, "y": 5, "width": 10, "height": 10})).unwrap()); + let doc = s.doc().unwrap(); + assert!(!can_align(doc, &[])); + assert!(!can_align(doc, &[loose]), "alone on the page"); + assert!(!can_align(doc, &[f, inner]), "a frame with its own child is the frame alone"); + assert!(can_align(doc, &[inner]), "one in a frame aligns to it"); + assert!(can_align(doc, &[f, loose]), "two align to each other"); + // Where the buttons are off, the command still does nothing (and no harm). + let before = doc.world_bounds(loose); + s.execute("align.right", &json!({"ids": [loose]})).unwrap(); + assert_eq!(s.doc().unwrap().world_bounds(loose), before); + s.execute("align.right", &json!({"ids": [inner]})).unwrap(); + assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap().x1, 200.0); +} + #[test] fn copy_paste_and_duplicate() { let mut s = sess(); diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 7552cca36..affda97dd 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -17,6 +17,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} Commits, +{added} / −{deleted} Zeilen (Δ {delta}), +{bin_added} / −{bin_deleted} Binärdateien, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Eine Sammlung dieser Datei mit diesen Modi und Variablen: Überschreiben Sie ihre Werte je Modus und fügen Sie eigene Variablen hinzu" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Eine einzelne Ebene richtet sich an ihrem Rahmen aus: Wählen Sie weitere Ebenen oder eine Ebene in einem Rahmen" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Eine Feder läuft, bis sie zur Ruhe kommt: Masse, Steifigkeit und Dämpfung bestimmen die Dauer" "About" = "Über" "About FigCraft" = "Über FigCraft" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index c35b16776..08ac64ba3 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -16,6 +16,7 @@ name = "English" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "A collection of this file with these modes and variables: override their values per mode and add variables of its own" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "A layer on its own aligns to its frame: select more layers, or one in a frame" "A responsive product page: hero, features, figures, a quote and a footer" = "A responsive product page: hero, features, figures, a quote and a footer" "A spring plays until it settles: its mass, stiffness and damping set how long" = "A spring plays until it settles: its mass, stiffness and damping set how long" "A title, an agenda, a big figure, a chart and a closing slide" = "A title, an agenda, a big figure, a chart and a closing slide" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 6514ac7be..3200e8913 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -17,6 +17,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commits, +{added} / −{deleted} líneas (Δ {delta}), +{bin_added} / −{bin_deleted} recursos binarios, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Una colección de este archivo con estos modos y variables: modifica sus valores por modo y añade variables propias" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Una capa sola se alinea con su marco: selecciona más capas o una dentro de un marco" "A responsive product page: hero, features, figures, a quote and a footer" = "Una página de producto adaptable: cabecera, funciones, cifras, una cita y un pie" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Un muelle se reproduce hasta que se asienta: su masa, rigidez y amortiguación fijan cuánto dura" "A title, an agenda, a big figure, a chart and a closing slide" = "Un título, una agenda, una gran cifra, un gráfico y una diapositiva de cierre" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index c86659345..69798340f 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -17,6 +17,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login} : {prs} PR, {commits} commits, +{added} / −{deleted} lignes (Δ {delta}), +{bin_added} / −{bin_deleted} ressources binaires, {first} – {last}" "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 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 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" "About" = "À propos" "About FigCraft" = "À propos de FigCraft" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index f7b9cf9fd..23a0f5e29 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -19,6 +19,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:PR {prs} 件、コミット {commits} 件、+{added} / −{deleted} 行(Δ {delta})、バイナリアセット +{bin_added} / −{bin_deleted}、{first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "このファイルのコレクションとそのモード・変数:モードごとに値を上書きし、独自の変数を追加できる" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "単独のレイヤーはフレームに整列します:レイヤーを追加で選択するか、フレーム内のレイヤーを選択" "A spring plays until it settles: its mass, stiffness and damping set how long" = "スプリングは静止するまで再生。長さは質量・剛性・減衰で決まる" "About" = "情報" "About FigCraft" = "FigCraft について" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 59bb23868..aed3529a5 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -18,6 +18,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR {prs}개, 커밋 {commits}개, +{added} / −{deleted}줄 (Δ {delta}), 바이너리 에셋 +{bin_added} / −{bin_deleted}, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "이 파일의 컬렉션과 그 모드 및 변수: 모드별로 값을 재정의하고 자체 변수를 추가할 수 있음" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "레이어 하나는 프레임에 맞춰 정렬됩니다: 레이어를 더 선택하거나 프레임 안의 레이어를 선택하세요" "A spring plays until it settles: its mass, stiffness and damping set how long" = "스프링은 멈출 때까지 재생됨. 길이는 질량, 강성, 감쇠로 결정됨" "About" = "정보" "About FigCraft" = "FigCraft 정보" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index a692a8cee..975002363 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -16,6 +16,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} linhas (Δ {delta}), +{bin_added} / −{bin_deleted} arquivos binários, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Uma coleção deste arquivo com estes modos e variáveis: substitua os valores por modo e adicione variáveis próprias" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Uma camada sozinha se alinha ao seu quadro: selecione mais camadas ou uma dentro de um quadro" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Uma mola se move até se estabilizar: sua massa, rigidez e amortecimento definem a duração" "About" = "Sobre" "About FigCraft" = "Sobre o FigCraft" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index d2431df09..d1887735e 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -19,6 +19,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:{prs} 个 PR,{commits} 次提交,+{added} / −{deleted} 行(Δ {delta}),二进制资源 +{bin_added} / −{bin_deleted},{first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "本文件中带有这些模式和变量的集合:可按模式覆盖其值,并添加自己的变量" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "单个图层与其所在画框对齐:请再选择其他图层,或选择画框内的图层" "A spring plays until it settles: its mass, stiffness and damping set how long" = "弹簧动画播放至静止,时长由质量、刚度和阻尼决定" "About" = "关于" "About FigCraft" = "关于 FigCraft" diff --git a/crates/ui-egui/src/a11y_tests.rs b/crates/ui-egui/src/a11y_tests.rs index 3fe858760..d041a5b36 100644 --- a/crates/ui-egui/src/a11y_tests.rs +++ b/crates/ui-egui/src/a11y_tests.rs @@ -388,3 +388,18 @@ fn every_accessibility_path_runs_with_the_tree_on_without_nested_context_locks() let out = u.frame_output(vec![]); assert!(out.platform_output.accesskit_update.is_some()); } + +#[test] +fn the_align_buttons_are_off_for_a_layer_alone_on_the_page() { + let (mut u, a, b) = setup(); + let align_left = |nodes: &[Node]| { + nodes.iter().find(|n| n.label().is_some_and(|l| l.starts_with("Align left"))).map(Node::is_disabled).expect("the Align left button") + }; + assert!(align_left(&tree(&mut u)), "Rect A alone on the page: nothing to align to"); + u.app.run("select.set", json!({"ids": [a, b]})); + assert!(!align_left(&tree(&mut u)), "two layers align to each other"); + let f = id(u.app.session.execute("frame.create", &json!({"x": 300, "y": 0, "width": 100, "height": 100})).unwrap()); + let inner = id(u.app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10, "parent": f})).unwrap()); + u.app.run("select.set", json!({"ids": [inner]})); + assert!(!align_left(&tree(&mut u)), "one in a frame aligns to it"); +} diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index d1244ff4f..6be287807 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -599,12 +599,15 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] (Icon::AlignBottom, tip(tr("Align bottom"), cmds[5])), ]; let w = col / 3.0; - if let Some(i) = widgets::segmented(ui, &items[..3], None, w) { + let picked = ui.add_enabled_ui(figcraft_engine::cmd::arrange::can_align(doc, sel), |ui| { + let h = widgets::segmented(ui, &items[..3], None, w); + let v = widgets::segmented(ui, &items[3..], None, w).map(|i| i + 3); + h.or(v) + }); + picked.response.on_disabled_hover_text(tr("A layer on its own aligns to its frame: select more layers, or one in a frame")); + if let Some(i) = picked.inner { app.run(cmds[i], json!({"ids": ids})); } - if let Some(i) = widgets::segmented(ui, &items[3..], None, w) { - app.run(cmds[i + 3], json!({"ids": ids})); - } if sel.len() > 2 { let more = icons::button(ui, Icon::DistributeH, 24.0, false, tr("Distribute and tidy")); menu_popup(&more).show(|ui| { From b8e5b8e133d4976d8f5cb6e4712bd55b6e650d81 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Wed, 14 Oct 2026 16:58:51 +0700 Subject: [PATCH 012/120] fix(pen): show the bezier handles while drawing The Pen showed only its anchors while a path was drawn; handles appeared only in vector edit mode afterwards. Now: - The point being dragged shows its in and out handles, and the point before it its out handle (the handles shaping the segments at the pen), drawn like vector edit mode's (one shared canvas helper). - Moving on to the next point, the rubber band is the segment a click would make: it leaves the last anchor along its out handle instead of going straight. - A press no longer leaves the previous straight rubber band on screen over the segment being dragged. Overlay gains pen_handles and rubber_handle (shown in ui.inspect). Tests: pen_overlay_tests (handles during and after a drag, none and a straight band for corner points, cleared on finish); canvas_fixes_tests (handle lines and dots painted mid-drag, curved band after release). Refs #256 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/lib.rs | 2 + crates/engine/src/pen_overlay_tests.rs | 65 ++++++++++++++++++++++++ crates/engine/src/tools.rs | 23 ++++++++- crates/ui-egui/src/canvas.rs | 34 ++++++++++--- crates/ui-egui/src/canvas_fixes_tests.rs | 36 ++++++++++++- 5 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 crates/engine/src/pen_overlay_tests.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a906..e6641a8d2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1517,6 +1517,8 @@ mod panels_behaviour_tests; #[cfg(test)] mod pdf_print_tests; #[cfg(test)] +mod pen_overlay_tests; +#[cfg(test)] mod pixel_preview_tests; #[cfg(test)] mod playground_tests; diff --git a/crates/engine/src/pen_overlay_tests.rs b/crates/engine/src/pen_overlay_tests.rs new file mode 100644 index 000000000..7e0064d2b --- /dev/null +++ b/crates/engine/src/pen_overlay_tests.rs @@ -0,0 +1,65 @@ +//! What the Pen tool shows while a path is drawn (`tool.overlay`): the handles shaping the segments +//! at the pen, and the rubber band to the pointer, which follows the last anchor's out handle. + +use crate::Session; +use crate::tools::{PointerEvent, PointerKind, ToolId}; + +fn at(s: &mut Session, kind: PointerKind, x: f64, y: f64) { + s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap(); +} + +/// A press at (x, y) dragged to (to_x, to_y) and released: a smooth point with out handle +/// (to - p). +fn drag_point(s: &mut Session, x: f64, y: f64, to_x: f64, to_y: f64) { + at(s, PointerKind::Down, x, y); + at(s, PointerKind::Move, to_x, to_y); + at(s, PointerKind::Up, to_x, to_y); +} + +#[test] +fn the_dragged_point_shows_its_handles_and_the_one_before_its_out_handle() { + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + drag_point(&mut s, 100.0, 100.0, 140.0, 100.0); + assert_eq!(s.tool.overlay.pen_handles, vec![[100.0, 100.0, 60.0, 100.0], [100.0, 100.0, 140.0, 100.0]], "in and out of the first point"); + // The second point, mid-drag. + at(&mut s, PointerKind::Down, 200.0, 200.0); + assert_eq!(s.tool.overlay.rubber, None, "the press shows the segment itself, not a straight band"); + at(&mut s, PointerKind::Move, 200.0, 240.0); + assert_eq!( + s.tool.overlay.pen_handles, + vec![[100.0, 100.0, 140.0, 100.0], [200.0, 200.0, 200.0, 160.0], [200.0, 200.0, 200.0, 240.0]], + "the one before's out handle, then the dragged point's in and out" + ); + at(&mut s, PointerKind::Up, 200.0, 240.0); + // Moving on: the band leaves along the last out handle. + at(&mut s, PointerKind::Hover, 300.0, 200.0); + assert_eq!(s.tool.overlay.rubber, Some([200.0, 200.0, 300.0, 200.0])); + assert_eq!(s.tool.overlay.rubber_handle, Some([200.0, 240.0])); +} + +#[test] +fn corner_points_have_no_handles_and_a_straight_band() { + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + for (x, y) in [(100.0, 100.0), (200.0, 100.0)] { + at(&mut s, PointerKind::Down, x, y); + at(&mut s, PointerKind::Up, x, y); + } + at(&mut s, PointerKind::Hover, 250.0, 200.0); + assert!(s.tool.overlay.pen_handles.is_empty()); + assert_eq!(s.tool.overlay.rubber, Some([200.0, 100.0, 250.0, 200.0])); + assert_eq!(s.tool.overlay.rubber_handle, None, "straight"); +} + +#[test] +fn finishing_the_path_clears_its_handles_and_band() { + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + drag_point(&mut s, 100.0, 100.0, 140.0, 100.0); + drag_point(&mut s, 200.0, 200.0, 200.0, 240.0); + at(&mut s, PointerKind::Hover, 300.0, 200.0); + s.pen_finish().unwrap(); + let ov = &s.tool.overlay; + assert!(ov.pen.is_empty() && ov.pen_handles.is_empty() && ov.rubber.is_none() && ov.rubber_handle.is_none()); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index cfb9176d0..3358bad37 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -266,6 +266,12 @@ pub struct Overlay { /// Pen tool: the in-progress path's anchor points and the rubber band to the pointer. pub pen: Vec<[f64; 2]>, pub rubber: Option<[f64; 4]>, + /// Pen tool: the handles shaping the segments at the pen (anchor x, y, handle x, y): the last + /// anchor's in and out handles and the one before's out handle. + pub pen_handles: Vec<[f64; 4]>, + /// Pen tool: where the last anchor's out handle ends, when it has one: the rubber band is the + /// curve a click would make, leaving along it. + pub rubber_handle: Option<[f64; 2]>, /// Frame/section the pointer is drawing or dropping into (highlighted). pub drop_target: Option, /// Auto layout insertion indicator (x0, y0, x1, y1). @@ -397,6 +403,14 @@ pub(crate) enum Gesture { }, } +/// The handles a pen path shows while it is drawn ([`Overlay::pen_handles`]): the last anchor's in +/// and out handles and the one before's out handle, those that aren't zero. +fn pen_handles(points: &[(Point, Vec2, Vec2)]) -> Vec<[f64; 4]> { + let last = points.last().into_iter().flat_map(|(p, i, o)| [(p, i), (p, o)]); + let before = points.len().checked_sub(2).and_then(|k| points.get(k)).into_iter().map(|(p, _, o)| (p, o)); + before.chain(last).filter(|(_, h)| h.hypot() > 0.0).map(|(p, h)| [p.x, p.y, p.x + h.x, p.y + h.y]).collect() +} + /// The pen tool's in-progress path (page coordinates). #[derive(Clone, Debug, Default)] pub struct PenPath { @@ -1026,8 +1040,9 @@ impl Session { let (to, g) = if ev.shift { (ev.p(), Vec::new()) } else { self.snap_pen_tool(ev)? }; guides = g; self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom)); - if let Some((last, _, _)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) { + if let Some((last, _, out)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) { self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]); + self.tool.overlay.rubber_handle = (out.hypot() > 0.0).then_some([last.x + out.x, last.y + out.y]); } } self.tool.overlay.guides = guides; @@ -1931,6 +1946,9 @@ impl Session { fn pen_down(&mut self, ev: &PointerEvent) -> Result<()> { self.tool.overlay.guides.clear(); + // The segment being placed shows as itself while the press lasts. + self.tool.overlay.rubber = None; + self.tool.overlay.rubber_handle = None; let p = if ev.shift { ev.p() } else { self.snap_pen_tool(ev)?.0 }; let (doc, page) = { let ds = self.ds()?; @@ -1985,6 +2003,7 @@ impl Session { fn pen_update(&mut self) -> Result<()> { let Some(pen) = self.tool.pen.clone() else { return Ok(()) }; self.tool.overlay.pen = pen.points.iter().map(|(p, _, _)| [p.x, p.y]).collect(); + self.tool.overlay.pen_handles = pen_handles(&pen.points); if pen.points.len() < 2 { if let Ok(ds) = self.ds_mut() { ds.revision += 1; @@ -2067,7 +2086,9 @@ impl Session { pub fn pen_finish(&mut self) -> Result<()> { let pen = self.tool.pen.take(); self.tool.overlay.pen.clear(); + self.tool.overlay.pen_handles.clear(); self.tool.overlay.rubber = None; + self.tool.overlay.rubber_handle = None; self.tool.gesture = Gesture::None; if pen.as_ref().is_some_and(|p| p.id.is_some()) { self.commit_interaction()?; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb23c8de0..beec9aa37 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -973,12 +973,30 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if app.session.tool.tool == ToolId::Pencil { painter.add(Shape::line(pts, stroke(t.text, 2.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); + } for p in &pts { painter.circle_filled(*p, 3.5, Color32::WHITE); painter.circle_stroke(*p, 3.5, stroke(ACCENT, 1.0)); } if let Some([x0, y0, x1, y1]) = ov.rubber { - painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 1.0)); + let (a, b) = (app.to_screen(x0, y0), app.to_screen(x1, y1)); + // The segment a click would make: along the last anchor's out handle, if it has one. + match ov.rubber_handle { + Some([hx, hy]) => { + let h = app.to_screen(hx, hy); + painter.add(egui::epaint::CubicBezierShape::from_points_stroke( + [a, h, b, b], + false, + Color32::TRANSPARENT, + stroke(ACCENT, 1.0), + )); + } + None => { + painter.line_segment([a, b], stroke(ACCENT, 1.0)); + } + } } } } @@ -1111,6 +1129,14 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig } } +/// A bezier handle from its anchor `v` to its end `e` (screen points): the line, and a dot of +/// radius `r` filled with `fill` (vector edit mode and the Pen draw them alike). +fn bezier_handle(painter: &egui::Painter, v: Pos2, e: Pos2, r: f32, fill: Color32) { + painter.line_segment([v, e], stroke(ACCENT, 1.0)); + painter.circle_filled(e, r, fill); + painter.circle_stroke(e, r, stroke(ACCENT, 1.0)); +} + /// Vector edit mode: a layer's segments, handles and points (`points`) with its `hover`ed part, and /// for the `active` layer the closed area under the paint bucket and the pen's rubber band. fn vector_overlay( @@ -1151,13 +1177,9 @@ fn vector_overlay( return; } for h in &o.handles { - let (v, e) = (sp(&h.vertex), sp(&h.handle)); - painter.line_segment([v, e], stroke(ACCENT, 1.0)); let hovered = hover == Some(VHit::Handle { seg: h.seg, start: h.start }); - let r = if hovered { 4.5 } else { 3.5 }; let on = o.selected_handles.contains(&(h.seg, h.start)); - painter.circle_filled(e, r, if on { ACCENT } else { Color32::WHITE }); - painter.circle_stroke(e, r, stroke(ACCENT, 1.0)); + bezier_handle(painter, sp(&h.vertex), sp(&h.handle), if hovered { 4.5 } else { 3.5 }, if on { ACCENT } else { Color32::WHITE }); } for (i, v) in o.vertices.iter().enumerate() { let p = sp(v); diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs index 37b03e554..5a36b7129 100644 --- a/crates/ui-egui/src/canvas_fixes_tests.rs +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -1,5 +1,6 @@ //! Canvas fixes, driven through egui headless: View ▸ Frame outlines and View ▸ Slices draw (off -//! by default), and the render worker counts a job as in flight until it is done. +//! by default), the render worker counts a job as in flight until it is done, and the Pen shows +//! the handles of the path it draws. use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -91,3 +92,36 @@ fn a_job_queued_behind_a_finished_one_stays_in_flight() { // Not in flight only once the second is done: its image is then waiting. assert!(worker.in_flight() || worker.poll().is_some(), "the second job counts until its image is there"); } + +#[test] +fn the_pen_draws_the_handles_of_the_point_being_dragged_and_a_curved_band() { + use egui::{Event, PointerButton}; + let mut u = Ui::new(Session::new()); + u.app.run("tool.set", json!({"tool": "pen"})); + let at = |u: &Ui, x: f64, y: f64| u.app.to_screen(x, y); + let press = |p: Pos2, pressed: bool| { + vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }] + }; + let accent = egui::epaint::ColorMode::Solid(crate::theme::ACCENT); + // A handle: the line from its anchor and the dot at its end. + let handle = |shapes: &[Shape], a: Pos2, e: Pos2| { + let near = |p: Pos2, q: Pos2| p.distance(q) < 0.5; + let line = shapes.iter().any(|s| matches!(s, Shape::LineSegment { points: [p, q], .. } if near(*p, a) && near(*q, e))); + let dot = shapes.iter().any(|s| matches!(s, Shape::Circle(c) if near(c.center, e) && c.fill == egui::Color32::WHITE)); + line && dot + }; + let a = at(&u, 100.0, 100.0); + u.frame(press(a, true)); + for k in 1..=4 { + u.frame(vec![Event::PointerMoved(at(&u, 100.0 + 10.0 * f64::from(k), 100.0))]); + } + let shapes = flat(&u.frame_output(vec![]).shapes); + assert!(handle(&shapes, a, at(&u, 140.0, 100.0)), "the out handle while dragging"); + assert!(handle(&shapes, a, at(&u, 60.0, 100.0)), "and the in handle opposite"); + u.frame(press(at(&u, 140.0, 100.0), false)); + // After release, towards the next point: the band curves along the out handle. + let shapes = flat(&u.frame_output(vec![Event::PointerMoved(at(&u, 200.0, 200.0))]).shapes); + assert!(handle(&shapes, a, at(&u, 140.0, 100.0)), "the handles stay"); + 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"); +} From 01eb35df25f3a26396be6c61b855cd8526acf86b Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 12:00:02 +0200 Subject: [PATCH 013/120] M6: .fig import lays out the layers of resized instances by their constraints (fixes #266) --- crates/format/src/fig/map.rs | 120 ++++++++++++++++++++++++++++----- crates/format/src/fig/tests.rs | 58 +++++++++++++++- 2 files changed, 161 insertions(+), 17 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 787ca6d4b..7d726183b 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -113,11 +113,21 @@ fn copy_str(o: &mut Obj, n: &View, from: &str, to: &str) { #[derive(Clone)] struct View<'a> { layers: Vec<&'a Kv<'a>>, + /// Size and transform worked out from the constraints, over the layers' own. + fitted: Option<((f64, f64), Affine)>, } impl<'a> View<'a> { fn of(n: &'a Kv<'a>) -> Self { - Self { layers: vec![n] } + Self { layers: vec![n], fitted: None } + } + + fn size(&self) -> Option<(f64, f64)> { + self.fitted.map(|f| f.0).or_else(|| self.get("size").and_then(|s| Some((num(s, "x")?, num(s, "y")?)))) + } + + fn transform(&self) -> Option { + self.fitted.map(|f| f.1).or_else(|| self.get("transform").and_then(matrix)) } fn with(&self, top: impl IntoIterator>) -> Self { @@ -147,6 +157,61 @@ impl<'a> View<'a> { } } +/// How the layers of a resized container follow it when the file gives no derived layout for +/// them: a frame's by their constraints, from its main's size to its own; a group's scale with it. +#[derive(Clone, Copy)] +enum Fit { + Frame { from: (f64, f64), to: (f64, f64) }, + Scale(f64, f64), +} + +impl Fit { + fn frame(from: Option<(f64, f64)>, to: Option<(f64, f64)>) -> Option { + let (from, to) = (from?, to?); + ((from.0 - to.0).abs() > 1e-9 || (from.1 - to.1).abs() > 1e-9).then_some(Fit::Frame { from, to }) + } + + fn scale(from: Option<(f64, f64)>, to: Option<(f64, f64)>) -> Option { + let (from, to) = (from?, to?); + let k = |a: f64, b: f64| if a > 1e-9 { b / a } else { 1.0 }; + let (kx, ky) = (k(from.0, to.0), k(from.1, to.1)); + ((kx - 1.0).abs() > 1e-9 || (ky - 1.0).abs() > 1e-9).then_some(Fit::Scale(kx, ky)) + } + + /// The size and transform (in the parent's coordinates) of a `w` × `h` layer at `t` with + /// constraints `h_con`, `v_con`. Like the editor's constraint solver, a layer keeps its + /// rotation and flip and stretches along its own axes. + fn apply(self, w: f64, h: f64, t: Affine, h_con: Option<&str>, v_con: Option<&str>) -> ((f64, f64), Affine) { + let [a, b, c, d, _, _] = t.as_coeffs(); + let lin = Affine::new([a, b, c, d, 0.0, 0.0]); + let bb = figcraft_doc::geom::transform_rect_bbox(t, figcraft_doc::geom::Rect::new(0.0, 0.0, w, h)); + let (kx, ky, x0, y0) = match self { + Fit::Frame { from, to } => { + let axis = |con: Option<&str>, lo: f64, hi: f64, old: f64, new: f64| match con { + Some("MAX" | "FIXED_MAX") => (lo + new - old, hi + new - old), + Some("STRETCH") => (lo, hi + new - old), + Some("CENTER") => (lo + (new - old) / 2.0, hi + (new - old) / 2.0), + Some("SCALE") if old > 1e-9 => (lo * new / old, hi * new / old), + _ => (lo, hi), + }; + let (x0, x1) = axis(h_con, bb.x0, bb.x1, from.0, to.0); + let (y0, y1) = axis(v_con, bb.y0, bb.y1, from.1, to.1); + let k = |new: f64, old: f64| if old > 1e-9 { (new / old).max(0.0) } else { 1.0 }; + (k(x1 - x0, bb.width()), k(y1 - y0, bb.height()), x0, y0) + } + Fit::Scale(kx, ky) => (kx, ky, bb.x0 * kx, bb.y0 * ky), + }; + let along = |x: f64, y: f64, fallback: f64| { + let len = x.hypot(y); + let k = (kx * x).hypot(ky * y) / len; + if len > 1e-12 && k.is_finite() { k } else { fallback } + }; + let size = (w * along(a, b, kx), h * along(c, d, ky)); + let nb = figcraft_doc::geom::transform_rect_bbox(lin, figcraft_doc::geom::Rect::new(0.0, 0.0, size.0, size.1)); + (size, Affine::translate((x0 - nb.x0, y0 - nb.y0)) * lin) + } +} + /// Where an instance's expanded layers are: the overrides and derived layout of the outermost /// instance (by id path), the path of the instance being expanded, and the property values. #[derive(Clone)] @@ -263,7 +328,7 @@ impl<'a> Mapper<'a> { let name = sp.get("name").and_then(Kv::str).filter(|s| !s.is_empty()).unwrap_or("Flow"); flows.push((sp.get("position").and_then(Kv::str).unwrap_or(""), json!({"nodeId": c, "name": name}))); } - kids.extend(self.layer(&c, None, Affine::IDENTITY, None, 0)); + kids.extend(self.layer(&c, None, Affine::IDENTITY, None, None, 0)); } flows.sort_by(|a, b| a.0.cmp(b.0)); if !flows.is_empty() { @@ -276,7 +341,15 @@ impl<'a> Mapper<'a> { /// Layer `id` (inside instance `inst` when expanding one) and its subtree. `space`: from the /// parent's coordinates to the enclosing frame's (groups and booleans place their children in /// the frame's space, like the REST JSON). `parent`: the parent's view (grid tracks). - fn layer(&mut self, id: &str, inst: Option<&Inst<'a>>, space: Affine, parent: Option<&View<'a>>, depth: usize) -> Option { + fn layer( + &mut self, + id: &str, + inst: Option<&Inst<'a>>, + space: Affine, + parent: Option<&View<'a>>, + fit: Option, + depth: usize, + ) -> Option { if depth > MAX_DEPTH { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return None; @@ -287,10 +360,19 @@ impl<'a> Mapper<'a> { } let base = *self.nodes.get(id)?; let path = inst.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()); - let view = match (inst, &path) { + let mut view = match (inst, &path) { (Some(i), Some(p)) => View::of(base).with(i.overrides.get(p).into_iter().flatten().copied()), _ => View::of(base), }; + // A layer of a resized instance the file gives no derived layout for follows its + // constraints (auto layout places the others, as in the editor). + let derived = view.layers.iter().skip(1).any(|l| l.get("size").is_some() || l.get("transform").is_some()); + let stacked = parent.and_then(|p| p.str("stackMode")).is_some_and(|m| matches!(m, "HORIZONTAL" | "VERTICAL" | "GRID")) + && view.str("stackPositioning") != Some("ABSOLUTE"); + if let (Some(f), false, false, Some((w, h))) = (fit, derived, stacked, view.size()) { + let t = view.transform().unwrap_or_default(); + view.fitted = Some(f.apply(w, h, t, view.str("horizontalConstraint"), view.str("verticalConstraint"))); + } let rest_id = match (inst, &path) { (Some(i), Some(p)) => format!("I{};{}", i.root, p.join(";")), _ => id.to_string(), @@ -338,21 +420,26 @@ impl<'a> Mapper<'a> { self.component(&mut o, &view, &rest_id); } self.prop_refs(&mut o, &view, inst); - let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { - space * view.get("transform").and_then(matrix).unwrap_or_default() - } else { - Affine::IDENTITY - }; + let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY }; let mut kids = Vec::new(); if ty == "INSTANCE" { if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) { + let main = self.nodes.get(&sym).map(|m| View::of(m)); + let fit = Fit::frame(main.and_then(|m| m.size()), view.size()); for c in self.children(&sym) { - kids.extend(self.layer(&c, Some(&sub), child_space, Some(&view), depth + 1)); + kids.extend(self.layer(&c, Some(&sub), child_space, Some(&view), fit, depth + 1)); } } } else { + // Inside an instance a layer's children are its main's: they follow it from the main's + // size to its own. + let fit = match (inst, ty) { + (None, _) => None, + (Some(_), "GROUP" | "BOOLEAN_OPERATION") => Fit::scale(View::of(base).size(), view.size()), + (Some(_), _) => Fit::frame(View::of(base).size(), view.size()), + }; for c in self.children(id) { - kids.extend(self.layer(&c, inst, child_space, Some(&view), depth + 1)); + kids.extend(self.layer(&c, inst, child_space, Some(&view), fit, depth + 1)); } } if stand.is_some_and(|s| s.0 == "FRAME") { @@ -487,10 +574,10 @@ impl<'a> Mapper<'a> { if let Some(b) = v.str("blendMode").filter(|b| *b != "UNSET") { o.insert("blendMode".into(), json!(b)); } - if let Some(s) = v.get("size").and_then(vec2) { - o.insert("size".into(), s); + if let Some((x, y)) = v.size() { + o.insert("size".into(), json!({"x": x, "y": y})); } - let t = v.get("transform").and_then(matrix).unwrap_or_default(); + let t = v.transform().unwrap_or_default(); o.insert("relativeTransform".into(), rows(space * t)); // Paints, from the shared style when one is linked (the layer's own copy can be stale). let mut styles = Obj::new(); @@ -739,7 +826,8 @@ impl<'a> Mapper<'a> { vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect(); let style = |id: u32| styles.get(&u64::from(id)).copied().filter(|_| id != 0); let scale = |k: &str| { - let (size, norm) = (v.get("size").and_then(|s| num(s, k)), vd.get("normalizedSize").and_then(|s| num(s, k))); + let size = v.size().map(|s| if k == "x" { s.0 } else { s.1 }); + let norm = vd.get("normalizedSize").and_then(|s| num(s, k)); size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n) }; let (sx, sy) = (scale("x"), scale("y")); @@ -1128,7 +1216,7 @@ impl<'a> Mapper<'a> { found.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1))); let mut out = Vec::new(); for (_, id, n) in found { - let Some(node) = self.layer(&id, None, Affine::IDENTITY, None, 0) else { break }; + let Some(node) = self.layer(&id, None, Affine::IDENTITY, None, None, 0) else { break }; let name = n.get("name").and_then(Kv::str).unwrap_or("Style"); let desc = n.get("styleDescription").or_else(|| n.get("description")).and_then(Kv::str).unwrap_or(""); let ty = n.get("styleType").and_then(Kv::str).unwrap_or(""); diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 541739c71..aa2d5d066 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -32,6 +32,7 @@ enum ExpressionFunction ADDITION SUBTRACTION RESOLVE_VARIANT MULTIPLY DIVIDE EQU enum NavigationType NAVIGATE OVERLAY enum TransitionType INSTANT_TRANSITION DISSOLVE SMART_ANIMATE enum EasingType IN_CUBIC OUT_CUBIC +enum ConstraintType MIN CENTER MAX STRETCH SCALE struct GUID uint sessionID, uint localID struct Color float r, float g, float b, float a struct Vector float x, float y @@ -70,7 +71,7 @@ message Event InteractionType interactionType message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType, VariableTarget targetVariable, VariableData targetVariableData, VariableSetID targetVariableSetID, GUID targetVariableModeID, ConditionalActions[] conditionalActions message Interaction Event event, Action[] actions message StartingPoint string name, string position -message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData message Blob byte[] bytes message Message NodeChange[] nodeChanges, Blob[] blobs "; @@ -665,6 +666,61 @@ fn instances_inside_their_own_main_stop() { assert!(doc.nodes().any(|n| n.name == "Self")); } +#[test] +fn resized_instances_lay_their_layers_out_by_their_constraints() { + let pin = |h: &'static str, v: &'static str| vec![("horizontalConstraint", e(h)), ("verticalConstraint", e(v))]; + let layer = |id, pos, ty, name, w, h, x, y, c: Vec<(&'static str, Kv<'static>)>| { + node(id, Some((5, pos)), ty, name, [vec![("size", xy(w, h)), ("transform", at(x, y))], c].concat()) + }; + let nodes = vec![ + node(0, None, "DOCUMENT", "D", vec![]), + node(1, Some((0, "!")), "CANVAS", "P", vec![]), + node(5, Some((1, "!")), "SYMBOL", "Logo", vec![("size", xy(132.0, 22.0)), ("transform", at(0.0, 0.0))]), + layer(6, "!", "ROUNDED_RECTANGLE", "Mark", 132.0, 22.0, 0.0, 0.0, pin("SCALE", "SCALE")), + layer(7, "\"", "ROUNDED_RECTANGLE", "Badge", 32.0, 10.0, 100.0, 12.0, pin("MAX", "MAX")), + layer(8, "#", "ROUNDED_RECTANGLE", "Bar", 112.0, 2.0, 10.0, 0.0, pin("STRETCH", "MIN")), + // A group scales its layers with it. + layer(11, "$", "FRAME", "Wordmark", 66.0, 22.0, 66.0, 0.0, [pin("SCALE", "SCALE"), vec![("resizeToFit", Kv::Bool(true))]].concat()), + node(12, Some((11, "!")), "ROUNDED_RECTANGLE", "Letter", vec![("size", xy(66.0, 22.0)), ("transform", at(0.0, 0.0))]), + node( + 9, + Some((1, "\"")), + "INSTANCE", + "Small logo", + vec![("size", xy(90.0, 15.0)), ("transform", at(0.0, 100.0)), ("symbolData", o(vec![("symbolID", g(5))]))], + ), + // Derived layout the file gives wins over the constraints. + node( + 10, + Some((1, "#")), + "INSTANCE", + "Derived logo", + vec![ + ("size", xy(90.0, 15.0)), + ("transform", at(0.0, 200.0)), + ("symbolData", o(vec![("symbolID", g(5))])), + ("derivedSymbolData", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))])), ("size", xy(50.0, 5.0))])])), + ], + ), + ]; + let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + let box_of = |inst: &str, name: &str| { + let i = named(&doc, inst); + let n = i.children.iter().map(|c| doc.node(*c).unwrap()).find(|n| n.name == name).unwrap(); + let t = n.transform.translation(); + [t.x, t.y, n.width, n.height].map(|x| (x * 1e3).round() / 1e3) + }; + assert_eq!(box_of("Small logo", "Mark"), [0.0, 0.0, 90.0, 15.0]); + assert_eq!(box_of("Small logo", "Badge"), [58.0, 5.0, 32.0, 10.0]); + assert_eq!(box_of("Small logo", "Bar"), [10.0, 0.0, 70.0, 2.0]); + // The main's own, then both instances'. + let mut letters: Vec<[f64; 2]> = + doc.nodes().filter(|n| n.name == "Letter").map(|n| [n.width, n.height].map(|x| (x * 1e3).round() / 1e3)).collect(); + letters.sort_by(|a, b| a[0].total_cmp(&b[0])); + assert_eq!(letters, [[45.0, 15.0], [45.0, 15.0], [66.0, 22.0]]); + assert_eq!(box_of("Derived logo", "Mark")[2..], [50.0, 5.0]); +} + #[path = "tail_tests.rs"] mod tail; From d8556e8f17afc4c33cc7a4f33d9a9fe7cef21d65 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 12:12:46 +0200 Subject: [PATCH 014/120] M6: .fig import: size overrides keep the constraint placement, nested instances resized again drop their old derived layout (#266) --- crates/format/src/fig/map.rs | 54 ++++++++++++++++++--------- crates/format/src/fig/tests.rs | 67 +++++++++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 19 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 7d726183b..338bbcd29 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -86,6 +86,10 @@ fn vec2(v: &Kv) -> Option { Some(json!({"x": num(v, "x")?, "y": num(v, "y")?})) } +fn size(v: &Kv) -> Option<(f64, f64)> { + Some((num(v, "x")?, num(v, "y")?)) +} + fn matrix(m: &Kv) -> Option { let c = |k: &str| num(m, k); let a = Affine::new([c("m00")?, c("m10")?, c("m01")?, c("m11")?, c("m02")?, c("m12")?]); @@ -123,7 +127,7 @@ impl<'a> View<'a> { } fn size(&self) -> Option<(f64, f64)> { - self.fitted.map(|f| f.0).or_else(|| self.get("size").and_then(|s| Some((num(s, "x")?, num(s, "y")?)))) + self.fitted.map(|f| f.0).or_else(|| self.get("size").and_then(size)) } fn transform(&self) -> Option { @@ -185,7 +189,8 @@ impl Fit { let [a, b, c, d, _, _] = t.as_coeffs(); let lin = Affine::new([a, b, c, d, 0.0, 0.0]); let bb = figcraft_doc::geom::transform_rect_bbox(t, figcraft_doc::geom::Rect::new(0.0, 0.0, w, h)); - let (kx, ky, x0, y0) = match self { + // Where the new box goes: its top-left (constraints) or its centre (a group's scale). + let (kx, ky, x0, y0, centred) = match self { Fit::Frame { from, to } => { let axis = |con: Option<&str>, lo: f64, hi: f64, old: f64, new: f64| match con { Some("MAX" | "FIXED_MAX") => (lo + new - old, hi + new - old), @@ -197,9 +202,9 @@ impl Fit { let (x0, x1) = axis(h_con, bb.x0, bb.x1, from.0, to.0); let (y0, y1) = axis(v_con, bb.y0, bb.y1, from.1, to.1); let k = |new: f64, old: f64| if old > 1e-9 { (new / old).max(0.0) } else { 1.0 }; - (k(x1 - x0, bb.width()), k(y1 - y0, bb.height()), x0, y0) + (k(x1 - x0, bb.width()), k(y1 - y0, bb.height()), x0, y0, false) } - Fit::Scale(kx, ky) => (kx, ky, bb.x0 * kx, bb.y0 * ky), + Fit::Scale(kx, ky) => (kx, ky, bb.center().x * kx, bb.center().y * ky, true), }; let along = |x: f64, y: f64, fallback: f64| { let len = x.hypot(y); @@ -208,17 +213,21 @@ impl Fit { }; let size = (w * along(a, b, kx), h * along(c, d, ky)); let nb = figcraft_doc::geom::transform_rect_bbox(lin, figcraft_doc::geom::Rect::new(0.0, 0.0, size.0, size.1)); - (size, Affine::translate((x0 - nb.x0, y0 - nb.y0)) * lin) + let at = if centred { nb.center() } else { figcraft_doc::geom::Point::new(nb.x0, nb.y0) }; + (size, Affine::translate((x0 - at.x, y0 - at.y)) * lin) } } +/// Overrides and derived layout by id path; each entry says whether it's derived layout. +type Overrides<'a> = BTreeMap, Vec<(bool, &'a Kv<'a>)>>; + /// Where an instance's expanded layers are: the overrides and derived layout of the outermost /// instance (by id path), the path of the instance being expanded, and the property values. #[derive(Clone)] struct Inst<'a> { root: String, path: Vec, - overrides: Rc, Vec<&'a Kv<'a>>>>, + overrides: Rc>, props: Rc>>, /// Mains being expanded (an instance inside its own main stops there). symbols: Vec, @@ -360,18 +369,23 @@ impl<'a> Mapper<'a> { } let base = *self.nodes.get(id)?; let path = inst.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()); - let mut view = match (inst, &path) { - (Some(i), Some(p)) => View::of(base).with(i.overrides.get(p).into_iter().flatten().copied()), - _ => View::of(base), + let entries = match (inst, &path) { + (Some(i), Some(p)) => i.overrides.get(p).map(Vec::as_slice).unwrap_or_default(), + _ => &[], }; + let mut view = View::of(base).with(entries.iter().map(|e| e.1)); // A layer of a resized instance the file gives no derived layout for follows its - // constraints (auto layout places the others, as in the editor). - let derived = view.layers.iter().skip(1).any(|l| l.get("size").is_some() || l.get("transform").is_some()); + // constraints from the main's geometry, then takes the size and place overridden on it + // (auto layout places the others, as in the editor). + let derived = entries.iter().any(|(d, l)| *d && (l.get("size").is_some() || l.get("transform").is_some())); let stacked = parent.and_then(|p| p.str("stackMode")).is_some_and(|m| matches!(m, "HORIZONTAL" | "VERTICAL" | "GRID")) && view.str("stackPositioning") != Some("ABSOLUTE"); - if let (Some(f), false, false, Some((w, h))) = (fit, derived, stacked, view.size()) { - let t = view.transform().unwrap_or_default(); - view.fitted = Some(f.apply(w, h, t, view.str("horizontalConstraint"), view.str("verticalConstraint"))); + let main = View::of(base); + if let (Some(f), false, false, Some((w, h))) = (fit, derived, stacked, main.size()) { + let t = main.transform().unwrap_or_default(); + let (fw, ft) = f.apply(w, h, t, view.str("horizontalConstraint"), view.str("verticalConstraint")); + let set = |k: &str| entries.iter().rev().find_map(|(_, l)| l.get(k)); + view.fitted = Some((set("size").and_then(size).unwrap_or(fw), set("transform").and_then(matrix).unwrap_or(ft))); } let rest_id = match (inst, &path) { (Some(i), Some(p)) => format!("I{};{}", i.root, p.join(";")), @@ -996,9 +1010,15 @@ impl<'a> Mapper<'a> { } // Overrides by id path below this instance (the instance itself is addressed by its main's id). let own_path: Vec = outer.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()).unwrap_or_default(); - let mut overrides: BTreeMap, Vec<&'a Kv<'a>>> = outer.map(|i| (*i.overrides).clone()).unwrap_or_default(); + let mut overrides: Overrides<'a> = outer.map(|i| (*i.overrides).clone()).unwrap_or_default(); + // A nested instance's derived layout is for the size it has in its main: resized here, it + // no longer holds and its layers follow their constraints instead. + let resized = outer.is_some() && self.nodes.get(id).and_then(|b| View::of(b).size()) != v.size(); let lists = [sd.and_then(|s| s.get("symbolOverrides")).map(Kv::list).unwrap_or_default(), v.list("derivedSymbolData")]; for (layer, list) in lists.into_iter().enumerate() { + if layer == 1 && resized { + continue; + } for ov in list { let rel: Vec = ov.get("guidPath").and_then(|g| g.get("guids")).map(Kv::list).unwrap_or_default().iter().filter_map(guid).collect(); @@ -1008,9 +1028,9 @@ impl<'a> Mapper<'a> { let e = overrides.entry([own_path.as_slice(), &rel].concat()).or_default(); // The outer instance's overrides win over this one's own; derived layout comes last. if layer == 0 && outer.is_some() { - e.insert(0, ov); + e.insert(0, (false, ov)); } else { - e.push(ov); + e.push((layer == 1, ov)); } } } diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index aa2d5d066..6d382c154 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -702,6 +702,24 @@ fn resized_instances_lay_their_layers_out_by_their_constraints() { ("derivedSymbolData", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))])), ("size", xy(50.0, 5.0))])])), ], ), + // A size the instance overrides: the place still follows the constraints. + node( + 13, + Some((1, "$")), + "INSTANCE", + "Resized badge", + vec![ + ("size", xy(90.0, 15.0)), + ("transform", at(0.0, 300.0)), + ( + "symbolData", + o(vec![ + ("symbolID", g(5)), + ("symbolOverrides", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(7)]))])), ("size", xy(20.0, 10.0))])])), + ]), + ), + ], + ), ]; let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); let box_of = |inst: &str, name: &str| { @@ -713,12 +731,57 @@ fn resized_instances_lay_their_layers_out_by_their_constraints() { assert_eq!(box_of("Small logo", "Mark"), [0.0, 0.0, 90.0, 15.0]); assert_eq!(box_of("Small logo", "Badge"), [58.0, 5.0, 32.0, 10.0]); assert_eq!(box_of("Small logo", "Bar"), [10.0, 0.0, 70.0, 2.0]); - // The main's own, then both instances'. + // The main's own, then the instances'. let mut letters: Vec<[f64; 2]> = doc.nodes().filter(|n| n.name == "Letter").map(|n| [n.width, n.height].map(|x| (x * 1e3).round() / 1e3)).collect(); letters.sort_by(|a, b| a[0].total_cmp(&b[0])); - assert_eq!(letters, [[45.0, 15.0], [45.0, 15.0], [66.0, 22.0]]); + assert_eq!(letters, [[45.0, 15.0], [45.0, 15.0], [45.0, 15.0], [66.0, 22.0]]); assert_eq!(box_of("Derived logo", "Mark")[2..], [50.0, 5.0]); + assert_eq!(box_of("Resized badge", "Badge"), [58.0, 5.0, 20.0, 10.0]); +} + +#[test] +fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() { + let stretch = || vec![("horizontalConstraint", e("STRETCH")), ("verticalConstraint", e("MIN"))]; + let nodes = vec![ + node(0, None, "DOCUMENT", "D", vec![]), + node(1, Some((0, "!")), "CANVAS", "P", vec![]), + node(20, Some((1, "!")), "SYMBOL", "Track", vec![("size", xy(100.0, 10.0)), ("transform", at(0.0, 0.0))]), + node(21, Some((20, "!")), "ROUNDED_RECTANGLE", "Fill", [vec![("size", xy(100.0, 10.0)), ("transform", at(0.0, 0.0))], stretch()].concat()), + node(30, Some((1, "\"")), "SYMBOL", "Slider", vec![("size", xy(200.0, 50.0)), ("transform", at(0.0, 100.0))]), + // The nested instance is 150 wide in its main, with the layout derived for that. + node( + 31, + Some((30, "!")), + "INSTANCE", + "Inner", + [ + vec![ + ("size", xy(150.0, 10.0)), + ("transform", at(0.0, 0.0)), + ("symbolData", o(vec![("symbolID", g(20))])), + ("derivedSymbolData", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(21)]))])), ("size", xy(150.0, 10.0))])])), + ], + stretch(), + ] + .concat(), + ), + node( + 40, + Some((1, "#")), + "INSTANCE", + "Wide slider", + vec![("size", xy(300.0, 50.0)), ("transform", at(0.0, 200.0)), ("symbolData", o(vec![("symbolID", g(30))]))], + ), + ]; + let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + let width = |outer: &str| { + let inner = doc.node(named(&doc, outer).children[0]).unwrap(); + (inner.width, doc.node(inner.children[0]).unwrap().width) + }; + // In its main the derived layout holds; stretched to 250 it no longer does. + assert_eq!(width("Slider"), (150.0, 150.0)); + assert_eq!(width("Wide slider"), (250.0, 250.0)); } #[path = "tail_tests.rs"] From ce366ea3258c9b1e40999ff75be4c6eea8ac1c61 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 12:14:45 +0200 Subject: [PATCH 015/120] M6: .fig import: a resized nested instance keeps the non-geometric derived data; resize check with a tolerance (#266) --- crates/format/src/fig/map.rs | 47 +++++++++++++++++++++++++--------- crates/format/src/fig/tests.rs | 12 ++++++++- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 338bbcd29..5a08dd405 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -218,8 +218,18 @@ impl Fit { } } -/// Overrides and derived layout by id path; each entry says whether it's derived layout. -type Overrides<'a> = BTreeMap, Vec<(bool, &'a Kv<'a>)>>; +/// Overrides and derived layout by id path. +type Overrides<'a> = BTreeMap, Vec<(Entry, &'a Kv<'a>)>>; + +/// Where an override entry comes from. +#[derive(Clone, Copy, PartialEq)] +enum Entry { + Override, + Derived, + /// Derived layout of a nested instance for a size it no longer has: its size and place no + /// longer hold, the rest (resolved colours, text) still does. + Stale, +} /// Where an instance's expanded layers are: the overrides and derived layout of the outermost /// instance (by id path), the path of the instance being expanded, and the property values. @@ -377,15 +387,19 @@ impl<'a> Mapper<'a> { // A layer of a resized instance the file gives no derived layout for follows its // constraints from the main's geometry, then takes the size and place overridden on it // (auto layout places the others, as in the editor). - let derived = entries.iter().any(|(d, l)| *d && (l.get("size").is_some() || l.get("transform").is_some())); + let derived = entries.iter().any(|(d, l)| *d == Entry::Derived && (l.get("size").is_some() || l.get("transform").is_some())); let stacked = parent.and_then(|p| p.str("stackMode")).is_some_and(|m| matches!(m, "HORIZONTAL" | "VERTICAL" | "GRID")) && view.str("stackPositioning") != Some("ABSOLUTE"); let main = View::of(base); + let set = |k: &str| entries.iter().rev().filter(|e| e.0 != Entry::Stale).find_map(|(_, l)| l.get(k)); if let (Some(f), false, false, Some((w, h))) = (fit, derived, stacked, main.size()) { let t = main.transform().unwrap_or_default(); let (fw, ft) = f.apply(w, h, t, view.str("horizontalConstraint"), view.str("verticalConstraint")); - let set = |k: &str| entries.iter().rev().find_map(|(_, l)| l.get(k)); view.fitted = Some((set("size").and_then(size).unwrap_or(fw), set("transform").and_then(matrix).unwrap_or(ft))); + } else if entries.iter().any(|(d, l)| *d == Entry::Stale && (l.get("size").is_some() || l.get("transform").is_some())) + && let Some(s) = set("size").and_then(size).or_else(|| main.size()) + { + view.fitted = Some((s, set("transform").and_then(matrix).or_else(|| main.transform()).unwrap_or_default())); } let rest_id = match (inst, &path) { (Some(i), Some(p)) => format!("I{};{}", i.root, p.join(";")), @@ -1011,14 +1025,16 @@ impl<'a> Mapper<'a> { // Overrides by id path below this instance (the instance itself is addressed by its main's id). let own_path: Vec = outer.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()).unwrap_or_default(); let mut overrides: Overrides<'a> = outer.map(|i| (*i.overrides).clone()).unwrap_or_default(); - // A nested instance's derived layout is for the size it has in its main: resized here, it - // no longer holds and its layers follow their constraints instead. - let resized = outer.is_some() && self.nodes.get(id).and_then(|b| View::of(b).size()) != v.size(); + // A nested instance's derived layout is for the size it has in its main: resized here, the + // sizes and places in it no longer hold and its layers follow their constraints instead. + let differs = |a: f64, b: f64| (a - b).abs() > 1e-6 * a.abs().max(b.abs()).max(1.0); + let resized = outer.is_some() + && match (self.nodes.get(id).and_then(|b| View::of(b).size()), v.size()) { + (Some(a), Some(b)) => differs(a.0, b.0) || differs(a.1, b.1), + (a, b) => a.is_some() != b.is_some(), + }; let lists = [sd.and_then(|s| s.get("symbolOverrides")).map(Kv::list).unwrap_or_default(), v.list("derivedSymbolData")]; for (layer, list) in lists.into_iter().enumerate() { - if layer == 1 && resized { - continue; - } for ov in list { let rel: Vec = ov.get("guidPath").and_then(|g| g.get("guids")).map(Kv::list).unwrap_or_default().iter().filter_map(guid).collect(); @@ -1028,9 +1044,16 @@ impl<'a> Mapper<'a> { let e = overrides.entry([own_path.as_slice(), &rel].concat()).or_default(); // The outer instance's overrides win over this one's own; derived layout comes last. if layer == 0 && outer.is_some() { - e.insert(0, (false, ov)); + e.insert(0, (Entry::Override, ov)); } else { - e.push((layer == 1, ov)); + e.push(( + match (layer, resized) { + (0, _) => Entry::Override, + (_, false) => Entry::Derived, + (_, true) => Entry::Stale, + }, + ov, + )); } } } diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 6d382c154..af8ac5a04 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -760,7 +760,14 @@ fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() { ("size", xy(150.0, 10.0)), ("transform", at(0.0, 0.0)), ("symbolData", o(vec![("symbolID", g(20))])), - ("derivedSymbolData", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(21)]))])), ("size", xy(150.0, 10.0))])])), + ( + "derivedSymbolData", + arr(vec![o(vec![ + ("guidPath", o(vec![("guids", arr(vec![g(21)]))])), + ("size", xy(150.0, 10.0)), + ("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])), + ])]), + ), ], stretch(), ] @@ -782,6 +789,9 @@ fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() { // In its main the derived layout holds; stretched to 250 it no longer does. assert_eq!(width("Slider"), (150.0, 150.0)); assert_eq!(width("Wide slider"), (250.0, 250.0)); + // What else the derived data gives (the colour a variable resolves to, say) still holds. + let fill = doc.node(doc.node(named(&doc, "Wide slider").children[0]).unwrap().children[0]).unwrap(); + assert!(matches!(fill.fills.first().map(|p| &p.kind), Some(figcraft_doc::color::PaintKind::Solid { color }) if color.r == 1.0 && color.g == 0.0)); } #[path = "tail_tests.rs"] From 61def74383ad636b4feadd773664bc3fa5b78e0a Mon Sep 17 00:00:00 2001 From: Justin John Date: Wed, 14 Oct 2026 16:04:34 +0530 Subject: [PATCH 016/120] M0: finish canvas drags released outside --- crates/engine/src/double_click_tests.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 11 +++++++++++ crates/ui-egui/src/canvas_drag_tests.rs | 16 ++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/crates/engine/src/double_click_tests.rs b/crates/engine/src/double_click_tests.rs index e51cb7da8..e9532f3e4 100644 --- a/crates/engine/src/double_click_tests.rs +++ b/crates/engine/src/double_click_tests.rs @@ -74,6 +74,26 @@ fn double_clicks_reach_and_edit_text_in_a_group_wherever_they_land() { } } +#[test] +fn editing_text_keeps_other_document_changes_since_open() { + let mut s = Session::new(); + let first = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 80, "height": 40}))["id"]).unwrap(); + let deleted = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 120, "y": 20, "width": 80, "height": 40}))["id"]).unwrap(); + let text = id_of(&ex(&mut s, "text.create", json!({"text": "A", "x": 20, "y": 100}))["id"]).unwrap(); + ex(&mut s, "node.set", json!({"ids": [first], "props": {"x": 650}})); + ex(&mut s, "edit.delete", json!({"ids": [deleted]})); + + let bounds = s.doc().unwrap().world_bounds(text).unwrap(); + double_click(&mut s, bounds.center().x, bounds.center().y, 0.0, 1.0); + assert_eq!(editing(&s), Some(text)); + crate::textedit::insert(&mut s, "B").unwrap(); + crate::textedit::end(&mut s).unwrap(); + + assert_eq!(s.doc().unwrap().world_bounds(first).unwrap().x0, 650.0); + assert!(!s.doc().unwrap().contains(deleted)); + assert_eq!(s.doc().unwrap().node(text).unwrap().kind.text().unwrap().characters, "B"); +} + #[test] fn a_press_drags_only_past_the_threshold_in_screen_pixels() { for (zoom, by, moves) in [(1.0, 2.0, false), (1.0, 4.0, true), (4.0, 0.5, false), (4.0, 1.0, true), (0.25, 8.0, false), (0.25, 16.0, true)] { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb23c8de0..8fd2e6c46 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -371,6 +371,17 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { } } let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else { + if app.canvas.dragging && ctx.input(|i| i.pointer.button_released(egui::PointerButton::Primary)) { + app.canvas.dragging = false; + let pos = hover_pos.unwrap_or(app.canvas.rect.center()); + let ev = pointer_event(app, PointerKind::Up, pos, &mods); + app.canvas.pressure = None; + app.canvas.last_pointer = Some(Point::new(ev.x, ev.y)); + if let Err(e) = app.session.pointer(&ev) { + app.toast(e.to_string()); + } + app.handle_ui_requests(); + } // Leaving the canvas: nothing is hovered there any more. if !app.canvas.dragging && std::mem::take(&mut app.canvas.hovering) { app.session.vector_pointer_left(); diff --git a/crates/ui-egui/src/canvas_drag_tests.rs b/crates/ui-egui/src/canvas_drag_tests.rs index 9bc56fb2a..112ba0e11 100644 --- a/crates/ui-egui/src/canvas_drag_tests.rs +++ b/crates/ui-egui/src/canvas_drag_tests.rs @@ -59,3 +59,19 @@ fn a_drag_shows_on_the_canvas_while_it_moves() { assert!(u.app.worker.as_ref().is_some_and(|w| !w.in_flight()), "nothing left to render"); u.button(p + vec2(30.0, 10.0), false); } + +#[test] +fn releasing_a_canvas_drag_outside_finishes_the_interaction() { + let mut s = Session::new(); + let r = + figcraft_engine::params::id_of(&s.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 80, "height": 60})).unwrap()["id"]).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + let start = u.app.to_screen(80.0, 70.0); + let outside = egui::pos2(u.app.canvas.rect.max.x + 20.0, start.y); + u.button(start, true); + u.frame(vec![Event::PointerMoved(outside)]); + u.button(outside, false); + + assert!(u.app.session.ds().unwrap().interaction.is_none(), "the document interaction is committed"); +} From efdd5fc64514cdd6a5e02623564c01f6525fff2f Mon Sep 17 00:00:00 2001 From: notdezzi Date: Wed, 14 Oct 2026 12:40:17 +0200 Subject: [PATCH 017/120] fix(format): import explicit variable modes on frames and instances A collection a saved file references by its asset key (the form library collections keep) instead of a guid resolved to nothing, so every explicitVariableModes entry was dropped silently and bound layers rendered in the collection's default mode (#267). The .fig import now resolves such references against the file's own nodes (the collection node carries the same key) for mode entries and the variables' collection link, and reports modes of collections it can't resolve. --- crates/format/src/fig/map.rs | 53 ++++++++++++++++++++-- crates/format/src/fig/tests.rs | 82 ++++++++++++++++++++++++++++++++-- docs/file-format-parity.md | 3 +- 3 files changed, 130 insertions(+), 8 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 787ca6d4b..95c9a1fa4 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -25,6 +25,8 @@ const MAX_DEPTH: usize = 256; const MAX_COMMANDS: usize = 200_000; /// Id of "no style". const NO_STYLE: &str = "4294967295:4294967295"; +/// Longest asset `key` or `version` kept for resolving references given as assets. +const MAX_ASSET_PART: usize = 256; /// The REST API file JSON for message `msg`, with the warnings about what was skipped. pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { @@ -165,6 +167,10 @@ struct Mapper<'a> { blobs: Vec<&'a [u8]>, /// Component property definitions: id → (REST name "Name#id", type). prop_defs: BTreeMap, + /// File nodes by asset `key` and `version`: references can name a collection, variable or + /// style as the asset instead of the guid, and the file carries the resources to resolve + /// them (the collection node itself says the same `key`). + assets: BTreeMap>, styles: Obj, /// Descriptions of components and component sets (REST `components` / `componentSets`). components: Obj, @@ -203,7 +209,36 @@ impl<'a> Mapper<'a> { } let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default().iter().map(|b| b.get("bytes").and_then(Kv::bytes).unwrap_or_default()).collect(); - Self { nodes, kids, blobs, prop_defs, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 } + let mut assets: BTreeMap> = BTreeMap::new(); + for n in msg.get("nodeChanges").map(Kv::list).unwrap_or_default() { + let (Some(key), Some(id)) = (n.get("key").and_then(Kv::str), n.get("guid").and_then(guid)) else { continue }; + if key.is_empty() || key.len() > MAX_ASSET_PART { + continue; + } + let version = n.get("version").and_then(Kv::str).filter(|v| !v.is_empty() && v.len() <= MAX_ASSET_PART).unwrap_or(""); + assets.entry(key.to_string()).or_default().entry(version.to_string()).or_insert(id); + } + Self { nodes, kids, blobs, prop_defs, assets, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 } + } + + /// The id of the resource a `VariableSetID` (or `VariableID`, `StyleId`) names: its `guid`, + /// else the file node its asset `key` and `version` names (a collection from a library is + /// referenced as the asset, and the file's own node for it says the same `key`). `None` when + /// the reference has neither or the asset names nothing (or ambiguously) in the file. + fn resource_id(&self, v: &Kv) -> Option { + if let Some(g) = v.get("guid").and_then(guid).filter(|g| *g != NO_STYLE) { + return Some(g); + } + let asset = v.get("assetRef")?; + let key = asset.get("key").and_then(Kv::str)?; + let versions = self.assets.get(key)?; + let version = asset.get("version").and_then(Kv::str); + match version { + Some(v) => versions.get(v), + // Without a version: an unversioned record, else the file's only one. + None => versions.get("").or_else(|| (versions.len() == 1).then(|| versions.values().next()).flatten()), + } + .cloned() } fn warn(&mut self, w: impl Into) { @@ -1105,11 +1140,21 @@ impl<'a> Mapper<'a> { o.insert("style".into(), json!({"boundVariables": text_bound})); } let mut modes = Obj::new(); + let mut lost = false; for e in v.get("variableModeBySetMap").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() { - if let (Some(set), Some(mode)) = (e.get("variableSetID").and_then(ref_guid), e.get("variableModeID").and_then(guid)) { - modes.insert(set, json!(mode)); + let (Some(set), Some(mode)) = (e.get("variableSetID"), e.get("variableModeID").and_then(guid)) else { continue }; + match self.resource_id(set) { + Some(id) => { + modes.insert(id, json!(mode)); + } + // A mode of a collection neither named by guid nor resolvable in the file. + None if set.get("guid").is_some() || set.get("assetRef").is_some() => lost = true, + None => {} } } + if lost { + self.warn("variable modes of collections that aren't in the file were skipped"); + } if !modes.is_empty() { o.insert("explicitVariableModes".into(), Value::Object(modes)); } @@ -1174,7 +1219,7 @@ impl<'a> Mapper<'a> { let mut variable_order = Vec::new(); let mut variables = Obj::new(); for (id, n) in of_type("VARIABLE", "sortPosition") { - let Some(set) = n.get("variableSetID").and_then(ref_guid).filter(|s| collections.contains_key(s)) else { continue }; + let Some(set) = n.get("variableSetID").and_then(|s| self.resource_id(s)).filter(|s| collections.contains_key(s)) else { continue }; let ty = n.get("variableResolvedType").and_then(Kv::str).unwrap_or(""); if !matches!(ty, "BOOLEAN" | "FLOAT" | "STRING" | "COLOR") { continue; diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 541739c71..51763acfa 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -38,12 +38,13 @@ struct Vector float x, float y struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 struct ColorStop Color color, float position message Image byte[] hash, string name -message VariableID GUID guid +message AssetRef string key, string version +message VariableID GUID guid, AssetRef assetRef message PropRefValue GUID defId message AnyValue bool boolValue, float floatValue, VariableID alias, Color colorValue, PropRefValue propRefValue, TextData textDataValue, string textValue, Expression expressionValue message Expression ExpressionFunction expressionFunction, VariableData[] expressionArguments message VariableTarget VariableID id -message VariableSetID GUID guid +message VariableSetID GUID guid, AssetRef assetRef message ConditionalActions Action[] actions, VariableData condition message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition @@ -70,7 +71,7 @@ message Event InteractionType interactionType message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType, VariableTarget targetVariable, VariableData targetVariableData, VariableSetID targetVariableSetID, GUID targetVariableModeID, ConditionalActions[] conditionalActions message Interaction Event event, Action[] actions message StartingPoint string name, string position -message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, string key, string version message Blob byte[] bytes message Message NodeChange[] nodeChanges, Blob[] blobs "; @@ -665,6 +666,81 @@ fn instances_inside_their_own_main_stop() { assert!(doc.nodes().any(|n| n.name == "Self")); } +/// A collection from a library is named by its asset key, not a guid — also in the explicit mode +/// map of the frames, components and instances set it on, and in the variables' collection link. +#[test] +fn modes_of_collections_named_by_asset_import() { + let modes = arr(vec![ + o(vec![("id", g(90)), ("name", s("Dark")), ("sortPosition", s("!"))]), + o(vec![("id", g(91)), ("name", s("Light")), ("sortPosition", s("\""))]), + ]); + let color_value = |c| o(vec![("value", o(vec![("colorValue", c)])), ("dataType", e("COLOR"))]); + let values = o(vec![( + "entries", + arr(vec![ + o(vec![("modeID", g(90)), ("variableData", color_value(rgb(0.0, 0.0, 0.0)))]), + o(vec![("modeID", g(91)), ("variableData", color_value(rgb(1.0, 1.0, 1.0)))]), + ]), + )]); + let by_asset = |key: &'static str, version: Option<&'static str>| { + let mut r = vec![("key", s(key))]; + r.extend(version.map(|v| ("version", s(v)))); + o(vec![("assetRef", o(r))]) + }; + let mode_map = |set: Kv<'static>| o(vec![("entries", arr(vec![o(vec![("variableSetID", set), ("variableModeID", g(91))])]))]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page A", vec![]), + node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]), + // The collection itself says the asset key its references name. + node(4, Some((2, "!")), "VARIABLE_SET", "Schema", vec![("variableSetModes", modes), ("key", s("schema-collection")), ("version", s("7"))]), + node( + 5, + Some((2, "\"")), + "VARIABLE", + "surface", + vec![("variableSetID", by_asset("schema-collection", Some("7"))), ("variableResolvedType", e("COLOR")), ("variableDataValues", values)], + ), + node( + 10, + Some((1, "!")), + "FRAME", + "Light section", + vec![("size", xy(400.0, 300.0)), ("variableModeBySetMap", mode_map(by_asset("schema-collection", Some("7"))))], + ), + node( + 20, + Some((1, "\"")), + "SYMBOL", + "Button", + vec![("size", xy(100.0, 40.0)), ("variableModeBySetMap", mode_map(by_asset("schema-collection", None)))], + ), + node( + 21, + Some((1, "#")), + "INSTANCE", + "Button copy", + vec![ + ("size", xy(100.0, 40.0)), + ("symbolData", o(vec![("symbolID", g(20))])), + ("variableModeBySetMap", mode_map(by_asset("schema-collection", Some("7")))), + ], + ), + // One names a collection that isn't in the file: its mode is skipped, with a warning. + node(11, Some((1, "$")), "FRAME", "Gone", vec![("size", xy(400.0, 300.0)), ("variableModeBySetMap", mode_map(by_asset("gone", None)))]), + ]; + let (doc, warnings) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + assert_eq!(doc.variables.collections.len(), 1); + assert_eq!(doc.variables.variables.len(), 1); + assert_eq!(doc.variables.variables[0].collection, "1:4"); + for name in ["Light section", "Button", "Button copy"] { + let n = named(&doc, name); + assert_eq!(n.var_modes.get("1:4").map(String::as_str), Some("1:91"), "{name}"); + } + assert!(named(&doc, "Gone").var_modes.is_empty()); + assert!(warnings.iter().any(|w| w.contains("variable modes of collections that aren't in the file")), "{warnings:?}"); +} + #[path = "tail_tests.rs"] mod tail; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d8e517119..361111c2d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.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), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), 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. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, 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 | +| `.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), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings (collections a reference names by its asset key, as library collections keep, resolve to the file's own collection node), 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. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, 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, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; 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`) | — | @@ -163,6 +163,7 @@ each. | Date | Change | Summary | |---|---|---| | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | +| 2026-10-14 | minor | `.fig` import (gap 1): explicit variable modes of frames, components and instances come across when the collection is named by its asset key instead of a guid (the form library collections keep in saved files); before, every such mode entry was dropped silently and bound layers resolved in the collection's default mode (#267). The variables' own collection link resolves by asset too, and modes of collections that aren't in the file are reported. Fixture-tested; real-file checks still owed. Numbers unchanged | | 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | | 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged | | 2026-10-12 | minor | Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig (experimental), the CLI and MCP write the whole document (layer tree, geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto layout, constraints, components, sets, instances with their overrides and property values, prototype interactions) with a kiwi encoder that mirrors the decoder and the schema of the fields written; what's left out is a warning. The import gained ellipse arcs, boolean operation types and component set descriptions, and reads durations as whole milliseconds. Verified only by round trips through our own importer. Summary ~59% → ~60%; `.fig` write 15–30 h → 5–15 h (checks against real files) | From 10dc38495f0ab020c3852e6f864de1cdb15be931 Mon Sep 17 00:00:00 2001 From: notdezzi Date: Wed, 14 Oct 2026 12:54:02 +0200 Subject: [PATCH 018/120] fix(engine): roll back failed commands during interactions too A command that fails part-way (node.set applies geometry keys before values like visible, which can fail validation) mutated the document even when an interaction was under way, because the rollback in Session::execute was gated on interaction.is_none(); drop the gate so the command-entry snapshot is restored during interactions as well, which is the interaction's last valid preview and leaves the gesture itself intact. --- .../engine/src/interaction_rollback_tests.rs | 67 +++++++++++++++++++ crates/engine/src/lib.rs | 7 +- 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/interaction_rollback_tests.rs diff --git a/crates/engine/src/interaction_rollback_tests.rs b/crates/engine/src/interaction_rollback_tests.rs new file mode 100644 index 000000000..3a7380170 --- /dev/null +++ b/crates/engine/src/interaction_rollback_tests.rs @@ -0,0 +1,67 @@ +//! A command that fails part-way (a property that applies, then one that doesn't) leaves nothing +//! behind — also during an interaction, whose last valid preview and own snapshot survive it. + +use serde_json::json; + +use crate::Session; +use crate::params::id_of; + +fn rect(s: &mut Session) -> crate::NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 80, "height": 60})).unwrap()["id"]).unwrap() +} + +/// `width` applies (geometry keys go first), then `visible` fails validation. +fn partially_applicable(s: &mut Session, id: crate::NodeId) -> crate::Result { + s.execute("node.set", &json!({"id": id, "width": 123, "visible": "yes"})) +} + +#[test] +fn a_failed_command_during_an_interaction_keeps_the_prior_preview() { + let mut s = Session::new(); + let r = rect(&mut s); + s.begin_interaction("Move").unwrap(); + let props = serde_json::Map::from_iter([("x".to_string(), json!(20.0))]); + s.preview(|d, _| crate::cmd::props::apply("node.set", d, r, &props)).unwrap(); + let (previewed, selection, page) = { + let ds = s.ds().unwrap(); + (ds.doc.clone(), ds.selection.clone(), ds.page) + }; + let before_journal = s.journal.clone(); + let before_history = s.ds().unwrap().history.undo.len(); + + assert!(partially_applicable(&mut s, r).is_err()); + + // The rejected edit is gone: the command-entry state (the valid preview) is back. + let ds = s.ds().unwrap(); + assert_eq!(*ds.doc, *previewed, "the document is what the preview showed"); + assert_eq!(ds.doc.node(r).unwrap().width, 80.0); + assert_eq!(ds.selection, selection); + assert_eq!(ds.page, page); + assert_eq!(ds.history.undo.len(), before_history); + assert_eq!(s.journal, before_journal); + + // The interaction is alive and commits the preview, not the rejected mutation. + s.commit_interaction().unwrap(); + let n = s.doc().unwrap().node(r).unwrap(); + assert_eq!((n.width, n.visible), (80.0, true)); + assert_eq!(n.transform.as_coeffs()[4], 20.0, "the previewed move is committed"); + assert_eq!(s.undo_depth(), (before_history + 1, 0)); + assert_eq!(s.undo_labels(1), vec!["Move".to_string()]); + s.undo().unwrap(); + assert_eq!(s.doc().unwrap().node(r).unwrap().transform.as_coeffs()[4], 0.0); +} + +#[test] +fn a_failed_command_without_an_interaction_still_rolls_back() { + let mut s = Session::new(); + let r = rect(&mut s); + let before_journal = s.journal.clone(); + let before_history = s.ds().unwrap().history.undo.len(); + + assert!(partially_applicable(&mut s, r).is_err()); + + let n = s.doc().unwrap().node(r).unwrap(); + assert_eq!((n.width, n.visible), (80.0, true)); + assert_eq!(s.ds().unwrap().history.undo.len(), before_history); + assert_eq!(s.journal, before_journal); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a906..c3ff71cad 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -837,10 +837,11 @@ impl Session { } } Err(_) => { - // Roll back any partial edit. + // Roll back any partial edit — also during an interaction, where `before` is the + // interaction's last valid preview: restore it and leave the gesture itself (its + // own snapshot) alone. if let Some((doc, sel, page, uid)) = before && let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) - && ds.interaction.is_none() { ds.doc = doc; ds.selection = sel; @@ -1477,6 +1478,8 @@ mod import_export_tests; #[cfg(test)] mod inspect_behaviour_tests; #[cfg(test)] +mod interaction_rollback_tests; +#[cfg(test)] mod invite_links_tests; #[cfg(test)] mod keymap_tests; From 73b2845ba4c794f5c3068ff41693625bdfcb3bce Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Wed, 14 Oct 2026 18:03:48 +0700 Subject: [PATCH 019/120] fix(engine): Alt-drag copies snap to their original; Duplicate repeats the step - A copy Alt-dragged off a layer snaps to that layer too: its edges, centres and equal spacing (Targets::add_box with the originals' box). Before, the originals were left out of the snap targets, so a copy couldn't line up with or space evenly from the layer it came from. - Duplicate (Cmd+D) repeats the last duplicate's page offset while its copies are what it duplicates: Alt-drag a copy, or Duplicate and move the copy, then each Cmd+D steps out the next one as far again. The step is measured when Duplicate runs. It lasts as long as the selection (undo reuses ids), copies placed by an auto layout set no step, and explicit dx/dy win. - measure_tests: an Alt-dragged copy between its original and another layer now snaps to equal spacing (45/45 instead of 50/40). Tests: duplicate_spacing_tests (snap to the original, equal spacing, repeat after moving a copy and after an Alt-drag, only for the last copies, undo then a new layer, rotated frame, auto layout). Refs #256 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/edit.rs | 52 +++++- crates/engine/src/duplicate_spacing_tests.rs | 159 +++++++++++++++++++ crates/engine/src/lib.rs | 10 ++ crates/engine/src/measure_tests.rs | 11 +- crates/engine/src/snap.rs | 22 +-- crates/engine/src/tools.rs | 13 +- docs/mcp.md | 6 + docs/ui-parity.md | 2 +- 8 files changed, 253 insertions(+), 22 deletions(-) create mode 100644 crates/engine/src/duplicate_spacing_tests.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 57ba9fb00..df6afc6d6 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { spec!( "edit.duplicate", "Duplicate", - "{ids?, dx?, dy? (offset; default 0 for frames-in-place... Figma offsets top-level frames to the right)}", + "{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)}", true, duplicate ), @@ -412,13 +412,48 @@ fn delete(s: &mut Session, p: &Value) -> Result { Ok(json!({"deleted": ids})) } +/// Layers duplicated last (Duplicate or Alt-drag) and their copies, in the same order. While the +/// copies are the selection, Duplicate places the next copies as far from them as they are from +/// their sources, measured then: so moving the copies first sets the step. +#[derive(Clone, Debug)] +pub struct Duplicated { + pub sources: Vec, + pub copies: Vec, +} + +impl Duplicated { + pub fn is_selection(&self, sel: &[NodeId]) -> bool { + let set = |v: &[NodeId]| v.iter().copied().collect::>(); + !self.copies.is_empty() && set(&self.copies) == set(sel) + } + + /// The page offset from the sources to the copies, if both are still there and the copies move + /// freely (an auto layout places its own). + fn offset(&self, doc: &Document) -> Option<(f64, f64)> { + let free = self + .copies + .iter() + .all(|c| doc.node(*c).is_some_and(|n| n.child_layout.absolute || n.parent.and_then(|p| doc.node(p)).is_none_or(|p| p.layout.is_none()))); + if !free || self.sources.iter().chain(&self.copies).any(|i| !doc.contains(*i)) { + return None; + } + let (a, b) = (doc.bounds_of(&self.sources)?, doc.bounds_of(&self.copies)?); + let d = (b.x0 - a.x0, b.y0 - a.y0); + (d.0.is_finite() && d.1.is_finite() && (d.0 != 0.0 || d.1 != 0.0)).then_some(d) + } +} + fn duplicate(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.duplicate", s, p)?; let rename = s.prefs.rename_duplicates; + let explicit = num(p, "dx").is_some() || num(p, "dy").is_some(); + let ds = s.ds()?; + let roots_now = z_sorted(&ds.doc, &roots(&ds.doc, &ids)); + let repeat = ds.last_duplicate.as_ref().filter(|d| !explicit && d.is_selection(&roots_now)).and_then(|d| d.offset(&ds.doc)); let doc = s.doc_mut()?; - let ids = z_sorted(doc, &roots(doc, &ids)); + let ids = roots_now; let mut out = Vec::new(); - for i in ids { + for i in ids.iter().copied() { let n = doc.get(i)?; let parent = n.parent.ok_or_else(|| bad("edit.duplicate", "pages are duplicated with page.duplicate"))?; let top_frame = doc.node(parent).is_some_and(Node::is_page) && n.kind.is_frame_like(); @@ -432,14 +467,21 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { _ if top_frame => (w + 100.0, 0.0), _ => (0.0, 0.0), }; - let nn = doc.get_mut(nid)?; - nn.transform = Affine::translate((dx, dy)) * nn.transform; + match repeat { + Some((rx, ry)) => super::arrange::move_world(doc, nid, rx, ry)?, + None => { + let nn = doc.get_mut(nid)?; + nn.transform = Affine::translate((dx, dy)) * nn.transform; + } + } if rename { + let nn = doc.get_mut(nid)?; nn.name = bump_name(&nn.name); } out.push(nid); } s.set_selection(out.clone()); + s.ds_mut()?.last_duplicate = Some(Duplicated { sources: ids, copies: out.clone() }); Ok(json!({"ids": out})) } diff --git a/crates/engine/src/duplicate_spacing_tests.rs b/crates/engine/src/duplicate_spacing_tests.rs new file mode 100644 index 000000000..b65fbb641 --- /dev/null +++ b/crates/engine/src/duplicate_spacing_tests.rs @@ -0,0 +1,159 @@ +//! Duplicating at a spacing: a copy Alt-dragged off a layer snaps to that layer too (its edges, +//! centres and equal spacing), and Duplicate repeats the last duplicate's offset while its copies +//! are selected, so Alt-drag (or Duplicate and move) then Cmd+D steps out evenly spaced copies. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use crate::tools::{PointerEvent, PointerKind}; +use figcraft_doc::NodeId; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn rect(s: &mut Session, x: f64, y: f64) -> NodeId { + id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": 100, "height": 100}))["id"]).unwrap() +} + +/// Alt-drag from `from` to `to`: returns the copy (the selection afterwards). +fn alt_drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> NodeId { + let ev = |kind, (x, y): (f64, f64)| PointerEvent { alt: true, ..PointerEvent::at(kind, x, y, 1.0) }; + s.pointer(&ev(PointerKind::Down, from)).unwrap(); + for k in 1..=4 { + let t = f64::from(k) / 4.0; + s.pointer(&ev(PointerKind::Move, (from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t))).unwrap(); + } + s.pointer(&ev(PointerKind::Up, to)).unwrap(); + let sel = s.selection(); + assert_eq!(sel.len(), 1, "one copy selected"); + sel[0] +} + +/// Duplicate the selection: the copy. +fn cmd_d(s: &mut Session) -> NodeId { + let out = ex(s, "edit.duplicate", json!({})); + id_of(&out["ids"][0]).unwrap() +} + +fn at(s: &Session, id: NodeId) -> (f64, f64) { + let b = s.doc().unwrap().world_bounds(id).unwrap(); + (b.x0, b.y0) +} + +#[test] +fn an_alt_dragged_copy_snaps_to_the_layer_it_came_from() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0); + // 2 px below the original's top edge: within the snap distance. + let copy = alt_drag(&mut s, (50.0, 50.0), (250.0, 52.0)); + assert_ne!(copy, a); + assert_eq!(at(&s, copy), (200.0, 0.0), "the copy's top lines up with the original's"); + assert_eq!(at(&s, a), (0.0, 0.0), "the original stays"); +} + +#[test] +fn an_alt_dragged_copy_spaces_equally_with_the_layer_it_came_from() { + let mut s = Session::new(); + rect(&mut s, 0.0, 0.0); + rect(&mut s, 200.0, 0.0); + // B's copy dropped 2 px short of the 100 gap that A and B have. + let copy = alt_drag(&mut s, (250.0, 50.0), (448.0, 50.0)); + assert_eq!(at(&s, copy), (400.0, 0.0)); +} + +#[test] +fn duplicate_repeats_the_step_set_by_moving_the_copy() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0); + ex(&mut s, "select.set", json!({"ids": [a]})); + let c1 = cmd_d(&mut s); + assert_eq!(at(&s, c1), (0.0, 0.0), "the first copy lands in place"); + ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150, "y": 20}})); + let c2 = cmd_d(&mut s); + assert_eq!(at(&s, c2), (300.0, 40.0), "the next one as far again"); + let c3 = cmd_d(&mut s); + assert_eq!(at(&s, c3), (450.0, 60.0), "and again"); +} + +#[test] +fn duplicate_after_an_alt_drag_repeats_its_step() { + let mut s = Session::new(); + rect(&mut s, 0.0, 0.0); + let copy = alt_drag(&mut s, (50.0, 50.0), (180.0, 50.0)); + assert_eq!(at(&s, copy), (130.0, 0.0)); + let next = cmd_d(&mut s); + assert_eq!(at(&s, next), (260.0, 0.0)); +} + +#[test] +fn duplicate_only_repeats_for_the_last_copies() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0); + ex(&mut s, "select.set", json!({"ids": [a]})); + let c1 = cmd_d(&mut s); + ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150}})); + // Another selection forgets it: the original duplicates in place. + ex(&mut s, "select.set", json!({"ids": [a]})); + ex(&mut s, "select.set", json!({"ids": [c1]})); + let c2 = cmd_d(&mut s); + assert_eq!(at(&s, c2), (150.0, 0.0), "the step was forgotten when the selection changed"); + // An offset given explicitly wins over a remembered one. + ex(&mut s, "node.set", json!({"ids": [c2], "props": {"x": 300}})); + let out = ex(&mut s, "edit.duplicate", json!({"dx": 10})); + let c3 = id_of(&out["ids"][0]).unwrap(); + assert_eq!(at(&s, c3), (310.0, 0.0)); +} + +#[test] +fn undo_doesnt_let_a_new_layer_take_a_remembered_step() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0); + ex(&mut s, "select.set", json!({"ids": [a]})); + let c1 = cmd_d(&mut s); + ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150}})); + ex(&mut s, "edit.undo", json!({})); + ex(&mut s, "edit.undo", json!({})); + assert!(!s.doc().unwrap().contains(c1)); + // Undo gives ids back: the new rectangle may well get the undone copy's. + let r = rect(&mut s, 500.0, 500.0); + ex(&mut s, "select.set", json!({"ids": [r]})); + let c = cmd_d(&mut s); + assert_eq!(at(&s, c), (500.0, 500.0), "a plain duplicate, in place"); +} + +#[test] +fn the_step_is_measured_in_page_space_inside_a_rotated_frame() { + let mut s = Session::new(); + let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 600, "height": 600}))["id"]).unwrap(); + ex(&mut s, "node.set", json!({"ids": [f], "props": {"rotation": 30}})); + let a = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 40, "height": 40, "parent": f}))["id"]).unwrap(); + ex(&mut s, "select.set", json!({"ids": [a]})); + let c1 = cmd_d(&mut s); + ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150}})); + let (p0, p1) = (at(&s, a), at(&s, c1)); + let c2 = cmd_d(&mut s); + let p2 = at(&s, c2); + let close = |a: f64, b: f64| (a - b).abs() < 1e-6; + assert!(close(p2.0 - p1.0, p1.0 - p0.0) && close(p2.1 - p1.1, p1.1 - p0.1), "{p0:?} {p1:?} {p2:?}"); +} + +#[test] +fn an_alt_drag_into_auto_layout_sets_no_step() { + let mut s = Session::new(); + let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100}))["id"]).unwrap(); + for i in 0..2 { + ex(&mut s, "shape.rectangle", json!({"x": i * 60, "y": 0, "width": 50, "height": 30, "parent": f})); + } + ex(&mut s, "layout.add", json!({"ids": [f]})); + ex(&mut s, "select.none", json!({})); + let kids = s.doc().unwrap().node(f).unwrap().children.clone(); + let b = s.doc().unwrap().world_bounds(kids[0]).unwrap(); + let from = (b.center().x, b.center().y); + alt_drag(&mut s, from, (from.0 + 70.0, from.1)); + assert!(s.ds().unwrap().last_duplicate.is_none(), "the layout placed the copy: no step to repeat"); + let n = s.doc().unwrap().node(f).unwrap().children.len(); + cmd_d(&mut s); + assert_eq!(s.doc().unwrap().node(f).unwrap().children.len(), n + 1, "Duplicate still adds one to the flow"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a906..f0e5968d8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -328,6 +328,9 @@ pub struct DocState { pub guide: Option<(NodeId, usize)>, /// The selection's rotation origin, when moved or shown (see [`rotation_origin`]; not saved). pub(crate) rotation_origin: Option, + /// The last duplicate while its copies are the selection: Duplicate repeats its offset (see + /// [`cmd::edit::Duplicated`]; not saved). + pub(crate) last_duplicate: Option, } /// A version shown on the canvas in place of the document: read-only until it's restored or the @@ -383,6 +386,7 @@ impl DocState { focus: None, guide: None, rotation_origin: None, + last_duplicate: None, }; st.zoom_to_fit(); st @@ -538,6 +542,10 @@ impl DocState { if self.rotation_origin.as_ref().is_some_and(|o| o.ids != self.selection) { self.rotation_origin = None; } + // So does the last duplicate (undo reuses ids: a new layer could take a copy's). + if self.last_duplicate.as_ref().is_some_and(|d| !d.is_selection(&self.selection)) { + self.last_duplicate = None; + } } } @@ -1441,6 +1449,8 @@ mod double_click_tests; #[cfg(test)] mod draw_tools_tests; #[cfg(test)] +mod duplicate_spacing_tests; +#[cfg(test)] mod effects_tests; #[cfg(test)] mod export_behaviour_tests; diff --git a/crates/engine/src/measure_tests.rs b/crates/engine/src/measure_tests.rs index 2329e7be7..539386bd7 100644 --- a/crates/engine/src/measure_tests.rs +++ b/crates/engine/src/measure_tests.rs @@ -196,16 +196,17 @@ fn alt_drag_shows_live_distances_to_the_neighbours() { e }; s.pointer(&ev(PointerKind::Down, 30.0)).unwrap(); - // Alt duplicates: the copy moves to 110..160, between the original and the other layer. + // Alt duplicates: the copy, dragged to 110..160, snaps to equal spacing between the original + // and the other layer (105..155). s.pointer(&ev(PointerKind::Move, 130.0)).unwrap(); let g = s.tool.overlay.guides.clone(); assert_eq!( labelled(&g), vec![ - ("30".into(), [135, 0, 135, 30]), - ("30".into(), [135, 70, 135, 100]), - ("40".into(), [160, 50, 200, 50]), - ("50".into(), [60, 50, 110, 50]) + ("30".into(), [130, 0, 130, 30]), + ("30".into(), [130, 70, 130, 100]), + ("45".into(), [60, 50, 105, 50]), + ("45".into(), [155, 50, 200, 50]) ] ); s.pointer(&ev(PointerKind::Up, 130.0)).unwrap(); diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 82474df00..b68f1fe95 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -74,15 +74,6 @@ impl Targets { // A layer's box in snap space. let bbox = |id: NodeId| doc.node(id).map(|n| transform_rect_bbox(to * doc.world_transform(id), n.local_rect())).filter(|b| b.is_finite()); let mut t = Targets { space, moving: moving.iter().filter_map(|m| bbox(*m)).reduce(|a, b| a.union(b)), ..Default::default() }; - let push = |r: Rect, t: &mut Targets| { - for x in [r.x0, r.center().x, r.x1] { - t.xs.push((x, r.y0, r.y1)); - } - for y in [r.y0, r.center().y, r.y1] { - t.ys.push((y, r.x0, r.x1)); - } - t.boxes.push(r); - }; let parent = parent_of(doc, page, moving); // The parent frame's own box (and its layout grids). if parent != page @@ -109,7 +100,7 @@ impl Targets { continue; } if let Some(b) = bbox(*c) { - push(b, &mut t); + t.add_box(b); } } } @@ -136,6 +127,17 @@ impl Targets { t } + /// A box to snap to (in snap space): its edges and centre lines, and equal spacing with it. + pub fn add_box(&mut self, r: Rect) { + for x in [r.x0, r.center().x, r.x1] { + self.xs.push((x, r.y0, r.y1)); + } + for y in [r.y0, r.center().y, r.y1] { + self.ys.push((y, r.x0, r.x1)); + } + self.boxes.push(r); + } + /// A point to snap onto (a vector point, a segment midpoint): lines through it on both axes, /// and a marker when a moving point lands on it. pub fn add_point(&mut self, p: Point) { diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index cfb9176d0..ff836b282 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1164,7 +1164,13 @@ impl Session { if ids.is_empty() { return Ok(()); } - let targets = Targets::gather(&doc, page, &ids); + let mut targets = Targets::gather(&doc, page, &ids); + // A copy dragged out with Alt snaps to the layers it came from too (they stay put). + if ev.alt + && let Some(b) = targets.moving + { + targets.add_box(b); + } self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" })?; self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false }; self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default }; @@ -1675,6 +1681,7 @@ impl Session { // dropped; layers in an auto layout frame already sit in their slot. let page = self.page()?; let sel = self.selection(); + let sources = if duplicate { ids.clone() } else { Vec::new() }; let moving = if duplicate { sel } else { ids }; let doc = self.doc()?.clone(); let in_al = @@ -1703,6 +1710,10 @@ impl Session { self.cancel_interaction()?; } else { self.commit_interaction()?; + // Duplicate repeats a free Alt-drag's step (an auto layout places its own). + if duplicate && !in_al && moving.len() == sources.len() { + self.ds_mut()?.last_duplicate = Some(crate::cmd::edit::Duplicated { sources, copies: moving }); + } } } else { self.cancel_interaction()?; diff --git a/docs/mcp.md b/docs/mcp.md index 2b66657c2..4d193ec85 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1406,6 +1406,12 @@ turned off 0°. With `pointer`, `alt: true` hovers measure (add `cmd` for nested layers, `shift` for padding) and `alt` drags show live distances to the neighbours; `inspect_ui` reports them under `tool.overlay` (`guides`, and `measure_box` for the box measured to). +- **Duplicating at a spacing**: a copy Alt-dragged off a layer with `pointer` snaps to that layer + too (its edges, centres and equal spacing). `edit.duplicate {ids?, dx?, dy?}` without `dx`/`dy` + repeats the last duplicate's page offset while its copies are what it duplicates (and still the + selection), measured when it runs: Alt-drag or duplicate and move a copy, then each + `edit.duplicate` steps out the next one as far again. Copies placed by an auto layout set no + step; explicit `dx`/`dy` always win. - **Scale tool** (`tool.scale`, K): dragging a selection handle with `pointer` scales everything by one factor (sizes and positions whatever the constraints, strokes, effects, corner radii, auto layout spacing, min/max sizes, layout grids, guides, tiled images, text), one undo step per drag, diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6de538e7a..ee6cf3147 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -55,7 +55,7 @@ interaction feel not yet compared. | Flip, rotate 90° | done | | | Rotation origin (Alt+R) | done | a handle at the selection's centre to drag (snaps to the box's corners, edge middles and centre), double-click puts it back, Escape or Alt+R hides it; the corner handles, the rotation field, Rotate 90°/180° and `transform.rotate` turn about it; it follows the layers and lasts as long as the selection, not saved (the documentation doesn't say; pinned) | | Enter with a shape or frame tool chosen | done | places what a click makes (100×100, lines 100 long, sections 400×300) in the middle of the view, selected (`tool.place`); from the toolbar, Enter on the chosen tool. No crosshair to move with the arrows first | -| Alt-drag duplicate | done | | +| Alt-drag duplicate | done | the copy snaps to the layer it came from too (edges, centres, equal spacing); Duplicate (Cmd+D) then repeats the copy's offset, also after a Duplicate whose copy was moved, as long as the copies stay selected (the documentation doesn't say; pinned from community answers) | | Drag threshold; double-click into groups and text | done | a press moves or resizes only once the pointer has travelled 3 px on screen, so a click or double-click made with an unsteady hand drags nothing; a double-click goes one level into a group and the next one edits the text under it, wherever on the text it lands, with all of it selected (a double-click in the text being edited selects the word) (`double_click_tests`); Enter on a selected text does the same (all of it selected, typing replaces it) and Escape leaves editing with the layer still selected (`text_enter_tests`) | | Scale tool (K): handles about an anchor, live factor, panel | done | | | Nudge 1 / Shift 10 (preference), Cmd+arrows resize | done | arrows reorder auto layout children | From 661144021e2896587b5f9c6a9c740ff8aa6db45f Mon Sep 17 00:00:00 2001 From: notdezzi Date: Wed, 14 Oct 2026 13:11:28 +0200 Subject: [PATCH 020/120] fix(format): exclusive unique staging files for atomic save/export Saving and exporting wrote a truncating std::fs::write to a deterministic staging name (.tmp-figcraft, .figcraft.tmp) before renaming, so an unrelated file or a planted symlink at that name was clobbered or written through, and separate writers of one destination shared a staging file they could corrupt. Both paths now go through figcraft_format::write_file_atomic: a uniquely named sibling staging file is created exclusively (create_new) and written through its own handle, renamed over the destination, and removed again on any failure, so only the staging file a write created is ever touched. Tests cover pre-existing staging files and symlinks, concurrent writers, and injected rename and permission failures. --- crates/engine/src/cmd/file.rs | 134 +++++++++++++++++++++++++++-- crates/format/src/lib.rs | 51 +++++++++-- crates/format/src/staging_tests.rs | 125 +++++++++++++++++++++++++++ 3 files changed, 298 insertions(+), 12 deletions(-) create mode 100644 crates/format/src/staging_tests.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 781ed6b07..922729f0e 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -437,15 +437,12 @@ pub(crate) fn read_capped(path: &str, max: u64) -> Result> { read(path) } +/// Write `bytes` to `path` through a uniquely named, exclusively created staging file and one +/// atomic rename (see [`figcraft_format::write_file_atomic`]): a failed or interrupted write never +/// truncates `path`, and no file or symlink at a staging name is ever written through. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> { - let tmp = format!("{path}.tmp-figcraft"); - std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{tmp}: {e}")))?; - std::fs::rename(&tmp, path).map_err(|e| { - // Leave nothing behind when the file can't take its place. - let _ = std::fs::remove_file(&tmp); - EngineError::Other(format!("{path}: {e}")) - }) + figcraft_format::write_file_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } #[cfg(target_arch = "wasm32")] @@ -1327,3 +1324,126 @@ mod rest_import_tests { let _ = std::fs::remove_dir_all(&dir); } } + +#[cfg(test)] +mod staging_write_tests { + use super::*; + + fn dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("figcraft-engine-staging-{name}-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d + } + + /// A symlink `from` → `to`; false when the platform won't make one (Windows needs privileges). + #[cfg(unix)] + fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::unix::fs::symlink(to, from).is_ok() + } + + #[cfg(windows)] + fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::windows::fs::symlink_file(to, from).is_ok() + } + + #[test] + fn an_existing_staging_file_is_never_written_through() { + let d = dir("existing"); + let path = d.join("shot.svg"); + // The old deterministic staging name, held by an unrelated file: the write must pick its + // own staging name instead of truncating this one. + let old = d.join("shot.svg.tmp-figcraft"); + std::fs::write(&old, b"not yours").unwrap(); + write(&path.to_string_lossy(), b"").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b""); + assert_eq!(std::fs::read(&old).unwrap(), b"not yours", "an unrelated file at a staging name is untouched"); + // Just the file and the untouched staging file: no new staging leftovers. + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 2); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_staging_symlink_is_never_followed() { + let d = dir("symlink"); + let path = d.join("shot.svg"); + let victim = d.join("victim.txt"); + std::fs::write(&victim, b"innocent").unwrap(); + let old = d.join("shot.svg.tmp-figcraft"); + if !symlink(&old, &victim) { + return; // Making a symlink needs privileges on some Windows setups. + } + write(&path.to_string_lossy(), b"").unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"innocent", "the symlink was never written through"); + assert_eq!(std::fs::read(&path).unwrap(), b""); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn concurrent_writers_of_one_file_never_interleave() { + let d = dir("concurrent"); + let path = d.join("shot.svg"); + for round in 0..5u8 { + let mut handles = Vec::new(); + for w in 0u8..4 { + let (path, bytes) = (path.clone(), vec![b'0' + w; 8192]); + handles.push(std::thread::spawn(move || write(&path.to_string_lossy(), &bytes))); + } + let mut wins = 0; + for h in handles { + // On Windows a rename over a file another writer just replaced can lose the race. + if h.join().unwrap().is_ok() { + wins += 1; + } + } + assert!(wins > 0, "round {round}: every writer lost"); + // Whatever landed is one writer's complete payload, never a mix. + let got = std::fs::read(&path).unwrap(); + assert_eq!(got.len(), 8192, "round {round}: a complete payload landed"); + assert!(got.windows(2).all(|w| w[0] == w[1]), "round {round}: one writer's payload, not a mix"); + } + // No staging file was left stuck (a failed writer removes its own). + let left = std::fs::read_dir(&d).unwrap().count(); + assert!(left <= if cfg!(windows) { 5 } else { 1 }, "{left} files left in the staging directory"); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_failed_write_leaves_the_destination_alone_and_cleans_up() { + let d = dir("failure"); + // The destination is a directory: the rename fails, our staging file is removed and the + // directory is untouched. + let taken = d.join("over-a-directory"); + std::fs::create_dir_all(&taken).unwrap(); + assert!(write(&taken.to_string_lossy(), b"payload").is_err()); + assert!(taken.is_dir()); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no staging file left behind"); + // A missing parent directory fails without creating anything anywhere. + assert!(write(&d.join("no").join("such.svg").to_string_lossy(), b"payload").is_err()); + assert!(!d.join("no").exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // A read-only directory refuses the staging file, and the old destination stays. + let ro = d.join("read-only"); + std::fs::create_dir_all(&ro).unwrap(); + let dest = ro.join("keep.svg"); + std::fs::write(&dest, b"keep").unwrap(); + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(&ro, perms).unwrap(); + // Running as root would defeat the read-only directory: skip instead of failing. + let probe = ro.join(".probe"); + if std::fs::write(&probe, b"").is_ok() { + let _ = std::fs::remove_file(&probe); + } else { + assert!(write(&dest.to_string_lossy(), b"payload").is_err()); + assert_eq!(std::fs::read(&dest).unwrap(), b"keep"); + assert_eq!(std::fs::read_dir(&ro).unwrap().count(), 1, "no staging file left behind"); + } + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o700); + std::fs::set_permissions(&ro, perms).unwrap(); + } + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index a989f3f69..56798d14a 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -654,14 +654,53 @@ fn read_branches(z: &mut zip::ZipArchive, images: &BTreeMap) -> Result<(), FormatError> { let bytes = save_bytes(doc, thumbnail_png)?; - let tmp = path.with_extension("figcraft.tmp"); - std::fs::write(&tmp, &bytes).map_err(|e| FormatError::Io(format!("{}: {e}", tmp.display())))?; - std::fs::rename(&tmp, path).map_err(|e| FormatError::Io(format!("{}: {e}", path.display())))?; - Ok(()) + write_file_atomic(path, &bytes).map_err(|e| FormatError::Io(format!("{}: {e}", path.display()))) +} + +/// Write `bytes` to `path` through a staging file and one atomic rename, so a reader of `path` +/// sees either the previous file or the complete new one, never a partial write. The staging file +/// is a uniquely named sibling (`.tmp-figcraft--`) created exclusively +/// (`create_new`) and written through its own handle: a file or symlink that already exists at a +/// staging name is never truncated or followed, and separate writers (also of separate processes) +/// never share a staging file. On failure only the staging file this call created is removed, and +/// `path` is replaced only on success. The rename replaces an existing destination on every +/// platform (`std::fs::rename` uses `MoveFileEx` with `MOVEFILE_REPLACE_EXISTING` on Windows). +/// (No `sync_all` yet: nothing around here promises durability across a power cut.) +#[cfg(not(target_arch = "wasm32"))] +pub fn write_file_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { + use std::sync::atomic::{AtomicU64, Ordering::Relaxed}; + + static STAGING: AtomicU64 = AtomicU64::new(0); + // Three tries at a fresh name: the counter is unique within this process, so only a leftover + // of a dead process that now shares our process id can collide, and the next counter won't. + for _ in 0..3 { + let mut name = path.as_os_str().to_os_string(); + name.push(format!(".tmp-figcraft-{}-{}", std::process::id(), STAGING.fetch_add(1, Relaxed))); + let tmp = std::path::PathBuf::from(name); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { + Ok(mut file) => { + if let Err(e) = file.write_all(bytes) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + return match std::fs::rename(&tmp, path) { + Ok(()) => Ok(()), + // Leave nothing behind when the file can't take its place. + Err(e) => { + let _ = std::fs::remove_file(&tmp); + Err(e) + } + }; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no free staging name next to the file")) } #[cfg(not(target_arch = "wasm32"))] @@ -677,6 +716,8 @@ mod image_tests; #[cfg(test)] mod large_tests; #[cfg(test)] +mod staging_tests; +#[cfg(test)] mod thumbnail_tests; #[cfg(test)] mod version_tests; diff --git a/crates/format/src/staging_tests.rs b/crates/format/src/staging_tests.rs new file mode 100644 index 000000000..1bc199e20 --- /dev/null +++ b/crates/format/src/staging_tests.rs @@ -0,0 +1,125 @@ +//! The staging write behind `save_file`: a pre-existing file or symlink at a staging name is never +//! truncated or followed, concurrent savers of one file never corrupt it, and a failed write +//! cleans up after itself and leaves the destination alone. + +use figcraft_doc::Document; + +use crate::{save_file, write_file_atomic}; + +fn dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("figcraft-staging-tests-{name}-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A symlink `from` → `to`; false when the platform won't make one (Windows needs privileges). +#[cfg(unix)] +fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::unix::fs::symlink(to, from).is_ok() +} + +#[cfg(windows)] +fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::windows::fs::symlink_file(to, from).is_ok() +} + +#[test] +fn an_existing_staging_file_is_never_written_through() { + let d = dir("existing"); + let path = d.join("a.figcraft"); + // The old deterministic staging name, held by an unrelated file: saving must pick its own + // staging name instead of truncating this one. + let old = d.join("a.figcraft.tmp"); + std::fs::write(&old, b"not yours").unwrap(); + save_file(&Document::new("Mine"), &path, None).unwrap(); + assert_eq!(std::fs::read(&old).unwrap(), b"not yours", "an unrelated file at a staging name is untouched"); + assert_eq!(crate::load_file(&path).unwrap().0.name, "Mine"); + // Just the file and the untouched staging file: no new staging leftovers. + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 2); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn a_staging_symlink_is_never_followed() { + let d = dir("symlink"); + let path = d.join("a.figcraft"); + let victim = d.join("victim.txt"); + std::fs::write(&victim, b"innocent").unwrap(); + let old = d.join("a.figcraft.tmp"); + if !symlink(&old, &victim) { + return; // Making a symlink needs privileges on some Windows setups. + } + save_file(&Document::new("Mine"), &path, None).unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"innocent", "the symlink was never written through"); + assert_eq!(crate::load_file(&path).unwrap().0.name, "Mine"); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn concurrent_savers_of_one_file_never_interleave() { + let d = dir("concurrent"); + let path = d.join("a.figcraft"); + let names = ["W0", "W1", "W2", "W3"]; + for round in 0..5 { + let mut handles = Vec::new(); + for name in names { + let (path, doc) = (path.clone(), Document::new(name)); + handles.push(std::thread::spawn(move || save_file(&doc, &path, None))); + } + for h in handles { + // On Windows a rename over a file another writer just replaced can lose the race. + if cfg!(windows) { + let _ = h.join().unwrap(); + } else { + h.join().unwrap().unwrap(); + } + } + // Whatever landed is one writer's complete file, never a mix: a torn archive wouldn't load. + let (doc, _) = crate::load_file(&path).unwrap(); + assert!(names.contains(&doc.name.as_str()), "round {round}: {:?}", doc.name); + } + // No staging file was left stuck. + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn a_failed_write_leaves_the_destination_alone_and_cleans_up() { + let d = dir("failure"); + // The destination is a directory: the rename fails, our staging file is removed, the + // directory is untouched. + let taken = d.join("folder.figcraft"); + std::fs::create_dir_all(&taken).unwrap(); + assert!(save_file(&Document::new("Mine"), &taken, None).is_err()); + assert!(write_file_atomic(&taken, b"payload").is_err()); + assert!(taken.is_dir()); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no staging file left behind"); + // A missing parent directory fails without creating anything anywhere. + assert!(write_file_atomic(&d.join("no").join("such.figcraft"), b"payload").is_err()); + assert!(!d.join("no").exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // A read-only directory refuses the staging file, and the old destination stays. + let ro = d.join("read-only"); + std::fs::create_dir_all(&ro).unwrap(); + let dest = ro.join("keep.figcraft"); + std::fs::write(&dest, b"keep").unwrap(); + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(&ro, perms).unwrap(); + // Running as root would defeat the read-only directory: skip instead of failing. + let probe = ro.join(".probe"); + if std::fs::write(&probe, b"").is_ok() { + let _ = std::fs::remove_file(&probe); + } else { + assert!(write_file_atomic(&dest, b"payload").is_err()); + assert_eq!(std::fs::read(&dest).unwrap(), b"keep"); + assert_eq!(std::fs::read_dir(&ro).unwrap().count(), 1, "no staging file left behind"); + } + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o700); + std::fs::set_permissions(&ro, perms).unwrap(); + } + let _ = std::fs::remove_dir_all(&d); +} From d1ac66a803f7b42d414cd44aed282e9a1847ba98 Mon Sep 17 00:00:00 2001 From: haranishi <223786623+haranishi@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:32:20 +0900 Subject: [PATCH 021/120] =?UTF-8?q?M9:=20=E6=97=A5=E6=9C=AC=E8=AA=9E?= =?UTF-8?q?=E3=81=AE=E3=83=86=E3=83=B3=E3=83=97=E3=83=AC=E3=83=BC=E3=83=88?= =?UTF-8?q?=E3=81=A8=E7=B7=A8=E9=9B=86=E6=93=8D=E4=BD=9C=E3=83=A9=E3=83=99?= =?UTF-8?q?=E3=83=AB=E3=82=92=E8=A3=9C=E5=AE=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ROADMAP.md | 15 +++--- crates/ui-egui/locales/ja.toml | 88 ++++++++++++++++++++++++++++++++++ docs/localization-parity.md | 32 +++++++------ 3 files changed, 113 insertions(+), 22 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29b..8489fac5b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Japanese draft catalog catches up with 88 missing entries; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -99,16 +99,16 @@ native speakers' reviews of the six language drafts need people. | Language | Code | Strings | Status | |---|---|---|---| | English | en | 100% | full | -| Simplified Chinese | zh-Hans | 100% | draft (needs native review) | +| Simplified Chinese | zh-Hans | 96% (2,077 / 2,165) | draft (needs native review) | | Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) | | Hindi | hi | 0% | none | | Arabic | ar | 0% | none | -| French | fr | 100% | draft (needs native review) | -| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) | +| French | fr | 96% (2,077 / 2,165) | draft (needs native review) | +| Portuguese (Brazil) | pt-BR | 96% (2,077 / 2,165) | draft (needs native review) | | Indonesian | id | 0% | none | -| Japanese | ja | 100% | draft (needs native review) | -| German | de | 100% | draft (needs native review) | -| Korean | ko | 100% | draft (needs native review) | +| Japanese | ja | 100% (2,165 / 2,165) | draft (needs native review) | +| German | de | 96% (2,077 / 2,165) | draft (needs native review) | +| Korean | ko | 96% (2,077 / 2,165) | draft (needs native review) | | Vietnamese | vi | 0% | none | No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese @@ -312,6 +312,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Japanese draft catalog catches up with 75 UI strings, 11 command labels and 2 menu wordings for templates, Getting started, recent files and everyday editor controls: 2,165 entries. Native review is still pending; the other drafts retain 2,077 entries (96%). Localization grade and estimates unchanged; detail: [localization-parity](docs/localization-parity.md) | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index f7b9cf9fd..4b4ed4897 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -19,7 +19,10 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:PR {prs} 件、コミット {commits} 件、+{added} / −{deleted} 行(Δ {delta})、バイナリアセット +{bin_added} / −{bin_deleted}、{first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "このファイルのコレクションとそのモード・変数:モードごとに値を上書きし、独自の変数を追加できる" +"A responsive product page: hero, features, figures, a quote and a footer" = "レスポンシブな製品ページ:ヒーロー、機能、数値、引用、フッター" "A spring plays until it settles: its mass, stiffness and damping set how long" = "スプリングは静止するまで再生。長さは質量・剛性・減衰で決まる" +"A title, an agenda, a big figure, a chart and a closing slide" = "タイトル、目次、大きな数値、グラフ、締めのスライド" +"A to Z" = "昇順(A→Z)" "About" = "情報" "About FigCraft" = "FigCraft について" "About this server" = "このサーバーについて" @@ -90,7 +93,9 @@ status = "draft — needs native review" "All supported properties" = "サポートされているすべてのプロパティ" "All variables" = "すべての変数" "Alpha" = "アルファ" +"Alphabetical" = "名前順" "Alt+drag" = "Alt+ドラッグ" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "グリッドに沿った分析画面:数値、ベクターで描いたグラフ、表" "An invite link that opens FigCraft and joins" = "FigCraft を開いて参加する招待リンク" "Anchor" = "アンカー" "Android" = "Android" @@ -109,6 +114,8 @@ status = "draft — needs native review" "Apply variable" = "変数を適用" "Apply variable to text" = "テキストに変数を適用" "Apply variable to visibility" = "表示に変数を適用" +"Apps" = "アプリ" +"Apr" = "4月" "Arc" = "円弧" "Arc ratio" = "円弧の比率" "Arc start" = "円弧の開始" @@ -120,6 +127,7 @@ status = "draft — needs native review" "As typed" = "入力どおり" "Asking the server…" = "サーバーに問い合わせ中…" "Assets" = "アセット" +"Aug" = "8月" "Auto" = "自動" "Auto (space around)" = "自動(周囲に間隔)" "Auto (space between)" = "自動(間に間隔)" @@ -164,6 +172,7 @@ status = "draft — needs native review" "Bottom" = "下" "Bottom left radius" = "左下の半径" "Bottom right radius" = "右下の半径" +"Bound to {name}. Click to detach the variable." = "「{name}」を適用中。クリックで変数の割り当てを解除。" "Branch" = "ブランチ" "Branch archived" = "ブランチをアーカイブ済み" "Branch merged" = "ブランチをマージ済み" @@ -250,6 +259,7 @@ status = "draft — needs native review" "Color values in this file are in this color space." = "このファイルのカラー値はこのカラースペースで表される。" "Color variables" = "カラー変数" "Colour" = "カラー" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "カラー・テキストスタイル、ライト・ダークの変数、ボタン、入力欄、カード" "Column" = "列" "Column gap" = "列の間隔" "Column sizes" = "列のサイズ" @@ -316,6 +326,7 @@ status = "draft — needs native review" "Create property…" = "プロパティを作成…" "Create style" = "スタイルを作成" "Create style from the selected text" = "選択したテキストからスタイルを作成" +"Created" = "作成日" "Crop" = "切り抜き" "Crop marks" = "トンボ" "Ctrl+arrows" = "Ctrl+矢印キー" @@ -329,6 +340,9 @@ status = "draft — needs native review" "Dark" = "ダーク" "Darken" = "比較(暗)" "Dash" = "破線" +"Dashboard" = "ダッシュボード" +"Date created" = "作成日" +"Dec" = "12月" "Decoration style" = "装飾のスタイル" "Decoration" = "装飾" "Deep select" = "深い階層を選択" @@ -355,6 +369,7 @@ status = "draft — needs native review" "Description" = "説明" "Description (optional)" = "説明(任意)" "Design" = "デザイン" +"Design system starter" = "デザインシステムの雛形" "Desktop" = "デスクトップ" "Destination" = "遷移先" "Detach alias" = "エイリアスを解除" @@ -390,6 +405,7 @@ status = "draft — needs native review" "Drafts" = "下書き" "Drag the stroke to add a width point" = "線をドラッグして幅のポイントを追加" "Draw" = "描画" +"Draw a frame" = "フレームを描く" "Drawing tools" = "描画ツール" "Drop shadow" = "ドロップシャドウ" "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "MIT または Apache-2.0 のデュアルライセンス。ArtCraft チームとコミュニティが制作。" @@ -408,6 +424,7 @@ status = "draft — needs native review" "Edit style" = "スタイルを編集" "Edit variable" = "変数を編集" "Edit with the builder" = "ビルダーで編集" +"Edited {when}" = "最終編集:{when}" "Effect settings" = "エフェクトの設定" "Effect styles" = "エフェクトスタイル" "Effects" = "エフェクト" @@ -438,6 +455,7 @@ status = "draft — needs native review" "Expand" = "展開" "Export" = "書き出し" "Export all" = "すべて書き出し" +"Export frames and layers as PNG, JPG, SVG or PDF" = "フレームやレイヤーを PNG、JPG、SVG、PDF で書き出し" "Export page as PNG" = "ページを PNG で書き出し" "Export settings" = "書き出し設定" "Export variables" = "変数を書き出し" @@ -457,9 +475,11 @@ status = "draft — needs native review" "F" = "F" "Fade" = "フェード" "False" = "false" +"Feb" = "2月" "Fetching from the server…" = "サーバーから取得中…" "File" = "ファイル" "File name" = "ファイル名" +"File not found" = "ファイルが見つからない" "File published" = "ファイルを公開済み" "Files aren't end-to-end encrypted: the server's operator can read them." = "ファイルはエンドツーエンドで暗号化されない:サーバーの運営者は内容を読める。" "Files nobody opens for {n} days are deleted." = { other = "{n} 日間誰も開かなかったファイルは削除される。" } @@ -506,6 +526,7 @@ status = "draft — needs native review" "Font size" = "フォントサイズ" "Font style" = "フォントスタイル" "Forget on this computer" = "このコンピューターで記録を消去" +"Four screens of a trail app with components and a prototype flow" = "コンポーネントとプロトタイプのフローを備えた登山アプリの4画面" "Fractions" = "分数" "Frame" = "フレーム" "Frame selection" = "選択範囲をフレームで囲む" @@ -519,6 +540,7 @@ status = "draft — needs native review" "Gap between columns" = "列の間隔" "Gap between items" = "アイテムの間隔" "Gap between rows" = "行の間隔" +"Getting started" = "使い始めるには" "Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "一般公開されているサーバーのアドレス、またはキー付きのサーバーのアドレスを入力(`figcraft-server key` で表示)。" "Glass" = "ガラス" "Go to main component" = "メインコンポーネントへ移動" @@ -527,6 +549,7 @@ status = "draft — needs native review" "Gradient" = "グラデーション" "Gray" = "グレー" "Green" = "緑" +"Greyscale components and two example screens to sketch a flow with" = "フローを考えるためのグレースケールのコンポーネントと2つの画面例" "Grid" = "グリッド" "Grid ({size})" = "グリッド({size})" "Grid styles" = "グリッドスタイル" @@ -548,6 +571,8 @@ status = "draft — needs native review" "Hide contents" = "コンテンツを非表示" "Hide from publishing" = "公開対象から除外" "Hide section contents" = "セクションのコンテンツを非表示" +"Hide these tips (Getting started in the Help menu shows them again)" = "ヒントを非表示(ヘルプメニューの「使い始めるには」で再表示)" +"Hide when publishing" = "公開時に非表示" "Highlight layers on hover" = "ホバー時にレイヤーをハイライト" "Highlights" = "ハイライト" "Hold Alt" = "Alt を押したまま" @@ -569,6 +594,8 @@ status = "draft — needs native review" "Hue" = "色相" "Hug" = "ハグ" "Hug contents" = "内容に合わせる" +"Icon components on a 24 px grid with keylines and a size preview" = "24 px グリッドのアイコンコンポーネント、キーライン、サイズのプレビュー" +"Icon set starter" = "アイコンセットの雛形" "If" = "条件" "If {condition}" = "条件:{condition}" "Ignore auto layout (absolute position)" = "オートレイアウトを無視(絶対位置)" @@ -613,6 +640,7 @@ status = "draft — needs native review" "Invite link" = "招待リンク" "Invite link copied" = "招待リンクをコピー済み" "It asks permission to:" = "次の権限を求めている:" +"Jan" = "1月" "Join" = "参加" "Join code copied" = "参加コードをコピー済み" "Join code or invite link" = "参加コードまたは招待リンク" @@ -623,6 +651,8 @@ status = "draft — needs native review" "Joined {file}" = "{file} に参加済み" "Joining…" = "参加中…" "Join|stroke" = "角の形状" +"Jul" = "7月" +"Jun" = "6月" "Just now" = "たった今" "Justify" = "両端揃え" "Keep appearance" = "見た目を保持" @@ -632,11 +662,14 @@ status = "draft — needs native review" "Key/Gamepad" = "キー/ゲームパッド" "Key/Gamepad {key}" = "キー/ゲームパッド {key}" "Keyboard shortcuts" = "キーボードショートカット" +"Kits" = "キット" +"Landing page" = "ランディングページ" "Language" = "言語" "Lasso" = "なげなわ" "Last" = "最後" "Last change: {note}" = "最後の変更:{note}" "Last commit" = "最後のコミット" +"Last modified" = "更新日" "Last on top" = "最後を上に" "Layer blur" = "レイヤーのぼかし" "Layer name" = "レイヤー名" @@ -675,6 +708,7 @@ status = "draft — needs native review" "Link" = "リンク" "Link copied" = "リンクをコピー済み" "Link docs, code or tickets for developers." = "開発者向けにドキュメント、コード、チケットをリンク。" +"Link frames on the Prototype tab, then present the flow" = "プロトタイプタブでフレームをつなぎ、フローを再生" "Link this component to its code component: instances then show your real snippet with their values." = "このコンポーネントをコードコンポーネントにリンクすると、インスタンスに実際のスニペットがその値とともに表示される。" "List spacing" = "リストの間隔" "List style" = "リストのスタイル" @@ -692,7 +726,9 @@ status = "draft — needs native review" "Main" = "メイン" "Main component deleted" = "メインコンポーネントは削除済み" "Main menu" = "メインメニュー" +"Make a component" = "コンポーネントを作成" "Make slot" = "スロットにする" +"Mar" = "3月" "Margin" = "マージン" "Mark all as read" = "すべて既読にする" "Mark as resolved" = "解決済みにする" @@ -705,6 +741,7 @@ status = "draft — needs native review" "Max W" = "最大 W" "Max lines" = "最大行数" "Maximize" = "最大化" +"May" = "5月" "Measure to the selection" = "選択範囲までの距離を測定" "Merge into main" = "メインにマージ" "Merge regions" = "領域を結合" @@ -726,6 +763,7 @@ status = "draft — needs native review" "Mixed" = "混在" "Mixed fonts" = "混在フォント" "Mixed interactions: click + to replace them" = "混在したインタラクション:+ をクリックして置き換え" +"Mobile app" = "モバイルアプリ" "Mode" = "モード" "Model" = "モデル" "Models" = "モデル" @@ -755,6 +793,7 @@ status = "draft — needs native review" "Navigate to" = "移動先" "Nested instance" = "ネストされたインスタンス" "New design file" = "新規デザインファイル" +"Newest first" = "新しい順" "Next or previous control in the focused region" = "フォーカス中の領域で次または前のコントロール" "Next ready for dev" = "次の開発準備完了" "Next size option" = "次のサイズオプション" @@ -797,6 +836,7 @@ status = "draft — needs native review" "No string variables yet. Create them in Local variables." = "文字列変数はまだない。ローカル変数で作成。" "No styles yet." = "スタイルはまだない。" "No styles yet. Click + to create one from the selection." = "スタイルはまだない。+ をクリックして選択範囲から作成。" +"No templates match your search" = "検索に一致するテンプレートなし" "No text styles yet. Click + to create one from the selected text." = "テキストスタイルはまだない。+ をクリックして選択したテキストから作成。" "No variables fit here." = "ここに使える変数はない。" "No variables yet. Create them in Local variables." = "変数はまだない。ローカル変数で作成。" @@ -816,6 +856,7 @@ status = "draft — needs native review" "Nothing here yet. Set a frame's status to list it." = "まだ何もない。フレームのステータスを設定すると一覧に表示される。" "Nothing online yet. Use Share on a file to put it online and it is listed here." = "オンラインのファイルはまだない。ファイルの「共有」からオンラインにすると、ここに表示される。" "Nothing to export" = "書き出すものがない" +"Nov" = "11月" "Now" = "現在" "Nudge (Shift: big nudge)" = "微調整(Shift:大きく微調整)" "Nudge amount" = "微調整の量" @@ -824,13 +865,16 @@ status = "draft — needs native review" "Number ↓" = "番号 ↓" "Numbered list" = "番号付きリスト" "Object" = "オブジェクト" +"Oct" = "10月" "Off" = "オフ" "Offered first when filling the slot (any content is allowed)" = "スロットを埋めるときに最初に提示(どのコンテンツも使用可)" "Offered first when swapping the instance (any component is allowed)" = "インスタンスを入れ替えるときに最初に提示(どのコンポーネントも使用可)" "Offset" = "オフセット" +"Oldest first" = "古い順" "On click" = "クリック時" "On drag" = "ドラッグ時" "On this page" = "このページ" +"One announcement in five common sizes: square, portrait, story, link and banner" = "1つのお知らせを5種類の一般的なサイズで:正方形、縦長、ストーリー、リンク、バナー" "Online" = "オンライン" "Online files" = "オンラインファイル" "Online sharing is off: files are shared on the local network only." = "オンライン共有はオフ:ファイルはローカルネットワークでのみ共有される。" @@ -857,6 +901,7 @@ status = "draft — needs native review" "Open|comments" = "未解決" "Options" = "オプション" "Orange" = "オレンジ" +"Order" = "並び順" "Other apps may not open every .fig file exported here." = "ここで書き出した .fig ファイルは、他のアプリで開けない場合がある。" "Others lose access and keep their copy; the file stays on the server for you" = "他の人はアクセスできなくなり、各自のコピーは残る。ファイルはあなた用にサーバーに残る" "Others type this under Share ▸ Join with a code" = "他の人は「共有 ▸ コードで参加」でこれを入力" @@ -903,6 +948,7 @@ status = "draft — needs native review" "Pick color" = "色を抽出" "Pick color (I)" = "色を抽出(I)" "Pick one font family first" = "先にフォントファミリーを 1 つ選択" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "フレームツールを選び、キャンバスをドラッグするか端末のサイズを選択" "Pin a property" = "プロパティを固定" "Pinch" = "すぼめる" "Pixel grid" = "ピクセルグリッド" @@ -911,6 +957,7 @@ status = "draft — needs native review" "Place a shape or frame (its tool chosen)" = "シェイプまたはフレームを配置(ツール選択中)" "Place automatically" = "自動で配置" "Place image" = "画像を配置" +"Playground" = "プレイグラウンド" "Plugins" = "プラグイン" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "プラグインは FigCraft のサンドボックス内で、ファイル、ネットワーク、時計なしで実行される。信頼できるプラグインのみをインストールすること。" "Plus darker" = "プラス(暗)" @@ -928,6 +975,7 @@ status = "draft — needs native review" "Present or preview" = "プレゼンテーションまたはプレビュー" "Present this flow" = "このフローをプレゼンテーション" "Presentation" = "プレゼンテーション" +"Presentation slides" = "プレゼンテーションのスライド" "Presenting: comments" = "プレゼンテーション中:コメント" "Presenting: cycle the size options" = "プレゼンテーション中:サイズオプションを切り替え" "Presenting: full screen" = "プレゼンテーション中:全画面表示" @@ -983,6 +1031,7 @@ status = "draft — needs native review" "Remove auto layout" = "オートレイアウトを解除" "Remove dev resource" = "開発リソースを削除" "Remove export setting" = "書き出し設定を削除" +"Remove from recents" = "最近使ったファイルから削除" "Remove from server" = "サーバーから削除" "Remove from server…" = "サーバーから削除…" "Remove guide" = "ガイドを削除" @@ -1092,15 +1141,18 @@ status = "draft — needs native review" "Search layers" = "レイヤーを検索" "Search plugins" = "プラグインを検索" "Search shortcuts" = "ショートカットを検索" +"Search templates" = "テンプレートを検索" "Section" = "セクション" "Section color" = "セクションの色" "Sections" = "セクション" +"See all templates" = "すべてのテンプレートを見る" "Select a frame and add a starting point to present it as a flow." = "フレームを選択して開始点を追加すると、フローとしてプレゼンテーションできる。" "Select a frame to preview it" = "フレームを選択するとプレビュー" "Select a layer to see its properties and code" = "レイヤーを選択するとプロパティとコードを表示" "Select all" = "すべて選択" "Select all with same" = "同じ項目をすべて選択" "Select layer" = "レイヤーを選択" +"Select layers and they space themselves as their content changes" = "レイヤーを選択すると、内容の変化に合わせて間隔を自動調整" "Select layers to apply the style to" = "スタイルを適用するレイヤーを選択" "Select overlapping shapes" = "重なったシェイプを選択" "Select points to edit their position, radius and mirroring" = "ポイントを選択すると位置、半径、対称を編集できる" @@ -1111,6 +1163,7 @@ status = "draft — needs native review" "Send backward" = "背面へ" "Send report" = "報告を送信" "Send to back" = "最背面へ" +"Sep" = "9月" "Server address" = "サーバーのアドレス" "Server address (ws://host:47900/)" = "サーバーのアドレス(ws://host:47900/)" "Server for online sharing" = "オンライン共有用のサーバー" @@ -1125,6 +1178,7 @@ status = "draft — needs native review" "Shape tools" = "シェイプツール" "Share" = "共有" "Share this file" = "このファイルを共有" +"Share with a code" = "コードで共有" "Share: live session, export" = "共有:ライブセッション、書き出し" "Shared" = "共有中" "Shared: copy its code from the file's menu" = "共有済み:コードはファイルのメニューからコピー" @@ -1134,6 +1188,7 @@ status = "draft — needs native review" "Sharing online · {people}" = "オンラインで共有中 · {people}" "Sharing stopped: the file stays on the server, open only to you" = "共有を停止:ファイルはサーバーに残り、開けるのはあなただけ" "Show" = "表示" +"Show all templates" = "すべてのテンプレートを表示" "Show as grid" = "グリッドで表示" "Show as list" = "リストで表示" "Show autosaves" = "自動保存を表示" @@ -1142,6 +1197,9 @@ status = "draft — needs native review" "Show device frame" = "デバイスフレームを表示" "Show dimensions on objects" = "オブジェクトに寸法を表示" "Show hotspot hints" = "ホットスポットのヒントを表示" +"Show in Explorer" = "エクスプローラーで表示" +"Show in Finder" = "Finder で表示" +"Show in folder" = "フォルダーで表示" "Show only this on the canvas" = "キャンバスにこれだけを表示" "Show open" = "未解決を表示" "Show section contents" = "セクションのコンテンツを表示" @@ -1166,13 +1224,17 @@ status = "draft — needs native review" "Small caps" = "スモールキャップ" "Smart animate" = "スマートアニメート" "Snap to pixel grid" = "ピクセルグリッドにスナップ" +"Social and slides" = "SNS・スライド" "Social media" = "ソーシャルメディア" +"Social post sizes" = "SNS投稿サイズ" "Soft light" = "ソフトライト" "Solid" = "実線" "Some variants have the same property values: give each one a unique combination" = "同じプロパティ値を持つバリアントがある:それぞれ固有の組み合わせにする" "Sort" = "並べ替え" +"Sort by" = "並べ替えの基準" "Sort by date" = "日付で並べ替え" "Sort by page" = "ページで並べ替え" +"Sort by {column}" = "{column}で並べ替え" "Space around" = "周囲に均等配置" "Space between" = "間に均等配置" "Space drag" = "Space ドラッグ" @@ -1191,9 +1253,11 @@ status = "draft — needs native review" "Start" = "開始" "Start ascending from / stop descending at" = "昇順の開始値 / 降順の終了値" "Start from a blank canvas" = "空白のキャンバスから始める" +"Start from a template" = "テンプレートから始める" "Start on path" = "パス上の開始位置" "Start point" = "始点" "Start sharing" = "共有を開始" +"Start sharing with the Share button, then pass on the code it gives" = "共有ボタンで共有を開始し、表示されたコードを相手に伝える" "Starting to share…" = "共有を開始中…" "Status" = "ステータス" "Sticky" = "固定" @@ -1242,6 +1306,7 @@ status = "draft — needs native review" "Tell the server's operator what is wrong with it" = "何が問題かをサーバーの運営者に伝える" "Temperature" = "色温度" "Template" = "テンプレート" +"Templates" = "テンプレート" "Terms" = "利用規約" "Text" = "テキスト" "Text align" = "テキストの配置" @@ -1299,6 +1364,8 @@ status = "draft — needs native review" "Truncate with an ellipsis" = "省略記号で切り詰める" "Try again" = "再試行" "Try another search or filter." = "別の検索語やフィルターを試す。" +"Try another word, or look at every kind." = "別の単語で検索するか、すべての種類を表示。" +"Turn a layer into a component, reuse it, and change every copy at once" = "レイヤーをコンポーネントに変換して再利用し、すべてのコピーを一括で変更" "Two digits" = "2 桁" "Type" = "種類" "Type settings" = "文字の設定" @@ -1334,6 +1401,7 @@ status = "draft — needs native review" "Use the layer's fill" = "レイヤーの塗りを使用" "Used in {files} · {instances}" = "{files}で使用 · {instances}" "Username" = "ユーザー名" +"Uses variables: each layer shows them in its own mode" = "変数を使用:各レイヤーに設定されたモードで表示" "Value" = "値" "Variable" = "変数" "Variable collections" = "変数コレクション" @@ -1371,12 +1439,14 @@ status = "draft — needs native review" "Web" = "Web" "WebSocket URL" = "WebSocket URL" "WebSocket URL copied" = "WebSocket URL をコピー済み" +"Websites" = "Webサイト" "Wedge" = "くさび" "Weight" = "太さ" "What changed since it was marked ready for dev" = "開発準備完了にしてからの変更内容" "What is wrong with this file? The server's operator gets your report." = "このファイルの何が問題ですか? 報告はサーバーの運営者に届く。" "What it's for" = "用途" "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." = "変更前の状態は不明:FigCraft が開発準備完了フレームのコピーを保存するようになる前に変更された。変更を解決する(または再度準備完了にする)と、今後はそこから比較する。" +"What you try here changes this preview and the code below, not the file" = "ここでの操作はプレビューと下のコードに反映。ファイルは変更されない" "While hovering" = "ホバー中" "While pressing" = "押している間" "White" = "白" @@ -1386,6 +1456,7 @@ status = "draft — needs native review" "Width" = "幅" "Width point" = "幅のポイント" "Width profile" = "幅プロファイル" +"Wireframe kit" = "ワイヤーフレームキット" "With the layers' export settings" = "レイヤーの書き出し設定で" "Wrap" = "折り返し" "Wrap in new section" = "新しいセクションで囲む" @@ -1400,6 +1471,7 @@ status = "draft — needs native review" "Your name in comments" = "コメントに表示する名前" "Your system asks for reduced motion: animations stay off." = "システムで動きを減らす設定がオンのため、アニメーションはオフのまま。" "Z" = "Z" +"Z to A" = "降順(Z→A)" "Zoom" = "ズーム" "Zoom around the pointer" = "ポインターを中心にズーム" "Zoom device to fill window" = "デバイスをウィンドウいっぱいにズーム" @@ -1426,6 +1498,7 @@ status = "draft — needs native review" "from {server}" = "{server} から" "hidden" = "非表示" "iOS" = "iOS" +"just now" = "たった今" "locked" = "ロック中" "no bleed" = "裁ち落としなし" "nobody else here yet" = "まだ他に誰もいない" @@ -1437,9 +1510,11 @@ status = "draft — needs native review" "reads the document" = "ドキュメントを読み取る" "the plugin" = "このプラグイン" "this session only" = "このセッションのみ" +"yesterday" = "昨日" "{instances} in {files}" = "{files}内の{instances}" "{language}: draft translation, waiting for a native speaker's review." = "{language}:下書きの翻訳。ネイティブスピーカーによるレビュー待ち。" "{length} bleed" = "裁ち落とし {length}" +"{month} {day}, {year}" = "{year}年{month}{day}日" "{name}\nClick to apply to the selection" = "{name}\nクリックして選択範囲に適用" "{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}\nキャンバスにドラッグするか、クリックしてビューの中央に配置。\nインスタンスの上にドロップするときに Alt(Option)を押したままにすると入れ替え。" "{name} (from {library})" = "{name}({library} から)" @@ -1601,8 +1676,10 @@ status = "draft — needs native review" "component.editProperty" = "コンポーネントプロパティを編集" "component.exposeInstance" = "ネストされたインスタンスを公開" "component.goToMain" = "メインコンポーネントへ移動" +"component.hideWhenPublishing" = "公開時に非表示" "component.list" = "コンポーネントを一覧表示" "component.makeSlot" = "スロットにする" +"component.playground" = "コンポーネントを試す" "component.properties" = "コンポーネントプロパティ" "component.pushChanges" = "変更をメインコンポーネントにプッシュ" "component.removeSlot" = "スロットを削除" @@ -1660,6 +1737,7 @@ status = "draft — needs native review" "file.exportSvg" = "選択範囲を SVG で書き出し" "file.import" = "インポート" "file.new" = "新規デザインファイル" +"file.newFromTemplate" = "テンプレートから新規作成" "file.open" = "開く" "file.openRecent" = "最近使ったファイルを開く" "file.placeImage" = "画像を配置" @@ -1687,6 +1765,7 @@ status = "draft — needs native review" "guide.remove" = "ガイドを削除" "guide.select" = "ガイドを選択" "help.about" = "FigCraft について" +"help.gettingStarted" = "使い始めるには" "help.page" = "ヘルプページ" "help.shortcuts" = "キーボードショートカット" "image.adjust" = "画像を調整" @@ -1740,6 +1819,8 @@ status = "draft — needs native review" "node.reparent" = "レイヤーを中に移動" "node.set" = "プロパティを設定" "object.batchRename" = "一括で名前を変更" +"object.convertToFrame" = "フレームに変換" +"object.convertToGroup" = "グループに変換" "object.flipH" = "左右反転" "object.flipV" = "上下反転" "object.frameSelection" = "選択範囲をフレームで囲む" @@ -1855,6 +1936,7 @@ status = "draft — needs native review" "stroke.set" = "線を設定" "stroke.widthProfile" = "幅プロファイル" "style.apply" = "スタイルを適用" +"style.bind" = "スタイルに変数を割り当て" "style.create" = "スタイルを作成" "style.delete" = "スタイルを削除" "style.detach" = "スタイルを切り離す" @@ -1863,6 +1945,7 @@ status = "draft — needs native review" "style.move" = "スタイルを移動" "style.renameFolder" = "スタイルフォルダー名を変更" "style.update" = "スタイルを編集" +"template.list" = "テンプレートを一覧表示" "text.alignCenter" = "中央揃え" "text.alignJustify" = "両端揃え" "text.alignLeft" = "左揃え" @@ -2028,8 +2111,11 @@ status = "draft — needs native review" "view.nextPage" = "次のページ" "view.nextRegion" = "次の領域にフォーカス" "view.outlines" = "アウトライン" +"view.outlinesHiddenLayers" = "アウトラインに非表示のレイヤーを含める" +"view.outlinesObjectBounds" = "アウトラインにオブジェクトの境界を含める" "view.outlinesSelection" = "選択範囲のアウトライン" "view.pan" = "ビューをパン" +"view.panelWidths" = "パネルの幅をリセット" "view.pixelGrid" = "ピクセルグリッド" "view.pixelPreview" = "ピクセルプレビュー" "view.prevFrame" = "前のフレームにズーム" @@ -2087,6 +2173,8 @@ status = "draft — needs native review" "view.design" = "デザイン" "view.layers" = "レイヤー" "view.outlines" = "アウトラインを表示" +"view.outlinesHiddenLayers" = "非表示のレイヤーを含める" +"view.outlinesObjectBounds" = "オブジェクトの境界を含める" "view.outlinesSelection" = "選択範囲のアウトラインを表示" "view.prototype" = "プロトタイプ" "view.themeDark" = "ダーク" diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 99f0f1a70..656ff6622 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Japanese draft catches up with templates, recent files and editor commands) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog and Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese @@ -17,16 +17,16 @@ browser's page translation. The `.lproj` folders in `/Applications/Figma.app` (6 Electron/Chromium locale packs, not Figma's own UI languages. Text *content* in any script is supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no vertical text. -## What FigCraft has (measured 2026-10-12, from source) +## What FigCraft has (measured 2026-10-14, from source) - **Layer:** one TOML catalog per language in `crates/ui-egui/locales/`, baked into the binary by the UI crate's build script (desktop and web; nothing read at run time). Keys are the English text (`tr("Fill")`, `trf` for `{named}` placeholders, `trn` for counts with one/other plural forms), so a missing translation shows English, never a raw key. One English text that needs two translations takes a context (`"Fill|sizing"`). -- **Strings:** 1,417 UI keys in `en.toml` (36 with plural forms; ~110 are labels from engine and - document data, such as tool names, blend modes and Dev Mode properties) plus 536 command labels - and 29 menu wordings by command id: 1,982 entries. Command ids, the CLI, MCP and the control +- **Strings:** 1,577 UI keys in `en.toml` (38 with plural forms; some are labels from engine and + document data, such as tool names, blend modes and Dev Mode properties) plus 557 command labels + and 31 menu wordings by command id: 2,165 entries. Command ids, the CLI, MCP and the control channel stay English. Accessibility names come from the same strings. - **Language preference:** Preferences ▸ Language (System, English, Deutsch, Español, Español (Latinoamérica), Français, 日本語, 한국어, Português (Brasil), 简体中文), applied at once without a @@ -44,8 +44,9 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v lacks, keys no code uses, unknown command ids, placeholders that differ from English, overlay entries that read as in their base language and, for complete languages (Spanish), anything untranslated. The other drafts are not held to it: a string added later shows English there - until someone translates it. Every draft translates every string as of 2026-10-12 (checked by - adding each to `COMPLETE` locally and running the check). + until someone translates it. Japanese and Spanish contain all 2,165 current catalog entries; + the other drafts contain 2,077 (88 newer entries still read English). Japanese remains a draft + awaiting native review; its catalog coverage does not include the unlocalized engine messages. - **Not yet localized:** messages the engine writes (errors, a few toasts, Dev Mode property values such as "inside"), number formatting (decimal points stay `.`), and plural forms beyond one/other (French and Brazilian Portuguese take the singular for 0 as well; Japanese, Korean and Chinese @@ -78,17 +79,17 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full | |---|---|---|---|---|---|---|---| -| English | en | 2,076 / 2,076 (100%, source) | yes | Latin | yes (source) | full | 0 | -| Simplified Chinese (Mandarin) | zh-Hans | 2,076 / 2,076 (100%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) | -| Spanish | es, es-419 | 2,076 / 2,076 (100%, draft); es-419 overlays 98 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) | +| English | en | 2,165 / 2,165 (100%, source) | yes | Latin | yes (source) | full | 0 | +| Simplified Chinese (Mandarin) | zh-Hans | 2,077 / 2,165 (96%, draft) | partial | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) | +| Spanish | es, es-419 | 2,165 / 2,165 (100%, draft); es-419 overlays 98 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) | | Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 | | Arabic | ar | 0 (0%) | no | Bidi, shaping and RTL names work; the UI does not mirror | no | none | 9–15 | -| French | fr | 2,076 / 2,076 (100%, draft) | yes | Latin | no | draft | 1–2 (after native review) | -| Portuguese | pt-BR | 2,076 / 2,076 (100%, draft) | yes | Latin | no | draft | 1–2 (after native review) | +| French | fr | 2,077 / 2,165 (96%, draft) | partial | Latin | no | draft | 1–2 (after native review) | +| Portuguese | pt-BR | 2,077 / 2,165 (96%, draft) | partial | Latin | no | draft | 1–2 (after native review) | | Indonesian | id | 0 (0%) | no | Latin | no | none | 3–5 | -| Japanese | ja | 2,076 / 2,076 (100%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | -| German | de | 2,076 / 2,076 (100%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | -| Korean | ko | 2,076 / 2,076 (100%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | +| Japanese | ja | 2,165 / 2,165 (100%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | +| German | de | 2,077 / 2,165 (96%, draft) | partial | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | +| Korean | ko | 2,077 / 2,165 (96%, draft) | partial | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics | no | none | 3–5 | Other languages: none shipped (Figma ships none beyond the eight above). @@ -107,6 +108,7 @@ Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; its users | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Japanese draft catches up with 75 UI strings (template names, descriptions and tips; recent-file sorting, dates and actions; component playground and variable hints), 11 command labels and 2 menu wordings. Existing entries and placeholders are preserved; Japanese dates use the already translated month. 2,165 entries (1,577 UI, 557 commands, 31 menu); native review is still pending. Other drafts retain 2,077 entries; localization grade and estimates unchanged | | 2026-10-12 | minor | Drafts catch up again, and the counts with them: French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese translate the 71 UI strings and 5 command labels they lacked since sharing with a join code and open hosting (the Share popover, join codes, owner tokens, reports, the .fig export notes), and with Spanish the 26 strings and 3 command labels of your online files (Home ▸ Online, its menu and dialogs, the marks on tabs); machine-drafted in each file's conventions, still drafts. The Latin American overlay adds computadora and agrega where the new Spanish says ordenador and añade (98 entries). 2,076 entries per catalog (1,503 UI strings, 544 command labels, 29 menu wordings) | | 2026-10-12 | minor | Draft catalogs catch up: French, German, Brazilian Portuguese, Japanese and Korean translate the 66 UI strings and 17 command labels added since they were drafted (cursor chat and spotlight, the plugin directory, Rename layers, presentation and snapping keys, list levels, the rotation origin, placing with Enter…), Simplified Chinese the 46 and 12 it lacked; machine-drafted from the English in each file's conventions, still drafts (not in `COMPLETE`). The Latin American overlay adds Control for a gamepad controller and presiona in the plugin directory hint (90 entries, with Enter for the keycap). String counts re-measured: 1,417 UI keys, 536 command labels and 29 menu wordings (1,982 entries), every draft at 100%. Localization grade unchanged | | 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h | From 310cb40af9cf3bb0364112aafd8d045a9d261195 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 13:58:37 +0200 Subject: [PATCH 022/120] M1: open .figcraft files from Finder, Open With and the Dock on macOS (fixes #289) --- Cargo.lock | 12 ++++++ apps/figcraft/Cargo.toml | 7 +++ apps/figcraft/src/apple_events.rs | 57 +++++++++++++++++++++++++ apps/figcraft/src/apple_events_tests.rs | 30 +++++++++++++ apps/figcraft/src/main.rs | 30 ++++++++++++- 5 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 apps/figcraft/src/apple_events.rs create mode 100644 apps/figcraft/src/apple_events_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 7055f3cf3..066be8763 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1418,6 +1418,7 @@ dependencies = [ "figcraft-format", "figcraft-text", "figcraft-ui-egui", + "fmv-macos-events", "image", "log", "rfd", @@ -1750,6 +1751,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fmv-macos-events" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55" +dependencies = [ + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", +] + [[package]] name = "fnv" version = "1.0.7" diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index b64b68d88..bafb6da14 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -31,6 +31,13 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col [target.'cfg(windows)'.dependencies] windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] } +# Finder double-clicks, Open With, Dock drops and `open -a FigCraft file.figcraft` arrive as a +# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This crate (also used by GridCraft, +# EffectCraft and PhotoCraft) registers the handler behind a safe API (Objective-C interop needs +# unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping. +[target.'cfg(target_os = "macos")'.dependencies] +fmv-macos-events = "=0.1.0" + [dev-dependencies] wat = { workspace = true } diff --git a/apps/figcraft/src/apple_events.rs b/apps/figcraft/src/apple_events.rs new file mode 100644 index 000000000..6b7920944 --- /dev/null +++ b/apps/figcraft/src/apple_events.rs @@ -0,0 +1,57 @@ +//! macOS open-documents and quit Apple events (#289). +//! +//! Finder double-clicks, Open With, drops on the Dock icon and `open -a FigCraft file.figcraft` +//! don't pass paths on the command line: LaunchServices sends the running (or just-launched) app a +//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the +//! `NSApplicationDelegate`, so AppKit answered "FigCraft cannot open files in the FigCraft Document +//! format". Handling it ourselves needs Objective-C class declarations, i.e. `unsafe`, which this +//! workspace forbids. The `fmv-macos-events` crate (also used by GridCraft, EffectCraft and +//! PhotoCraft) wraps exactly that, an `NSAppleEventManager` handler registered before Finder's +//! launch event that leaves winit's delegate alone, behind a safe main-thread API. + +use std::path::PathBuf; + +use figcraft_ui_egui::FigApp; +use fmv_macos_events::{Event, Inbox, Registration}; +use serde_json::json; + +/// Keeps the Apple-event handlers registered; hold it until the event loop returns. +pub struct AppleEvents { + _registration: Registration, + inbox: Inbox, +} + +impl AppleEvents { + /// Register the handlers. Call on the main thread before the event loop starts, so the event + /// that launched the app (a Finder double-click) is caught too. + pub fn install() -> Self { + let (registration, inbox) = Registration::install(); + Self { _registration: registration, inbox } + } + + /// The queue the app drains every frame ([`poll`]); events arriving later wake `ctx`. + pub fn connect(&self, ctx: &egui::Context) -> Inbox { + let ctx = ctx.clone(); + self.inbox.set_wake(move || ctx.request_repaint()); + self.inbox.clone() + } +} + +/// Open the documents and act on the quit requests that arrived since the last frame. +pub fn poll(inbox: &Inbox, fig: &mut FigApp, ctx: &egui::Context) { + for e in inbox.drain() { + match e { + Event::Open(paths) => open_documents(fig, paths), + Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + } + } +} + +/// Open `paths` as File › Open does; a file that fails shows its error and the others still open. +pub fn open_documents(fig: &mut FigApp, paths: Vec) { + for p in paths { + if fig.run("file.open", json!({"path": p.to_string_lossy()})).is_some() { + fig.ui.home = false; + } + } +} diff --git a/apps/figcraft/src/apple_events_tests.rs b/apps/figcraft/src/apple_events_tests.rs new file mode 100644 index 000000000..173f5385c --- /dev/null +++ b/apps/figcraft/src/apple_events_tests.rs @@ -0,0 +1,30 @@ +//! Documents macOS hands over in an open-documents Apple event (a Finder double-click, Open With, +//! a drop on the Dock icon) open as File › Open opens them (#289). + +use std::path::PathBuf; + +use figcraft_engine::Session; +use figcraft_ui_egui::{FigApp, NoServices}; +use serde_json::json; + +use crate::apple_events::open_documents; + +#[test] +fn documents_from_finder_open_and_leave_the_home_screen() { + let dir = std::env::temp_dir().join(format!("figcraft-apple-events-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("BLA-001.figcraft"); + let mut s = Session::new(); + s.execute("file.saveAs", &json!({"path": file.to_string_lossy()})).unwrap(); + + let mut app = FigApp::new(Session::empty(), Box::new(NoServices)); + app.ui.home = true; + // A file that can't be read is reported and doesn't keep the others from opening. + open_documents(&mut app, vec![dir.join("Missing.figcraft"), file.clone()]); + let paths: Vec<_> = app.session.docs.iter().map(|d| d.path.clone().map(PathBuf::from)).collect(); + assert_eq!(paths, [Some(file)]); + assert!(!app.ui.home, "the opened document shows"); + assert!(app.ui.last_error.as_deref().is_some_and(|e| e.contains("Missing.figcraft")), "{:?}", app.ui.last_error); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 0332f7d83..7b14d8e23 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -9,6 +9,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] +#[cfg(target_os = "macos")] +mod apple_events; mod args; mod control_server; mod gamepad; @@ -16,6 +18,8 @@ mod graphics; mod recovery; mod services; +#[cfg(all(test, target_os = "macos"))] +mod apple_events_tests; #[cfg(test)] mod args_tests; #[cfg(test)] @@ -43,6 +47,9 @@ struct App { title: String, /// Shared with the services, which write the copies after a panic. recovery: recovery::Shared, + /// Documents and quit requests from Finder, the Dock and Open With. + #[cfg(target_os = "macos")] + apple_events: fmv_macos_events::Inbox, } impl eframe::App for App { @@ -55,6 +62,8 @@ impl eframe::App for App { } let title = with_app(|fig| { control_server::drain_queue(&self.queue, &mut fig.control_queue); + #[cfg(target_os = "macos")] + apple_events::poll(&self.apple_events, fig, ctx); // Closing the window asks about unsaved changes (and keeps the app running while other // windows are open). figcraft_ui_egui::windows::on_close_request(fig, ctx); @@ -188,5 +197,24 @@ fn main() -> eframe::Result { let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() }; graphics::configure(&mut options.wgpu_options); figcraft_ui_egui::windows::install(fig); - eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery })))) + // Registered before the event loop starts, so it catches the Finder event that launched the app + // as well as later ones. Lives until the event loop returns; the app creator only borrows it. + #[cfg(target_os = "macos")] + let apple_events = apple_events::AppleEvents::install(); + #[cfg(target_os = "macos")] + let apple_events = &apple_events; + eframe::run_native( + "FigCraft", + options, + Box::new(move |_cc| { + Ok(Box::new(App { + queue, + ctx_slot, + title: String::new(), + recovery, + #[cfg(target_os = "macos")] + apple_events: apple_events.connect(&_cc.egui_ctx), + })) + }), + ) } From e92792e3e89dc7f19c9e2b7f1234770f503de06e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 14:36:39 +0200 Subject: [PATCH 023/120] M1: a press on a corner handle resizes instead of picking the corner-radius handle (fixes #292) The corner-radius handle sits 10 px in from each corner and answers within 6 px, so a press on the inner half of the 8 px corner square picked it: the drag rounded the corners or, dragged outward, did nothing. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the constrain-proportions link); further in the radius handle still answers. --- ROADMAP.md | 1 + crates/engine/src/tools.rs | 19 +++++++++++- .../engine/src/transform_behaviour_tests.rs | 29 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29b..a752a6ade 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. - **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged. diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index cfb9176d0..07f277d4f 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -617,6 +617,18 @@ pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option< handle_at_corners(&corners, p, zoom, is_line) } +/// Half the side of the square the canvas draws on each corner of the handle box (screen pixels, +/// upright whatever the box's rotation). +const CORNER_SQUARE_PX: f64 = 4.0; + +/// Whether `p` is on a corner square of the selection's handle box as drawn: there the corner +/// resizes, although a corner-radius handle 10 px in reaches that far too. +fn on_corner_square(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> bool { + let reach = CORNER_SQUARE_PX / zoom.max(1e-6); + matches!(handle_at(doc, sel, p, zoom), Some(Handle::Corner(_))) + && selection_box(doc, sel).is_some_and(|c| c.iter().any(|c| (c.x - p.x).abs() <= reach && (c.y - p.y).abs() <= reach)) +} + /// A box whose sides are both shorter than this on screen (pixels) would be covered by its /// handles: the canvas draws just its outline (the handles still answer just outside it). pub const SMALL_BOX_PX: f64 = 16.0; @@ -982,7 +994,11 @@ impl Session { let mut measure_box = None; let arc_hover = if self.arc_handles_shown() { crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None }; self.tool.overlay.arc_hover = arc_hover; - let shape_hover = if self.arc_handles_shown() { crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None }; + let shape_hover = if self.arc_handles_shown() && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) { + crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) + } else { + None + }; self.tool.overlay.shape_hover = shape_hover; if matches!(tool, ToolId::Move | ToolId::Scale) && on_edited { cursor = Cursor::Text; @@ -1090,6 +1106,7 @@ impl Session { return Ok(()); } if self.arc_handles_shown() + && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) && let Some((id, handle)) = crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom) { self.begin_interaction("Corner radius")?; diff --git a/crates/engine/src/transform_behaviour_tests.rs b/crates/engine/src/transform_behaviour_tests.rs index 3fbecb017..330473a48 100644 --- a/crates/engine/src/transform_behaviour_tests.rs +++ b/crates/engine/src/transform_behaviour_tests.rs @@ -277,6 +277,35 @@ fn constrain_proportions_keeps_the_other_side_in_ratio() { assert_eq!(size(&s, z), (20.0, 0.0), "a zero height leaves nothing to scale"); } +#[test] +fn a_press_anywhere_on_a_corner_handle_resizes() { + // The canvas draws each corner handle as an 8 px square centred on the corner, and the + // corner-radius handle sits 10 px in along the diagonal: a press on the square's inner half + // resizes (both sides, in ratio with the link on), it doesn't pick the radius handle. + let radii = |s: &Session, r: NodeId| s.doc().unwrap().node(r).unwrap().corners.radii; + for lock in [false, true] { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 1000.0, 600.0, None); + set(&mut s, &[r], json!({"constrainProportions": lock})); + ex(&mut s, "select.set", json!({"ids": [r]})); + let on_square = Point::new(997.0, 597.0); + s.pointer(&ev(PointerKind::Hover, on_square, NONE)).unwrap(); + assert!(matches!(s.tool.overlay.cursor, Cursor::Resize(_)), "lock {lock}: {:?}", s.tool.overlay.cursor); + drag(&mut s, on_square, Point::new(1197.0, 637.0), NONE); + let (w, h) = size(&s, r); + let want_h = if lock { 718.2 } else { 637.0 }; + assert!(near(w, 1197.0) && near(h, want_h), "lock {lock}: both sides follow the corner, got {w}×{h}"); + assert_eq!(radii(&s, r), [0.0; 4]); + } + // Further in, past the square, the radius handle still answers. + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 1000.0, 600.0, None); + ex(&mut s, "select.set", json!({"ids": [r]})); + drag(&mut s, Point::new(993.0, 593.0), Point::new(980.0, 580.0), NONE); + assert_eq!(size(&s, r), (1000.0, 600.0)); + assert!(radii(&s, r)[0] > 0.0, "the radius handle rounds the corners"); +} + #[test] fn frames_resize_with_constraints_and_cmd_ignores_them() { let mut s = session(); From 3709e7320c9796817712a6796007338deea59d53 Mon Sep 17 00:00:00 2001 From: fede9234 Date: Wed, 14 Oct 2026 14:54:01 +0200 Subject: [PATCH 024/120] fix(desktop): Windows' first preferred language picks the UI language (#291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows' preferred-language list is also the input-language list: a language added for a keyboard sits in it, and taking the first entry with a catalog turned the interface German on a Polish system (a German keyboard) while the system and the other apps stayed Polish. Only the first preferred language now decides; when it has no catalog the interface reads English. macOS' AppleLanguages and gettext's `LANGUAGE` still take the first language with a catalog. Fixes #291. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- ROADMAP.md | 4 +++- apps/figcraft/src/services.rs | 8 ++++++-- docs/localization-parity.md | 14 ++++++++------ docs/roadmap.md | 3 ++- docs/translating.md | 8 +++++--- 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29b..1ce465365 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; the UI language on Windows comes from the first preferred language alone (#291); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-14** · Windows UI language (#291): the interface language comes from Windows' first preferred language alone. The preferred-language list is also the input-language list (a German keyboard on a Polish system adds Deutsch to it), and taking the first entry with a catalog turned FigCraft German while Windows and the other apps read Polish; Polish has no catalog, so the interface reads English now. macOS' AppleLanguages and gettext's `LANGUAGE` still take the first language with a catalog. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. - **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged. @@ -312,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Windows UI language (#291): the interface language comes from Windows' first preferred language alone; that list is also the input-language list (a German keyboard on a Polish system added Deutsch), and the first entry with a catalog turned FigCraft German. A first language without a catalog reads English; macOS and Linux keep taking the first language with a catalog. Numbers unchanged | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index f4aeb63d7..4a14a73a2 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -59,12 +59,16 @@ impl Desktop { /// The system's preferred interface languages, most preferred first, comma-separated /// ("nl-NL,de-DE,en-US"): Windows' language list, macOS's AppleLanguages, `LANGUAGE` and the -/// locale variables elsewhere. The UI shows the first one it has a catalog for. +/// locale variables elsewhere. The UI shows the first language in the list that has a catalog. +/// Windows keeps only its first preferred language (and English when it has no catalog): that +/// list is also the input-language list, so a language added for a keyboard (Deutsch on a Polish +/// system) must not decide the interface language. fn system_language() -> Option { #[cfg(target_os = "windows")] { windows::System::UserProfile::GlobalizationPreferences::Languages() - .map(|l| l.into_iter().map(|s| s.to_string()).collect::>().join(",")) + .and_then(|l| l.GetAt(0)) + .map(|s| s.to_string()) .inspect_err(|e| log::info!("language: can't read the system languages: {e}")) .ok() } diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 99f0f1a70..ecf1bc71a 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -30,12 +30,13 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v channel stay English. Accessibility names come from the same strings. - **Language preference:** Preferences ▸ Language (System, English, Deutsch, Español, Español (Latinoamérica), Français, 日本語, 한국어, Português (Brasil), 简体中文), applied at once without a - restart; System takes the first of the OS's preferred languages that has a catalog (Windows' - language list, macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional tag - falls back to its language, pt-PT or pt to pt-BR, and es-419, es-MX, es-AR, es-CO and the other - Latin American and US Spanish tags to es-419; zh, zh-CN, zh-SG and zh-Hans read zh-Hans, while - Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and reads English or the next OS - language, never Simplified) or the browser's language on the web. + restart; System takes the first of the OS's preferred languages that has a catalog (on Windows + only the first preferred language counts, since that list also holds the languages added for a + keyboard; macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional + tag falls back to its language, pt-PT or pt to pt-BR, and es-419, es-MX, es-AR, es-CO and the + other Latin American and US Spanish tags to es-419; zh, zh-CN, zh-SG and zh-Hans read zh-Hans, + while Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and reads English or the + next OS language, never Simplified) or the browser's language on the web. `pref.language {language}` sets it from the CLI, MCP and the control channel. - **Regional overlays:** a regional catalog whose language has a catalog lists only what differs (`es-419.toml` over `es.toml`); its UI strings, command labels and menu wordings read the @@ -107,6 +108,7 @@ Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; its users | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Windows UI language (#291): the interface language comes from Windows' first preferred language alone; that list is also the input-language list (a German keyboard on a Polish system adds Deutsch), and taking the first entry with a catalog turned the interface German there. A first preferred language without a catalog reads English; macOS AppleLanguages and gettext's `LANGUAGE` still take the first language with a catalog | | 2026-10-12 | minor | Drafts catch up again, and the counts with them: French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese translate the 71 UI strings and 5 command labels they lacked since sharing with a join code and open hosting (the Share popover, join codes, owner tokens, reports, the .fig export notes), and with Spanish the 26 strings and 3 command labels of your online files (Home ▸ Online, its menu and dialogs, the marks on tabs); machine-drafted in each file's conventions, still drafts. The Latin American overlay adds computadora and agrega where the new Spanish says ordenador and añade (98 entries). 2,076 entries per catalog (1,503 UI strings, 544 command labels, 29 menu wordings) | | 2026-10-12 | minor | Draft catalogs catch up: French, German, Brazilian Portuguese, Japanese and Korean translate the 66 UI strings and 17 command labels added since they were drafted (cursor chat and spotlight, the plugin directory, Rename layers, presentation and snapping keys, list levels, the rotation origin, placing with Enter…), Simplified Chinese the 46 and 12 it lacked; machine-drafted from the English in each file's conventions, still drafts (not in `COMPLETE`). The Latin American overlay adds Control for a gamepad controller and presiona in the plugin directory hint (90 entries, with Enter for the keycap). String counts re-measured: 1,417 UI keys, 536 command labels and 29 menu wordings (1,982 entries), every draft at 100%. Localization grade unchanged | | 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h | diff --git a/docs/roadmap.md b/docs/roadmap.md index 8e2780d68..6bd406637 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; the UI language on Windows comes from the first preferred language alone (#291); Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Windows UI language (#291): the interface language comes from Windows' first preferred language alone; the preferred-language list is also the input-language list (a German keyboard on a Polish system adds Deutsch), and taking the first entry with a catalog turned FigCraft German. A first preferred language without a catalog reads English; macOS AppleLanguages and gettext's `LANGUAGE` are unchanged. Estimates unchanged. | | 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/docs/translating.md b/docs/translating.md index b7c26a401..cd587b25b 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -4,9 +4,11 @@ FigCraft's interface strings live in one TOML catalog per language in [`crates/ui-egui/locales/`](../crates/ui-egui/locales). The build bakes every catalog into the binary (desktop and web); nothing is read at run time. Preferences ▸ Language picks the language and the interface switches at once. System takes the first of the OS's preferred languages that has -a catalog (the browser's language on the web): a regional tag reads its language's catalog -(`fr-CA` → `fr`), a language with only a regional catalog reads that one (`pt-PT` → `pt-BR`), and -Latin American and US Spanish (`es-MX`, `es-AR`, `es-CO`, `es-US`, `es-419`…) read `es-419`. +a catalog (the browser's language on the web; on Windows only the first preferred language counts, +since that list also holds the languages added for a keyboard, and a first language without a +catalog reads English): a regional tag reads its language's catalog (`fr-CA` → `fr`), a language +with only a regional catalog reads that one (`pt-PT` → `pt-BR`), and Latin American and US Spanish +(`es-MX`, `es-AR`, `es-CO`, `es-US`, `es-419`…) read `es-419`. Chinese (`zh`, `zh-CN`, `zh-SG`, `zh-Hans`) reads `zh-Hans`; Traditional Chinese (`zh-Hant`, `zh-TW`, `zh-HK`, `zh-MO`) has no catalog and reads English (or the next OS language), not Simplified. From 4193a82dfbb0cbc1d28e93a7df6337dbd63d6e05 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:08:47 +0200 Subject: [PATCH 025/120] Count a recovery revision as copied only after its write succeeds Fixes #186 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- apps/figcraft/src/recovery.rs | 63 ++++++++++++++++++++++------- apps/figcraft/src/recovery_tests.rs | 48 ++++++++++++++++++++++ contributors/people.toml | 4 ++ 3 files changed, 101 insertions(+), 14 deletions(-) diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index 93335394f..0885c68e2 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -41,14 +41,40 @@ pub struct Recovery { dir: PathBuf, /// Held while the app runs: a free lock means the run that wrote the folder is gone. _lock: File, - /// The revision each document was last copied at, and the write in flight. - written: HashMap>)>, + /// What was copied of each document, and the write in flight. + written: HashMap, /// When (UI clock) the next copies are due. next: f64, /// Copies of online files from earlier runs, kept until their outcome is known. waiting: Vec, } +/// The state of the copy of one document: only a write that succeeded counts as a copy. +#[derive(Default)] +struct Copy { + /// The revision whose copy was written. + done: Option, + /// The write in flight: the revision it copies, and whether it succeeded once it ends. + flight: Option<(u64, JoinHandle)>, +} + +impl Copy { + /// Take in the result of the write in flight once it ended; a failed one leaves its revision uncopied. + fn reap(&mut self) { + if self.flight.as_ref().is_some_and(|f| f.1.is_finished()) + && let Some((revision, h)) = self.flight.take() + && h.join().unwrap_or(false) + { + self.done = Some(revision); + } + } + + /// Whether `revision` has neither been copied nor is being copied. + fn pending(&self, revision: u64) -> bool { + self.done != Some(revision) && self.flight.as_ref().is_none_or(|f| f.0 != revision) + } +} + /// A copy of one of your online files whose file is being joined again as session `session`. pub struct Waiting { session: u64, @@ -86,15 +112,21 @@ impl Recovery { /// an idle window can sleep). pub fn tick(&mut self, s: &Session, now: f64) -> Option { self.settle(s); + self.written.values_mut().for_each(Copy::reap); // A join on its way settles within its timeouts: look again soon. let settling = (!self.waiting.is_empty()).then_some(0.5); - let pending = s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.0 != d.revision)); if now < self.next { - return pending.then_some(self.next - now).or(settling); + return self.pending(s).then_some(self.next - now).or(settling); } self.write(s, false); self.next = now + INTERVAL; - settling + // A copy that failed is tried again at the next turn, without another edit. + settling.or(self.pending(s).then_some(INTERVAL)) + } + + /// Whether a document with unsaved changes has a revision that is neither copied nor being copied. + fn pending(&self, s: &Session) -> bool { + s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.pending(d.revision))) } /// Remove earlier runs' copies whose outcome is known, once what they became is copied here. @@ -131,9 +163,10 @@ impl Recovery { } continue; } - let entry = self.written.entry(d.uid).or_insert((0, None)); - let busy = entry.1.as_ref().is_some_and(|h| !h.is_finished()); - if entry.0 == d.revision || busy { + let entry = self.written.entry(d.uid).or_default(); + entry.reap(); + // A write still in flight (for any revision) is waited for; a failed one is tried again. + if entry.done == Some(d.revision) || entry.flight.is_some() { continue; } let doc = d.doc.clone(); @@ -144,20 +177,22 @@ impl Recovery { let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq)); let job = move || { if !write_atomic(&path, &doc) { - return; + return false; } let _ = match online { Some(text) => std::fs::write(¬e, text), None => std::fs::remove_file(¬e), }; + true }; + // The revision counts as copied only once its write succeeded. if wait { - job(); - entry.1 = None; + if job() { + entry.done = Some(d.revision); + } } else { - entry.1 = std::thread::Builder::new().name("recovery".into()).spawn(job).ok(); + entry.flight = std::thread::Builder::new().name("recovery".into()).spawn(job).ok().map(|h| (d.revision, h)); } - entry.0 = d.revision; } // Closed documents. let open: Vec = s.docs.iter().map(|d| d.uid).collect(); @@ -177,7 +212,7 @@ impl Recovery { pub fn finish(self) { let Self { dir, _lock, written, .. } = self; // Windows removes a folder only once nothing in it is open: copies being written first. - for h in written.into_values().filter_map(|w| w.1) { + for (_, h) in written.into_values().filter_map(|w| w.flight) { let _ = h.join(); } drop(_lock); diff --git a/apps/figcraft/src/recovery_tests.rs b/apps/figcraft/src/recovery_tests.rs index 95560319c..18d18f6a2 100644 --- a/apps/figcraft/src/recovery_tests.rs +++ b/apps/figcraft/src/recovery_tests.rs @@ -62,3 +62,51 @@ fn copies_follow_saves_and_a_normal_quit_leaves_nothing() { assert!(!dir.exists(), "a normal quit removes the copies"); let _ = std::fs::remove_dir_all(&root); } + +/// The files named `*.figcraft` in the run folder under `root`. +fn copy_count(root: &std::path::Path) -> usize { + let Some(dir) = std::fs::read_dir(root).unwrap().flatten().next().map(|e| e.path()) else { return 0 }; + std::fs::read_dir(dir).unwrap().flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count() +} + +#[test] +fn a_copy_that_failed_is_tried_again_without_another_edit() { + let root = root("retry"); + let s = edited(); + let mut r = Recovery::start(&root).unwrap(); + let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path(); + let moved = root.join("moved"); + // The run folder is gone while the copy is written: it fails. + std::fs::rename(&dir, &moved).unwrap(); + r.write(&s, true); + std::fs::rename(&moved, &dir).unwrap(); + // Nothing was edited since: the same revision still has to be copied. + r.write(&s, true); + assert_eq!(copy_count(&root), 1, "the failed revision was copied again"); + r.finish(); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_background_copy_that_failed_is_tried_again_without_another_edit() { + let root = root("retry-bg"); + let s = edited(); + let mut r = Recovery::start(&root).unwrap(); + let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path(); + let moved = root.join("moved"); + std::fs::rename(&dir, &moved).unwrap(); + r.write(&s, false); + // Let the failing write end before the folder comes back. + std::thread::sleep(std::time::Duration::from_millis(300)); + std::fs::rename(&moved, &dir).unwrap(); + for _ in 0..50 { + r.write(&s, false); + if copy_count(&root) == 1 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + assert_eq!(copy_count(&root), 1, "the failed revision was copied again"); + r.finish(); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/contributors/people.toml b/contributors/people.toml index e61cfd31a..db1f2b544 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -16,3 +16,7 @@ # # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false From 8b961b980ad9bd9376e81aa3bf11d16bbfccadf5 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:12:40 +0200 Subject: [PATCH 026/120] Recovery: wait for the write in flight before an emergency save and before removing copies (#202) --- apps/figcraft/src/recovery.rs | 21 ++++++++++++-- apps/figcraft/src/recovery_tests.rs | 45 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index 93335394f..8e2c719f7 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -125,13 +125,21 @@ impl Recovery { let path = self.dir.join(format!("{}.figcraft", d.uid)); let note = path.with_extension(ONLINE); if !needs_copy(d) { - if self.written.remove(&d.uid).is_some() { + if let Some((_, writer)) = self.written.remove(&d.uid) { + // A write still running would bring the copy back after it was removed. + if let Some(h) = writer { + let _ = h.join(); + } let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(¬e); } continue; } let entry = self.written.entry(d.uid).or_insert((0, None)); + // An emergency save must copy the newest revision: let the write in flight finish first. + if wait && let Some(h) = entry.1.take() { + let _ = h.join(); + } let busy = entry.1.as_ref().is_some_and(|h| !h.is_finished()); if entry.0 == d.revision || busy { continue; @@ -162,9 +170,12 @@ impl Recovery { // Closed documents. let open: Vec = s.docs.iter().map(|d| d.uid).collect(); let dir = &self.dir; - self.written.retain(|uid, _| { + self.written.retain(|uid, w| { let keep = open.contains(uid); if !keep { + if let Some(h) = w.1.take() { + let _ = h.join(); + } let _ = std::fs::remove_file(dir.join(format!("{uid}.figcraft"))); let _ = std::fs::remove_file(dir.join(format!("{uid}.{ONLINE}"))); } @@ -172,6 +183,12 @@ impl Recovery { }); } + /// Test hook: pretend a write of `uid` at `revision` is in flight in `writer`. + #[cfg(test)] + pub fn hold(&mut self, uid: u64, revision: u64, writer: JoinHandle<()>) { + self.written.insert(uid, (revision, Some(writer))); + } + /// 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) { diff --git a/apps/figcraft/src/recovery_tests.rs b/apps/figcraft/src/recovery_tests.rs index 95560319c..13faaef57 100644 --- a/apps/figcraft/src/recovery_tests.rs +++ b/apps/figcraft/src/recovery_tests.rs @@ -62,3 +62,48 @@ fn copies_follow_saves_and_a_normal_quit_leaves_nothing() { assert!(!dir.exists(), "a normal quit removes the copies"); let _ = std::fs::remove_dir_all(&root); } + +fn slow_write(path: std::path::PathBuf) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(150)); + let _ = std::fs::write(path, b"stale"); + }) +} + +#[test] +fn saving_or_closing_waits_for_a_slow_write_so_the_copy_does_not_come_back() { + let root = root("slow-cleanup"); + let mut s = edited(); + let mut r = Recovery::start(&root).unwrap(); + let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path(); + let uid = s.docs[0].uid; + let copy = dir.join(format!("{uid}.figcraft")); + r.hold(uid, 1, slow_write(copy.clone())); + s.ds_mut().unwrap().mark_saved(); + r.write(&s, false); + std::thread::sleep(std::time::Duration::from_millis(400)); + assert!(!copy.exists(), "a saved document's copy stays removed"); + // Closed: the same. + let s = edited(); + let uid = s.docs[0].uid; + let copy = dir.join(format!("{uid}.figcraft")); + r.hold(uid, 1, slow_write(copy.clone())); + r.write(&Session::empty(), false); + std::thread::sleep(std::time::Duration::from_millis(400)); + assert!(!copy.exists(), "a closed document's copy stays removed"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn an_emergency_save_waits_for_the_write_in_flight_and_copies_the_newest_revision() { + let root = root("slow-emergency"); + let s = edited(); + let mut r = Recovery::start(&root).unwrap(); + let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path(); + let uid = s.docs[0].uid; + let copy = dir.join(format!("{uid}.figcraft")); + r.hold(uid, 0, slow_write(copy.clone())); + r.write(&s, true); + assert!(std::fs::read(©).is_ok_and(|b| b != b"stale"), "the newest revision replaced the slow write"); + let _ = std::fs::remove_dir_all(&root); +} From 6eeac4fa04c72c74836d27848f2836227c737597 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:14:35 +0200 Subject: [PATCH 027/120] Keep the redo steps when a failed command batch rolls back (#196) --- crates/engine/src/batch_rollback_tests.rs | 22 ++++++++++++++++++++++ crates/engine/src/lib.rs | 5 ++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/batch_rollback_tests.rs b/crates/engine/src/batch_rollback_tests.rs index db0b79622..12d958cda 100644 --- a/crates/engine/src/batch_rollback_tests.rs +++ b/crates/engine/src/batch_rollback_tests.rs @@ -61,3 +61,25 @@ fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state() assert_eq!(s.journal, before_journal); assert_eq!(s.message, before_message); } + +#[test] +fn failed_group_keeps_the_redo_steps() { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + assert!(s.undo().unwrap()); + assert_eq!(s.undo_depth().1, 1); + + let result = s.execute( + "command.batch", + &json!({ + "commands": [ + {"command": "shape.rectangle", "params": {"x": 20, "y": 20, "width": 10, "height": 10}}, + {"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}} + ] + }), + ); + + assert!(result.is_err()); + assert_eq!(s.undo_depth().1, 1, "the redo step survives the failed batch"); + assert!(s.redo().unwrap()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a906..0d9eddf48 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1082,6 +1082,8 @@ impl Session { self.ds()?; let before: Vec<_> = self.docs.iter().map(|ds| (ds.uid, ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.history.limit)).collect(); + // An edit inside clears the redo steps; a failed group has to bring them back. + let redo_before: Vec<_> = self.docs.iter().map(|ds| ds.history.redo.clone()).collect(); let active = self.active; let journal_len = self.journal.len(); @@ -1099,8 +1101,9 @@ impl Session { if result.is_err() { self.docs.retain(|ds| before.iter().any(|(uid, _, _, _, _, _)| *uid == ds.uid)); - for (uid, doc, selection, page, undo_len, limit) in &before { + for ((uid, doc, selection, page, undo_len, limit), redo) in before.iter().zip(redo_before) { if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == *uid) { + ds.history.redo = redo; ds.doc = doc.clone(); ds.selection = selection.clone(); ds.page = *page; From a8a689d682da98cd407d7134a8a1084b8657e680 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:12:55 +0200 Subject: [PATCH 028/120] fix(server): keep each team library version in its own file (#206) --- apps/figcraft-server/src/hardening_tests.rs | 38 +++++++++++++++++++++ apps/figcraft-server/src/store.rs | 36 +++++++++++++++---- docs/collab-protocol.md | 6 ++-- docs/server.md | 3 +- 4 files changed, 74 insertions(+), 9 deletions(-) diff --git a/apps/figcraft-server/src/hardening_tests.rs b/apps/figcraft-server/src/hardening_tests.rs index 6a74e6174..086539cae 100644 --- a/apps/figcraft-server/src/hardening_tests.rs +++ b/apps/figcraft-server/src/hardening_tests.rs @@ -800,3 +800,41 @@ fn the_server_forgets_addresses_whose_connections_are_gone() { assert!(server.blocks_remembered() <= 2, "{} blocks remembered", server.blocks_remembered()); server.stop(); } + +#[test] +fn a_library_publish_that_fails_keeps_serving_the_old_version() { + let dir = TempDir::new("libfail"); + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap(); + // Version 1, then a changed library as version 2. + let mut s = Session::new(); + let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "name": "Button"})).unwrap()["id"].as_u64().unwrap(); + let c = s.execute("component.create", &json!({"ids": [f]})).unwrap()["id"].as_u64().unwrap(); + s.execute("library.publish", &json!({})).unwrap(); + let v1 = figcraft_engine::library_server::archive(&s.doc().unwrap().published.clone().unwrap()).unwrap(); + s.execute("node.set", &json!({"id": c, "props": {"fill": "#00ff00"}})).unwrap(); + s.execute("library.publish", &json!({})).unwrap(); + let v2 = figcraft_engine::library_server::archive(&s.doc().unwrap().published.clone().unwrap()).unwrap(); + assert_eq!(store.publish_library(&file.id, &v1).unwrap(), 1); + let files = dir.path().join(crate::store::FILES).join(&file.id); + // The description can't be written: a directory sits where its temporary file goes. + let tmp = crate::disk::temp_of(&files.join(crate::disk::META)); + std::fs::create_dir(&tmp).unwrap(); + assert!(store.publish_library(&file.id, &v2).is_err()); + let fetch = |s: &mut Store| s.library(ip("198.51.100.1"), &file.id, Access::View, LibraryReq::Fetch { have: None, usage: None }); + let served = |reply: LibraryReply| match reply { + LibraryReply::Library { version, archive } => (version, archive), + other => panic!("{other:?}"), + }; + assert_eq!(served(fetch(&mut store)), (1, v1.clone()), "version 1, with its own bytes"); + // A crash right after the archive was written leaves it on disk, the description unchanged. + std::fs::write(files.join(crate::store::library_name(2)), &v2).unwrap(); + drop(store); + std::fs::remove_dir(&tmp).unwrap(); + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + assert_eq!(served(fetch(&mut store)), (1, v1), "still version 1 after a restart"); + // Publishing again works, and serves the new version. + assert_eq!(store.publish_library(&file.id, &v2).unwrap(), 2); + assert_eq!(served(fetch(&mut store)), (2, v2)); + assert!(!files.join(crate::store::library_name(1)).exists(), "the old version is deleted"); +} diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index e5e56b8fb..15408251a 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -10,7 +10,8 @@ //! and a join code's hash and wrapped token (see `codes`) //! /files//snapshot.bin the session state at the last compaction //! /files//log.bin what changed since (see `disk`) -//! /files//library.bin its team library, when one was published (a `.figcraft` archive) +//! /files//library-.bin its team library, when one was published (a `.figcraft` +//! archive; `library.bin` from servers before versioned names) //! /files//usage.json how other files use that library, as their apps report it //! /plugins/ the plugin directory (see `directory`) //! /blocked.json addresses the operator blocked @@ -179,7 +180,7 @@ impl Meta { } } -/// What a file's team library is (`library.bin` holds it). +/// What a file's team library is ([`library_file`] holds it). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] struct LibraryMeta { id: String, @@ -919,11 +920,12 @@ impl Store { /// A file's team library for `ip`, unless its version is `have`; within what one address and /// everyone may download (the size is known before anything is read). fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option) -> Result { - let (path, max) = (self.dir(file).join(LIBRARY), self.cfg.max_file_bytes); + let (dir, max) = (self.dir(file), self.cfg.max_file_bytes); let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) }; if have == Some(lib.version) { return Ok(LibraryReply::Unchanged { version: lib.version }); } + let path = library_file(&dir, lib.version); let size = fs::metadata(&path).map_or(0, |m| m.len()); let now = Instant::now(); if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) { @@ -965,14 +967,24 @@ impl Store { return Err(format!("version {} is on the server already: publish again to follow it", old.version)); } } - // The archive first: a description never names a version whose library isn't there (a - // failed description write leaves the older number, which the next publish follows). - disk::write_atomic(&dir.join(LIBRARY), archive).map_err(|e| format!("couldn't keep the library: {e}"))?; + // Each version has a file of its own, written before the description names it: a failed + // description write or a crash in between leaves the previous version served whole, never + // the new bytes under the old number. + let path = dir.join(library_name(m.version)); + disk::write_atomic(&path, archive).map_err(|e| format!("couldn't keep the library: {e}"))?; let before = entry.meta.library.replace(LibraryMeta { id: m.id.clone(), name: m.name.clone(), version: m.version }); if let Err(e) = write_json(&dir.join(META), &entry.meta) { + // Never served (the description doesn't name it); the next publish of it replaces it. + if let Err(r) = fs::remove_file(&path) { + log::warn!("file {file}: an unpublished library version couldn't be deleted: {r}"); + } entry.meta.library = before; return Err(format!("couldn't keep the library: {e}")); } + // The previous version isn't served any more. + if let Some(Err(e)) = before.map(|old| fs::remove_file(library_file(&dir, old.version))) { + log::warn!("file {file}: the previous library version couldn't be deleted: {e}"); + } log::info!("file {file}: published library version {} ({} bytes)", m.version, archive.len()); Ok(m.version) } @@ -1249,6 +1261,18 @@ fn flush_log(id: &str, log: &mut Log) -> Result { } } +/// The name of the file holding version `version` of a file's team library. +pub fn library_name(version: u64) -> String { + format!("library-{version}.bin") +} + +/// Where version `version` of the team library in `dir` is: its own file, or `library.bin` for a +/// version published before each version had one. +fn library_file(dir: &Path, version: u64) -> PathBuf { + let path = dir.join(library_name(version)); + if path.exists() { path } else { dir.join(LIBRARY) } +} + fn write_json(path: &Path, value: &T) -> Result<(), String> { let text = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?; disk::write_atomic(path, &text).map_err(|e| e.to_string()) diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 7888241d6..874988a34 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -726,8 +726,10 @@ resuming with its offline edits, and leaving the session going back to snapshot - **What is kept.** The published library a library file holds (manifest and snapshot with per-asset versions) travels as a small `.figcraft` archive holding only it. The server reads it in full with the limits a library file is read with, requires the file's library id once one is - kept and a newer version, then writes it beside the file (`library.bin`, atomically) and records - id, name and version in the file's description. Publishing from the app first fetches the + kept and a newer version, then writes it beside the file in a file of its own + (`library-.bin`, atomically) and only then records id, name and version in the file's + description, so a failed write or a crash in between leaves the previous version served whole; + the previous version's file is deleted after. Publishing from the app first fetches the server's version and publishes the next one from it, so per-asset versions continue whoever published last; a race is refused and the next publish follows. - **Using it.** A file enables the library with the link in place of a library file's path diff --git a/docs/server.md b/docs/server.md index 88d999281..f2e4820ca 100644 --- a/docs/server.md +++ b/docs/server.md @@ -320,7 +320,8 @@ behaves as before (blocked addresses, report rates and the limits on a link with a join code its stretched hash and the wrapped token /files//snapshot.bin its state at the last compaction (with a checksum) /files//log.bin what changed since, one checksummed record per change -/files//library.bin its team library, when one was published (a .figcraft archive) +/files//library-.bin its team library, when one was published (a .figcraft + archive; library.bin when it was published by an older server) /files//usage.json which files use that library's components, as their apps report it /plugins/index.json the plugin directory: every version kept (manifest fields, size, SHA-256) /plugins/.bin a plugin bundle, named by the SHA-256 of its bytes From 54ba84eddca3df64fae40fc1859abad8d6c03c37 Mon Sep 17 00:00:00 2001 From: fede9234 Date: Wed, 14 Oct 2026 16:19:46 +0200 Subject: [PATCH 029/120] Text: colour emoji draw from their own colour tables (#268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Colour emoji came out as the placeholder rectangles their outlines carry (Apple Color Emoji) or as .notdef when the face has no outline at all: nothing read sbix, CBDT/EBDT or COLR. A glyph now asks its face for colour ink before its outline: an embedded bitmap strike decoded to premultiplied RGBA and placed by the strike's bearings the way Skia (and CoreText) places it, scaled to the size the run asks for, or a COLR v0 glyph's layers filled from the font's palette. Faces that carry only colour tables are indexed again: Apple Color Emoji is no longer skipped for being 190 MB (the scan still reads headers and name tables only), and "Segoe UI Emoji" and "Noto Color Emoji" join "Apple Color Emoji" as colour fallbacks, read only for a text that nothing else covers. The png crate decodes the strikes; skrifa keeps shaping, metrics and outlines. Left for later: COLR v1 paint graphs, alpha-only strikes and the SVG and PDF exports (text is outlines there). Tests: 8 cases in the text crate (placement, PNG and raw-BGRA decoding, scaling, COLR layers and bitmaps of the machine's own colour font) and a render case gated on that font; the font-free ones run everywhere. Verified on Windows with Segoe UI Emoji (COLR) and with Noto Color Emoji (CBDT) read from a downloaded file; the sbix path runs through the same code and needs a macOS run to be seen. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- Cargo.lock | 1 + ROADMAP.md | 3 +- crates/render/src/draw.rs | 56 +++++- crates/render/src/tests.rs | 31 ++++ crates/text/Cargo.toml | 2 + crates/text/src/color.rs | 307 +++++++++++++++++++++++++++++++++ crates/text/src/color_tests.rs | 203 ++++++++++++++++++++++ crates/text/src/fontdb.rs | 42 ++++- crates/text/src/layout.rs | 34 +++- crates/text/src/lib.rs | 2 + docs/target-app-parity.md | 5 +- docs/ui-parity.md | 3 +- 12 files changed, 673 insertions(+), 16 deletions(-) create mode 100644 crates/text/src/color.rs create mode 100644 crates/text/src/color_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 5e9c6f684..3211147ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1680,6 +1680,7 @@ dependencies = [ "figcraft-geom", "harfrust 0.13.3", "log", + "png", "skrifa 0.47.0", "unicode-bidi", "unicode-segmentation", diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29b..29f1f78a0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (colour emoji drawn from the system colour fonts (#268): bitmap strikes (sbix, CBDT/EBDT) and COLR v0 layers draw instead of the placeholder outlines; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -312,6 +312,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Colour emoji drawn (#268): a glyph that has colour ink draws that instead of its outline — an embedded bitmap strike (sbix, CBDT/EBDT: decoded to premultiplied RGBA, placed by the strike's bearings the way Skia places it and scaled to the size the run asks for) or a COLR v0 glyph's layers filled from the palette; fonts that carry only colour tables (Apple Color Emoji's placeholder outlines, Noto Color Emoji's none at all) are indexed again, "Segoe UI Emoji" and "Noto Color Emoji" join "Apple Color Emoji" and all three are read only for a text that nothing else covers; left for later: COLR v1 paint graphs, alpha-only strikes, emoji in the SVG and PDF exports (text is outlines there). 9 tests (8 in the text crate: placement, PNG and raw-BGRA decoding, scaling, the machine's own colour font; one in the renderer), the font-free ones run everywhere. Numbers unchanged | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index be839b277..a2bfed3c4 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -531,21 +531,50 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, NodeKind::Text(t) => { let Some((layout, dy)) = figcraft_text::layout_node(n) else { return }; let gxf = xf * Affine::translate((0.0, dy)); + // Colour glyphs (emoji) carry their own ink, which draws instead of their outline: a + // bitmap the font ships, or COLR layers with a colour each. + let gs = gxf.determinant().abs().sqrt(); + let mut ink: Vec<(usize, Ink)> = Vec::new(); // Group glyphs by style so runs with their own fills paint with them. let mut by_style: HashMap = HashMap::new(); for g in &layout.glyphs { - by_style.entry(g.style).or_default().extend(g.path().elements().iter().copied()); + match g.color(gs) { + Some(figcraft_text::ColorInk::Bitmap(bitmap, place)) => ink.push((g.style, Ink::Bitmap(bitmap, place))), + Some(figcraft_text::ColorInk::Layers(layers)) => { + for layer in layers.iter() { + ink.push((g.style, Ink::Layer(g.layer_path(layer.gid), layer.rgba))); + } + } + None => by_style.entry(g.style).or_default().extend(g.path().elements().iter().copied()), + } } for d in &layout.decorations { by_style.entry(d.style).or_default().extend(d.path().elements().iter().copied()); } - let mut keys: Vec = by_style.keys().copied().collect(); + // A run can have ink and no outline at all (bitmap-only fonts). + let mut keys: Vec = by_style.keys().copied().chain(ink.iter().map(|(style, _)| *style)).collect(); keys.sort_unstable(); - // Every run's fills, then what paints over the fills, then the strokes on top. + keys.dedup(); + // Every run's fills (its ink included), then what paints over the fills, then the + // strokes on top. for k in &keys { - let Some(tp) = by_style.get(k).and_then(ts_path) else { continue }; let fills = layout.styles.get(*k).and_then(|s| s.fills.as_ref()).unwrap_or(&n.fills); - fill_with(pix, &tp, fills, w, h, gxf, FillRule::Winding, ctx, None); + if let Some(tp) = by_style.get(k).and_then(ts_path) { + fill_with(pix, &tp, fills, w, h, gxf, FillRule::Winding, ctx, None); + } + for (_, item) in ink.iter().filter(|(style, _)| style == k) { + match item { + Ink::Bitmap(bitmap, place) => blit(pix, bitmap, ts_transform(gxf * *place)), + Ink::Layer(path, rgba) => { + if let Some(tp) = ts_path(path) { + let mut paint = tiny_skia::Paint::default(); + paint.set_color_rgba8(rgba[0], rgba[1], rgba[2], rgba[3]); + paint.anti_alias = true; + pix.fill_path(&tp, &paint, FillRule::Winding, ts_transform(gxf), None); + } + } + } + } } if let Some(f) = over_fills { f(pix); @@ -687,6 +716,23 @@ fn draw_children_clipped(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, dept backdrop::replace(pix, (lx, ly), &layer, mask.as_ref().map(|m| (m, (0, 0)))); } +/// Colour ink of a text run, in the order the run paints it. Strokes follow the outlines only: a +/// bitmap-strike glyph has no outline to stroke. +enum Ink { + /// An embedded bitmap and the transform from its pixels into layout coordinates. + Bitmap(Arc, Affine), + /// One COLR layer: its outline in layout coordinates and the colour to fill it with. + Layer(BezPath, [u8; 4]), +} + +/// Draw a colour bitmap (premultiplied RGBA) under a transform, sampled smoothly when the +/// transform scales it. +fn blit(pix: &mut Pixmap, bitmap: &figcraft_text::ColorBitmap, transform: Transform) { + let Some(image) = tiny_skia::PixmapRef::from_bytes(&bitmap.rgba, bitmap.width, bitmap.height) else { return }; + let paint = PixmapPaint { quality: tiny_skia::FilterQuality::Bilinear, ..Default::default() }; + pix.draw_pixmap(0, 0, image, &paint, transform, None); +} + fn is_axis_aligned(xf: Affine) -> bool { let c = xf.as_coeffs(); c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && fract_ok(c[4]) && fract_ok(c[5]) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 167cc7425..b851e9cd8 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -217,6 +217,37 @@ fn text_renders_something() { } } +/// The machine's colour emoji face, if it has one (this test skips without: no font ships here). +fn emoji_face() -> Option<(String, String)> { + figcraft_text::scan_system_fonts(); + let mut db = figcraft_text::db().write().unwrap(); + db.emoji_fallbacks().into_iter().find(|f| f.has_color()).map(|f| (f.family.clone(), f.style.clone())) +} + +#[test] +fn colour_emoji_draw_their_pixels() { + let Some((family, style)) = emoji_face() else { return }; + let mut d = figcraft_doc::Document::new("t"); + let p = d.first_page().unwrap(); + let mut td = TextData::new("\u{1F600}"); + (td.style.font_family, td.style.font_style) = (family.clone(), style.clone()); + td.style.font_size = 48.0; + d.create(p, NodeKind::Text(td), "T", 0.0, 0.0, 200.0, 64.0).unwrap(); + let pix = render_view(&d, p, &view(100, 80), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap(); + // The emoji is the only thing in this document that paints saturated colour. + let coloured = pix + .data() + .as_chunks::<4>() + .0 + .iter() + .filter(|c| { + let (r, g, b) = (i16::from(c[0]), i16::from(c[1]), i16::from(c[2])); + c[3] == 255 && (r.max(g).max(b) - r.min(g).min(b)) > 60 + }) + .count(); + assert!(coloured > 200, "{family} drew {coloured} coloured pixels"); +} + #[test] fn export_png_roundtrip() { let (d, _, r) = doc_with_rect(Color::from_hex("00FF00").unwrap()); diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 273ff1288..84a80858a 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -13,6 +13,8 @@ figcraft-geom = { workspace = true } figcraft-doc = { workspace = true } skrifa = { workspace = true } harfrust = { workspace = true } +# Colour emoji: sbix/CBDT strikes are PNG images the text layer decodes for the renderer. +png = { workspace = true } unicode-bidi = { workspace = true } unicode-segmentation = { workspace = true } log = { workspace = true } diff --git a/crates/text/src/color.rs b/crates/text/src/color.rs new file mode 100644 index 000000000..101dabc97 --- /dev/null +++ b/crates/text/src/color.rs @@ -0,0 +1,307 @@ +//! Colour glyphs: embedded bitmap strikes (sbix, CBDT/EBDT) and COLR v0 layers. +//! +//! Colour emoji fonts don't draw like other glyphs: their outlines are placeholder rectangles +//! (Apple Color Emoji) or missing altogether (Noto Color Emoji), and the picture itself lives in a +//! bitmap strike or in a stack of coloured layers. This module gives the renderer either a +//! premultiplied RGBA raster at the size it asked for, or the layer glyphs and the palette colours +//! to fill with its own outlines. Anything else (COLR v1's paint graphs, alpha-only strikes) is +//! left to the outline pipeline, which draws the placeholders as before. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::PoisonError; + +use figcraft_geom::Affine; +use skrifa::bitmap::{BitmapData, BitmapGlyph, BitmapStrikes, Origin}; +use skrifa::color::ColorPalettes; +use skrifa::instance::Size; +use skrifa::raw::TableProvider; +use skrifa::{FontRef, GlyphId}; + +use crate::fontdb::{Coords, Face}; + +/// Colour rasters kept per face before the cache starts over, in bytes: they are pixels, and a +/// canvas zoomed far in makes each one large. +const MAX_RASTER_BYTES: usize = 32 * 1024 * 1024; +/// Rasters kept per face (misses count too: a face whose strikes cover a few glyphs builds one +/// per glyph and size, and the zoom changes the size). +const MAX_RASTER_ENTRIES: usize = 8192; +/// COLR layer lists kept per face (24 bytes a layer, so a count is enough). +const MAX_LAYERS: usize = 4096; +/// Largest colour raster per em (device pixels). Past this the renderer scales one up instead. +pub const MAX_PX_PER_EM: f64 = 512.0; + +/// A colour bitmap glyph: premultiplied RGBA at `size` px per em, and the offset of its top-left +/// corner from the glyph's pen, in px at `size` (y down). +#[derive(Debug)] +pub struct ColorBitmap { + pub width: u32, + pub height: u32, + pub left: f64, + pub top: f64, + pub rgba: Arc<[u8]>, +} + +/// One layer of a COLR v0 glyph: the glyph that draws the layer and the palette colour it fills +/// with (straight RGBA: the renderer paints it). +#[derive(Debug)] +pub struct ColorLayer { + pub gid: u32, + pub rgba: [u8; 4], +} + +/// A glyph's colour ink: what draws instead of its outline, placed in coordinates the caller +/// supplies (a bitmap pixel maps through `place`; COLR layers are drawn like the glyph's outline). +pub enum ColorInk { + Bitmap(Arc, Affine), + Layers(Arc>), +} + +/// Colour rasters cached per face, keyed by glyph, variations and size. +#[derive(Default)] +pub(crate) struct Rasters { + entries: HashMap<(u32, Coords, u64), Option>>, + bytes: usize, +} + +impl Rasters { + fn get(&self, key: &(u32, Coords, u64)) -> Option> { + self.entries.get(key).and_then(Option::clone) + } + + fn known(&self, key: &(u32, Coords, u64)) -> bool { + self.entries.contains_key(key) + } + + fn insert(&mut self, key: (u32, Coords, u64), bitmap: Option>) { + if let Some(b) = &bitmap { + self.bytes += b.rgba.len(); + } + if self.bytes > MAX_RASTER_BYTES || self.entries.len() > MAX_RASTER_ENTRIES { + self.entries.clear(); + self.bytes = bitmap.as_ref().map(|b| b.rgba.len()).unwrap_or(0); + } + self.entries.insert(key, bitmap); + } +} + +impl Face { + /// Whether the face has colour glyphs at all: bitmaps (sbix, CBDT/EBDT) or COLR layers. + pub fn has_color(&self) -> bool { + self.has_bitmaps() || self.has_colr() + } + + /// The face has bitmap strikes (`sbix`/`CBDT`/`EBDT`) + pub fn has_bitmaps(&self) -> bool { + self.bitmap_strikes + } + + /// The face has a `COLR` table. + pub fn has_colr(&self) -> bool { + self.colr + } + + /// The glyph's embedded bitmap, scaled to `size` px per em (premultiplied RGBA), with the offset + /// of its top-left corner from the pen in px at `size` (y down). `None` when the face has no + /// bitmap for the glyph. + pub fn color_bitmap(&self, gid: u32, coords: &Coords, size: f64) -> Option> { + if !self.bitmap_strikes || !size.is_finite() || size <= 0.0 { + return None; + } + let size = size.clamp(1.0, MAX_PX_PER_EM); + let key = (gid, coords.clone(), size.to_bits()); + let mut cache = self.rasters.lock().unwrap_or_else(PoisonError::into_inner); + if cache.known(&key) { + return cache.get(&key); + } + let made = self.read_bitmap(gid, size).map(Arc::new); + cache.insert(key, made.clone()); + made + } + + /// The glyph's COLR v0 layers (empty when the glyph has none: COLR v1 glyphs, or no COLR data). + pub fn color_layers(&self, gid: u32) -> Option>> { + if !self.colr { + return None; + } + let mut cache = self.layers.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(hit) = cache.get(&gid) { + return hit.clone(); + } + let made = self.read_layers(gid).map(Arc::new); + if cache.len() >= MAX_LAYERS { + cache.clear(); + } + cache.insert(gid, made.clone()); + made + } + + /// Read a strike bitmap and place it. The formula is the one Skia (and CoreText behind it) + /// uses: bearings are in font units and scale with the em, the origin offsets and the image + /// with the strike's ppem, and an sbix image is measured from its lower-left corner. + fn read_bitmap(&self, gid: u32, size: f64) -> Option { + let gid = GlyphId::new(gid); + let font = FontRef::from_index(self.bytes.data(), self.index).ok()?; + let strike = BitmapStrikes::new(&font).glyph_for_size(Size::new(size as f32), gid)?; + let (rgba, width, height) = decode(&strike)?; + if width == 0 || height == 0 || rgba.len() != width as usize * height as usize * 4 { + return None; + } + let (left, top) = placement(&strike, size, self.upem); + // Scale the strike's own pixels to the requested size (the caller blits them 1:1). + let scale = size / f64::from(strike.ppem_y.max(1.0)); + let w = ((f64::from(width) * scale).round() as i64).clamp(1, MAX_PX_PER_EM as i64) as u32; + let h = ((f64::from(height) * scale).round() as i64).clamp(1, MAX_PX_PER_EM as i64) as u32; + let rgba = if (w, h) == (width, height) { rgba } else { scaled(&rgba, width, height, w, h)? }; + Some(ColorBitmap { width: w, height: h, left, top, rgba: rgba.into() }) + } + + /// Read the COLR v0 layers of a glyph (`None` for a glyph the table doesn't cover, which is + /// every glyph of a COLR v1-only font). + fn read_layers(&self, gid: u32) -> Option> { + let font = FontRef::from_index(self.bytes.data(), self.index).ok()?; + let colr = font.colr().ok()?; + let range = colr.v0_base_glyph(GlyphId::new(gid))?; + let palettes = ColorPalettes::new(&font); + let palette = palettes.get(0); + let colors = palette.as_ref().map(|p| p.colors()).unwrap_or(&[]); + let mut out = Vec::new(); + for index in range { + let (layer, color_index) = colr.v0_layer(index)?; + let rgba = colors.get(usize::from(color_index)).map(|c| [c.red(), c.green(), c.blue(), c.alpha()]).unwrap_or([0, 0, 0, 255]); + out.push(ColorLayer { gid: layer.to_u32(), rgba }); + } + (!out.is_empty()).then_some(out) + } +} + +/// Where a strike's image sits from the pen: the image's top-left corner in px at `size` (y down). +fn placement(glyph: &BitmapGlyph, size: f64, upem: f64) -> (f64, f64) { + let units = size / upem.max(1.0); + let pixels = size / f64::from(glyph.ppem_y.max(1.0)); + let left = f64::from(glyph.inner_bearing_x) * pixels + f64::from(glyph.bearing_x) * units; + let above = match glyph.placement_origin { + // sbix: the origin is the image's lower-left corner, so the whole image hangs above it. + Origin::BottomLeft => f64::from(glyph.inner_bearing_y) + f64::from(glyph.height), + Origin::TopLeft => f64::from(glyph.inner_bearing_y), + }; + (left, -above * pixels - f64::from(glyph.bearing_y) * units) +} + +/// A strike's pixels as premultiplied RGBA (what the renderer's pixmaps hold), with their size. +fn decode(glyph: &BitmapGlyph<'_>) -> Option<(Vec, u32, u32)> { + match &glyph.data { + // The OpenType spec's PNG strikes are plain PNGs (straight alpha), so they are + // premultiplied here for the renderer. + BitmapData::Png(png) => { + let (rgba, width, height) = decode_png(png)?; + Some((premultiplied(rgba), width, height)) + } + // Bit depth 32 without PNG is BGRA with premultiplied channels (the spec says so). + BitmapData::Bgra(bgra) => { + let (width, height) = (glyph.width, glyph.height); + let mut rgba = bgra.get(..width as usize * height as usize * 4)?.to_vec(); + for px in rgba.chunks_exact_mut(4) { + px.swap(0, 2); + } + Some((rgba, width, height)) + } + // Alpha-only strikes would be masks, not colour: the outline pipeline draws those glyphs. + BitmapData::Mask(_) => None, + } +} + +#[cfg(test)] +#[path = "color_tests.rs"] +mod tests; + +/// A PNG strike as straight 8-bit RGBA. +fn decode_png(png: &[u8]) -> Option<(Vec, u32, u32)> { + let mut decoder = png::Decoder::new(std::io::Cursor::new(png)); + // Expand palettes, `tRNS` and sub-byte samples to 8-bit RGBA. + decoder.set_transformations(png::Transformations::normalize_to_color8() | png::Transformations::ALPHA); + let mut reader = decoder.read_info().ok()?; + let mut buf = vec![0u8; reader.output_buffer_size()?]; + let info = reader.next_frame(&mut buf).ok()?; + if info.width == 0 || info.height == 0 || info.width > 4096 || info.height > 4096 { + return None; + } + let (width, height) = (info.width, info.height); + let data = &buf[..info.buffer_size()]; + let rgba = match info.color_type { + png::ColorType::Rgba => data.to_vec(), + png::ColorType::Rgb => data.as_chunks::<3>().0.iter().flat_map(|p| [p[0], p[1], p[2], 255]).collect(), + png::ColorType::Grayscale => data.iter().flat_map(|g| [*g, *g, *g, 255]).collect(), + png::ColorType::GrayscaleAlpha => data.as_chunks::<2>().0.iter().flat_map(|p| [p[0], p[0], p[0], p[1]]).collect(), + png::ColorType::Indexed => return None, + }; + Some((rgba, width, height)) +} + +/// Straight RGBA → premultiplied RGBA (in place). +fn premultiplied(mut rgba: Vec) -> Vec { + for px in rgba.chunks_exact_mut(4) { + let a = u32::from(px[3]); + for c in &mut px[..3] { + *c = ((u32::from(*c) * a + 127) / 255) as u8; + } + } + rgba +} + +/// Premultiplied RGBA scaled to `dw`×`dh`: a box average when shrinking, bilinear when growing. +fn scaled(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Option> { + if sw == 0 || sh == 0 || dw == 0 || dh == 0 { + return None; + } + let sample = |x: u32, y: u32| -> [u8; 4] { + let i = ((y.min(sh - 1) * sw + x.min(sw - 1)) * 4) as usize; + match src.get(i..i + 4) { + Some(px) => [px[0], px[1], px[2], px[3]], + None => [0, 0, 0, 0], + } + }; + let mut out = vec![0u8; dw as usize * dh as usize * 4]; + if dw >= sw && dh >= sh { + let at = |v: f64, n: u32| ((v + 0.5) * f64::from(n) - 0.5).clamp(0.0, f64::from(n - 1)); + for y in 0..dh { + let sy = at(f64::from(y) / f64::from(dh), sh); + let (y0, fy) = (sy.floor() as u32, sy.fract()); + for x in 0..dw { + let sx = at(f64::from(x) / f64::from(dw), sw); + let (x0, fx) = (sx.floor() as u32, sx.fract()); + let (p00, p10, p01, p11) = (sample(x0, y0), sample(x0 + 1, y0), sample(x0, y0 + 1), sample(x0 + 1, y0 + 1)); + let o = ((y * dw + x) * 4) as usize; + for c in 0..4 { + let top = f64::from(p00[c]) * (1.0 - fx) + f64::from(p10[c]) * fx; + let bottom = f64::from(p01[c]) * (1.0 - fx) + f64::from(p11[c]) * fx; + out[o + c] = (top * (1.0 - fy) + bottom * fy).round().clamp(0.0, 255.0) as u8; + } + } + } + return Some(out); + } + let (fx, fy) = (f64::from(sw) / f64::from(dw), f64::from(sh) / f64::from(dh)); + for y in 0..dh { + let (y0, y1) = ((f64::from(y) * fy).floor() as u32, ((f64::from(y) + 1.0) * fy).ceil() as u32); + for x in 0..dw { + let (x0, x1) = ((f64::from(x) * fx).floor() as u32, ((f64::from(x) + 1.0) * fx).ceil() as u32); + let mut sum = [0.0f64; 4]; + let mut n = 0.0f64; + for sy in y0..y1.clamp(y0 + 1, sh) { + for sx in x0..x1.clamp(x0 + 1, sw) { + let px = sample(sx, sy); + for c in 0..4 { + sum[c] += f64::from(px[c]); + } + n += 1.0; + } + } + let o = ((y * dw + x) * 4) as usize; + for c in 0..4 { + out[o + c] = (sum[c] / n.max(1.0)).round().clamp(0.0, 255.0) as u8; + } + } + } + Some(out) +} diff --git a/crates/text/src/color_tests.rs b/crates/text/src/color_tests.rs new file mode 100644 index 000000000..d98f8d760 --- /dev/null +++ b/crates/text/src/color_tests.rs @@ -0,0 +1,203 @@ +//! Colour glyph behaviour (colour emoji): bitmap strikes, COLR layers, and where they sit. +//! +//! The placement cases are pure numbers, so they run everywhere. The rest reads the machine's +//! colour emoji face and skips (like the other suites) on a machine without one; which format it +//! uses depends on the platform (Apple Color Emoji is sbix, Noto Color Emoji is CBDT, Segoe UI +//! Emoji is COLR), so a machine exercises the paths its own font has. + +use super::*; +use crate::fontdb::{db, no_coords, scan_system_fonts}; +use crate::layout::layout; +use figcraft_doc::TextData; +use skrifa::bitmap::{BitmapData, BitmapGlyph, MaskData, Origin}; + +/// A strike glyph described by hand, for the placement cases. +fn glyph(width: u32, height: u32, origin: Origin, bearing: (f32, f32), inner: (f32, f32), ppem: f32) -> BitmapGlyph<'static> { + BitmapGlyph { + data: BitmapData::Png(&[]), + bearing_x: bearing.0, + bearing_y: bearing.1, + inner_bearing_x: inner.0, + inner_bearing_y: inner.1, + ppem_x: ppem, + ppem_y: ppem, + advance: None, + width, + height, + placement_origin: origin, + } +} + +/// A strike glyph with data, for the decoding cases (the fields they don't read stay neutral). +fn strike(data: BitmapData<'_>, width: u32, height: u32) -> BitmapGlyph<'_> { + BitmapGlyph { + data, + bearing_x: 0.0, + bearing_y: 0.0, + inner_bearing_x: 0.0, + inner_bearing_y: 0.0, + ppem_x: 10.0, + ppem_y: 10.0, + advance: None, + width, + height, + placement_origin: Origin::TopLeft, + } +} + +#[test] +fn bitmap_placement_follows_the_strike() { + // CBDT: bearings are pixels from the origin to the bitmap's left and top edges, read at the + // strike's ppem and scaled to the size asked for: 2 px at ppem 10 in a 20 px run → 4 px. + let cbdt = glyph(100, 50, Origin::TopLeft, (0.0, 0.0), (2.0, 8.0), 10.0); + let (left, top) = placement(&cbdt, 20.0, 1000.0); + assert_eq!((left, top), (4.0, -16.0), "the left edge sits right of the pen, the top above it"); + + // sbix: bearings and origin offsets are font units, and the image is placed by its lower-left + // corner, so its top is the whole image above the origin. + let sbix = glyph(10, 10, Origin::BottomLeft, (-1.0, -3.0), (1.0, 2.0), 10.0); + let (left, top) = placement(&sbix, 20.0, 1000.0); + assert!((left - (2.0 - 0.02)).abs() < 1e-9, "{left}"); + assert!((top - (-24.0 + 0.06)).abs() < 1e-9, "{top}"); + + // Doubling the size doubles the offset with it. + let scaled = glyph(10, 10, Origin::TopLeft, (0.0, 0.0), (1.0, 1.0), 10.0); + assert_eq!(placement(&scaled, 40.0, 1000.0).1, -4.0); + assert_eq!(placement(&scaled, 20.0, 1000.0).1, -2.0); +} + +/// A 2×2 PNG with straight alpha, the format strikes carry: row 0 is (255,0,0,128) then +/// (0,255,0,255), row 1 is (0,0,255,0) then (10,20,30,51). +const PNG_STRAIGHT: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x72, 0xb6, 0x0d, 0x24, 0x00, 0x00, 0x00, 0x17, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xf8, 0xcf, 0xc0, 0xd0, + 0xc0, 0xf0, 0x1f, 0x08, 0x19, 0x80, 0x98, 0x4b, 0x44, 0xce, 0x18, 0x00, 0x34, 0xb8, 0x04, 0xec, 0xa7, 0x3b, 0x38, 0x04, 0x00, 0x00, 0x00, 0x00, + 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +]; + +#[test] +fn png_strikes_decode_to_premultiplied_rgba() { + let (rgba, width, height) = decode(&strike(BitmapData::Png(PNG_STRAIGHT), 2, 2)).unwrap(); + assert_eq!((width, height), (2, 2)); + assert_eq!( + rgba, + vec![128, 0, 0, 128, 0, 255, 0, 255, 0, 0, 0, 0, 2, 4, 6, 51], + "straight alpha times its own alpha, rounded: the renderer's surfaces are premultiplied" + ); +} + +#[test] +fn raw_bgra_strikes_are_already_premultiplied() { + // BitmapData::Bgra is BGRA with premultiplied channels (the spec says so), so decoding only + // moves the red and blue channels. + let (rgba, width, height) = decode(&strike(BitmapData::Bgra(&[17, 34, 51, 255, 4, 8, 12, 16]), 2, 1)).unwrap(); + assert_eq!((width, height), (2, 1)); + assert_eq!(rgba, vec![51, 34, 17, 255, 12, 8, 4, 16]); +} + +#[test] +fn strikes_that_are_not_colour_decode_to_nothing() { + let mask = MaskData { bpp: 8, is_packed: false, data: &[0; 16] }; + assert!(decode(&strike(BitmapData::Mask(mask), 2, 2)).is_none(), "alpha-only strikes are masks, not colour"); + assert!(decode(&strike(BitmapData::Png(b"not a png"), 2, 2)).is_none(), "a broken PNG is skipped, not drawn"); + assert!(decode(&strike(BitmapData::Bgra(&[1, 2, 3]), 2, 2)).is_none(), "a buffer shorter than the glyph is skipped"); +} + +#[test] +fn scaling_keeps_the_pixels_premultiplied() { + // A 4×4 face: one straight colour over a falling alpha, premultiplied by hand. + let straight = [220u32, 180, 60]; + let ramp = [255u32, 190, 128, 64, 0]; + let mut src = Vec::new(); + for y in 0..4 { + for x in 0..4 { + let a = ramp[(x + y) % ramp.len()]; + src.extend(straight.iter().map(|c| ((c * a + 127) / 255) as u8)); + src.push(a as u8); + } + } + for (dw, dh) in [(2, 2), (3, 4), (9, 7), (16, 16)] { + let out = scaled(&src, 4, 4, dw, dh).unwrap(); + assert_eq!(out.len(), (dw * dh * 4) as usize); + for px in out.chunks_exact(4) { + assert!(px[0] <= px[3] && px[1] <= px[3] && px[2] <= px[3], "{dw}×{dh}: {px:?}"); + } + } + assert!(scaled(&src, 4, 4, 0, 4).is_none() && scaled(&src, 0, 0, 4, 4).is_none(), "an empty side scales to nothing"); +} + +/// The machine's colour emoji face, if it has one (tests skip without: no font ships in the repo). +fn emoji_face() -> Option> { + scan_system_fonts(); + let mut db = db().write().unwrap(); + db.emoji_fallbacks().into_iter().find(|f| f.has_color()) +} + +#[test] +fn colour_emoji_carry_their_own_pixels() { + let Some(face) = emoji_face() else { return }; + // A grinning face: a bitmap strike (Apple, Noto) or COLR layers (Segoe) must cover it. + let gid = face.glyph_for('\u{1F600}'); + assert_ne!(gid, 0, "U+1F600 is not in {}", face.family); + if let Some(layers) = face.color_layers(gid) { + assert!(!layers.is_empty()); + assert!(layers.len() < 64, "{} layers", layers.len()); + assert!(layers.iter().all(|l| l.gid != 0), "every layer names a glyph: {layers:?}"); + return; + } + let Some(bitmap) = face.color_bitmap(gid, &no_coords(), 32.0) else { + panic!("{} has colour tables but draws nothing for U+1F600", face.family); + }; + // The image is a sane size for 32 px of text and its pixels are premultiplied (what the + // renderer's surfaces hold), so no channel is brighter than its alpha. + assert!(bitmap.width >= 8 && bitmap.width <= 128, "{} px wide", bitmap.width); + assert!(bitmap.height >= 8 && bitmap.height <= 128, "{} px tall", bitmap.height); + assert_eq!(bitmap.rgba.len(), bitmap.width as usize * bitmap.height as usize * 4); + let mut opaque = 0; + for px in bitmap.rgba.chunks_exact(4) { + assert!(px[0] <= px[3] && px[1] <= px[3] && px[2] <= px[3], "premultiplied: {px:?}"); + opaque += usize::from(px[3] == 255); + } + assert!(opaque > 20, "the glyph draws something: {opaque} opaque pixels"); + // A bigger strike is scaled to a bigger image, and a smaller one to a smaller image. + let small = face.color_bitmap(gid, &no_coords(), 16.0); + let large = face.color_bitmap(gid, &no_coords(), 64.0); + if let (Some(small), Some(large)) = (small, large) { + assert!(small.width < bitmap.width && bitmap.width < large.width, "{} {} {}", small.width, bitmap.width, large.width); + } +} + +#[test] +fn glyphs_place_their_ink_at_a_scale() { + let Some(face) = emoji_face() else { return }; + let mut t = TextData::new("\u{1F600}"); + (t.style.font_family, t.style.font_style) = (face.family.clone(), face.style.clone()); + t.style.font_size = 32.0; + let l = layout(&t, None, None); + let Some(g) = l.glyphs.first() else { return }; + // Zoomed 2:1 the glyph asks for a 64 px raster and places it in layout coordinates. + let Some(ColorInk::Bitmap(bitmap, place)) = g.color(2.0) else { + assert!(g.color(2.0).is_some(), "the emoji glyph has ink"); + return; + }; + assert!(bitmap.width >= 32 && bitmap.width <= 65, "{} px at 2:1", bitmap.width); + let corner = place * figcraft_geom::Point::new(0.0, 0.0); + assert!(corner.x >= g.x - g.size && corner.x <= g.x + g.size, "x {} vs pen {}", corner.x, g.x); + assert!(corner.y >= g.y - g.size && corner.y <= g.y + g.size, "y {} vs baseline {}", corner.y, g.y); + // Past the raster cap the image stops growing (a canvas zoomed far in). + let Some(ColorInk::Bitmap(huge, _)) = g.color(1000.0) else { return }; + assert!(f64::from(huge.width) <= MAX_PX_PER_EM + 1.0, "{} px", huge.width); +} + +#[test] +fn text_without_colour_ink_keeps_its_outlines() { + // Faces without colour tables answer "no ink" without touching the font again, so plain text + // (the whole canvas, most of the time) pays one flag per glyph. + let Some(face) = crate::fontdb::find_face("Inter", "Regular") else { return }; + if face.has_color() { + return; + } + let gid = face.glyph_for('A'); + assert!(gid != 0, "Inter draws A"); + assert!(face.color_layers(gid).is_none() && face.color_bitmap(gid, &no_coords(), 32.0).is_none()); +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 2c584ebc6..a83fa0fe5 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -11,6 +11,8 @@ use skrifa::raw::{FileRef, TableProvider}; use skrifa::string::StringId; use skrifa::{GlyphId, MetadataProvider}; +use crate::color::{ColorLayer, Rasters}; + /// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`). pub struct CraftFont { pub family: &'static str, @@ -107,6 +109,11 @@ pub struct Face { charmap: HashMap, outlines: Mutex>>, advances: Mutex>, + /// Colour glyph tables (see [`crate::color`]): bitmap strikes and/or COLR layers. + pub(crate) bitmap_strikes: bool, + pub(crate) colr: bool, + pub(crate) rasters: Mutex, + pub(crate) layers: Mutex>>>>, } impl std::fmt::Debug for Face { @@ -139,6 +146,9 @@ impl Face { gsub_features.dedup(); let charmap: HashMap = f.charmap().mappings().map(|(c, g)| (c, g.to_u32())).collect(); let (axes, instances) = variation_info(&f); + let has_table = |tag: &[u8; 4]| f.data_for_tag(skrifa::Tag::new(tag)).is_some(); + let bitmap_strikes = has_table(b"sbix") || has_table(b"CBDT") || has_table(b"EBDT"); + let colr = has_table(b"COLR"); let under = m.underline.map(|d| (f64::from(d.offset), f64::from(d.thickness))).unwrap_or((-upem * 0.1, upem * 0.06)); let strike = m.strikeout.map(|d| f64::from(d.offset)).unwrap_or(ascent * 0.3); let weight = { @@ -169,6 +179,10 @@ impl Face { bytes, outlines: Mutex::new(HashMap::new()), advances: Mutex::new(HashMap::new()), + bitmap_strikes, + colr, + rasters: Mutex::new(Rasters::default()), + layers: Mutex::new(HashMap::new()), }) } @@ -613,7 +627,6 @@ impl FontDb { "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", - "Apple Color Emoji", "Noto Sans Arabic", "Apple Symbols", "Segoe UI Symbol", @@ -625,6 +638,19 @@ impl FontDb { out } + /// Colour emoji faces, for characters no other fallback covers. Kept out of + /// [`fallbacks`](Self::fallbacks) because the files are large (Apple Color Emoji is ~190 MB) + /// and only texts that actually show emoji need them; each family reads one face. + pub fn emoji_fallbacks(&mut self) -> Vec> { + let mut out = Vec::new(); + for fam in ["Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"] { + if let Some(f) = self.find_one(fam, "Regular") { + out.push(f); + } + } + out + } + pub fn face_count(&self) -> usize { self.faces.len() } @@ -699,8 +725,10 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap Option> { Some(buf) } -/// (index, family, style) of each outline face (one we can draw) in a font file. +/// (index, family, style) of each face in a font file: outline fonts and colour fonts (Apple Color +/// Emoji has placeholder outlines, Noto Color Emoji has none at all). fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> { let count = match FileRef::new(bytes) { Ok(FileRef::Collection(c)) => c.len(), @@ -753,7 +782,10 @@ fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> { (0..count.min(64)) .filter_map(|i| { let f = skrifa::FontRef::from_index(bytes, i).ok()?; - [b"glyf", b"CFF ", b"CFF2"].iter().any(|t| f.data_for_tag(skrifa::Tag::new(t)).is_some()).then_some(())?; + [b"glyf", b"CFF ", b"CFF2", b"sbix", b"CBDT", b"EBDT", b"COLR"] + .iter() + .any(|t| f.data_for_tag(skrifa::Tag::new(t)).is_some()) + .then_some(())?; let (family, style) = names(&f); let family = family.filter(|f| !f.starts_with('.'))?; Some((i, family, style.unwrap_or_else(|| "Regular".into()))) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 110c75898..1c5308f5e 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -10,6 +10,7 @@ use figcraft_doc::{ use figcraft_geom::{Affine, BezPath, Rect}; use harfrust::{Direction, Feature, ShapeOptions, ShaperInstance, Tag, UnicodeBuffer, Variation}; +use crate::color::{ColorInk, MAX_PX_PER_EM}; use crate::fontdb::{Coords, Face, db, no_coords}; /// A positioned glyph (pen position on the baseline, layout coordinates, y down). @@ -37,11 +38,34 @@ pub struct Glyph { impl Glyph { /// The glyph outline placed in layout coordinates. pub fn path(&self) -> BezPath { + self.layer_path(self.gid) + } + + /// The outline of one of the face's glyphs (a COLR layer), placed like this glyph. + pub fn layer_path(&self, gid: u32) -> BezPath { let s = self.size / self.face.upem; - let mut p = (*self.face.outline_at(self.gid, &self.coords)).clone(); + let mut p = (*self.face.outline_at(gid, &self.coords)).clone(); p.apply_affine(self.warp * Affine::new([s, 0.0, 0.0, s, self.x, self.y])); p } + + /// The glyph's colour ink at `scale` device pixels per layout unit, placed in layout + /// coordinates (a bitmap's pixels map through the affine; COLR layers are drawn like the + /// outline). `None` — the usual case — means the outline is what draws. + pub fn color(&self, scale: f64) -> Option { + if !scale.is_finite() || scale <= 0.0 { + return None; + } + if let Some(layers) = self.face.color_layers(self.gid) { + return Some(ColorInk::Layers(layers)); + } + // Whole device pixels per em: rasters stay on the pixel grid and the cache stays small. + let px = (self.size * scale).round().clamp(1.0, MAX_PX_PER_EM); + let bitmap = self.face.color_bitmap(self.gid, &self.coords, px)?; + let s = px / self.size; + let place = self.warp * Affine::translate((self.x + bitmap.left / s, self.y + bitmap.top / s)) * Affine::scale(1.0 / s); + Some(ColorInk::Bitmap(bitmap, place)) + } } #[derive(Clone, Debug)] @@ -299,11 +323,17 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) span_style.push(idx); } let mut dbw = db().write().unwrap_or_else(std::sync::PoisonError::into_inner); - let fallbacks = dbw.fallbacks(); + let mut fallbacks = dbw.fallbacks(); let mut faces: Vec>> = Vec::with_capacity(styles.len()); for s in &styles { faces.push(dbw.find(&s.font_family, &s.font_style).or_else(|| dbw.default_face())); } + // Colour emoji are their own, large faces (Apple Color Emoji is ~190 MB): they are read only + // for a text whose characters nothing above covers. + let covered = |c: char| c == '\n' || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c)); + if chars.iter().any(|c| !covered(*c)) { + fallbacks.extend(dbw.emoji_fallbacks()); + } drop(dbw); // OpenType features the user toggled (global for the node). diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 101d8bc55..27456d824 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -7,12 +7,14 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod breaks; +pub mod color; pub mod display; pub mod fontdb; mod layout; mod onpath; pub use breaks::grapheme_step; +pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c34d59169..ac7fc428e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -103,7 +103,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 | | 8 | Frames, groups, sections, masks | 5 | 97% | 80% | Visual QA with real files (a one-step frame/group convert, and a behaviour suite from the documentation landed: masked-out areas don't take clicks, mask group bounds and exports follow the mask, sections kept out of frames and groups, frames and sections drawn around layers take them, section resizing leaves contents in place, X/Y measured in the frame) | 1–3 | | 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around, cell alignment and wrapped-line alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 | -| 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 | +| 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed; colour emoji draw from the system colour fonts — bitmap strikes (sbix, CBDT/EBDT) or COLR v0 layers — where they used to draw the placeholder outlines, COLR v1 still falling back to outlines) | 3–7 | | 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean and text properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 | | 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 | | 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 | @@ -153,7 +153,7 @@ graphic or a simple mockup. Same method, with the core features a casual user to | Select, move, resize, rotate, align, group, duplicate | 15 | 75% | not yet compared tool by tool | | Frames with device presets | 8 | 80% | | | Rectangle, ellipse, line, arrow, corner radius | 8 | 80% | | -| Text: type, font, size, weight, colour, alignment | 12 | 70% | no spell check | +| Text: type, font, size, weight, colour, alignment | 12 | 70% | no spell check; colour emoji from COLR v0 layers or bitmap strikes only (COLR v1 fonts fall back to their outlines) | | Colour picker, fills and strokes; place and crop images | 10 | 72% | no HEIC images | | Layers panel: rename, hide, lock, reorder | 7 | 80% | | | Basic auto layout (add, gap, padding) | 6 | 70% | | @@ -628,6 +628,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Colour emoji drawn (#268): a glyph with colour ink draws it instead of its outline — an embedded bitmap strike (sbix, CBDT/EBDT) decoded to premultiplied RGBA and placed the way Skia places it, or a COLR v0 glyph's layers filled from the palette; faces that carry only colour tables (Apple Color Emoji, Noto Color Emoji) are indexed again and "Segoe UI Emoji" and "Noto Color Emoji" join "Apple Color Emoji" in the fallbacks. Left: COLR v1 paint graphs, alpha-only strikes, the SVG and PDF exports. See [ui-parity](ui-parity.md); numbers unchanged | | 2026-10-13 | minor | Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, spacing by octave folding fitted to measurements at several zoom levels, fixed to the shape); click, Shift-click and drag select and move areas with their points (drawn unselected); a click on an empty spot clears them and stays in edit mode (see [ui-parity](ui-parity.md)) | | 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. | | 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6de538e7a..4713d910a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -80,7 +80,7 @@ interaction feel not yet compared. | Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: the transform box around points of several layers; areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | | -| Text: point, auto height, fixed; caret, words, lines, IME | done | carets step over whole emoji and accented letters; CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | +| Text: point, auto height, fixed; caret, words, lines, IME | done | carets step over whole emoji and accented letters; CJK words by script run (no dictionary); vertical trim in Type settings; no spell check; colour emoji draw from the system colour fonts (bitmap strikes or COLR v0 layers; COLR v1 falls back to outlines) | | Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`) | | Comment (C), Shift+C to show/hide | done | local only | | Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only | @@ -164,6 +164,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Colour emoji draw from the system colour fonts (#268): a glyph with colour ink (an sbix, CBDT or EBDT bitmap strike, or COLR v0 layers) draws that instead of its outline, scaled to the size the run asks for; COLR v1 fonts still draw their outlines | | 2026-10-14 | minor | Home screen recent files as cards with thumbnails on their page colour, a sortable table view, selection, keyboard and a context menu | | 2026-10-13 | minor | Vector edit mode regions: with the Move tool the filled area under the pointer is hatched with "/" lines in the selection colour (about 1.4 px), clipped by the area's own fill rule so holes stay clear, and the hatch goes when the pointer is 3.5 px from a point or 2 px from a segment (screen pixels at any zoom); the spacing follows octave folding, fitted to measurements at several zoom levels (8 px at every power-of-two zoom, the gap growing with the zoom and stepping back at √2 · 2ⁿ), anchored to the vector so it doesn't slide when panning, one continuous pattern across selected areas; a click selects an area with its points (they move with it but keep the unselected look), Shift adds an area or takes a selected one out, a press on a selected area drags everything selected, drags show no hover hatch, a click on an empty spot (such as a hole) clears the areas and stays in edit mode, the last area listed wins where areas overlap, only filled vectors have areas; `vector.edit.select {regions}` and `vector.edit.state` report them (`regions_tests`, `vector_regions_tests`) | | 2026-10-13 | minor | Home screen: a Templates tab (eight starter templates as cards with thumbnails, search and a kind filter; click or Enter opens a new document) replaces the Community placeholder, the Drafts placeholder is removed, Recents offers four templates while there are no recent files and shows a Getting started card (closable; Help ▸ Getting started brings it back), File ▸ New from template…, and a first run opens on the home screen | From 8b8825ce681837a3166536bc4c0ac55ca49f1d06 Mon Sep 17 00:00:00 2001 From: Barzabel Date: Wed, 14 Oct 2026 17:37:59 +0300 Subject: [PATCH 030/120] M1: say why when the window can't open (#261) Release builds on Windows have no console, so an eframe::run_native error (no usable graphics adapter, a device the driver refuses) went to an unseen standard error and the app quit silently. The error is now logged and shown in a message box with a hint: unset WGPU_BACKEND when it is set, otherwise update the driver or try another graphics API (OpenGL on Windows), and where to report it. With --control the box is skipped so agents aren't blocked. --- apps/figcraft/src/main.rs | 10 ++++++- apps/figcraft/src/startup.rs | 44 ++++++++++++++++++++++++++++++ apps/figcraft/src/startup_tests.rs | 35 ++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 apps/figcraft/src/startup.rs create mode 100644 apps/figcraft/src/startup_tests.rs diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 0332f7d83..4533f0bf1 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -15,6 +15,7 @@ mod gamepad; mod graphics; mod recovery; mod services; +mod startup; #[cfg(test)] mod args_tests; @@ -26,6 +27,8 @@ mod recovery_online_tests; mod recovery_tests; #[cfg(test)] mod services_tests; +#[cfg(test)] +mod startup_tests; use std::sync::{Arc, Mutex}; @@ -188,5 +191,10 @@ fn main() -> eframe::Result { let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() }; graphics::configure(&mut options.wgpu_options); figcraft_ui_egui::windows::install(fig); - eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery })))) + let result = eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery })))); + // Say why instead of quitting silently: release builds on Windows have no console. + if let Err(e) = &result { + startup::failed(e, port.is_some()); + } + result } diff --git a/apps/figcraft/src/startup.rs b/apps/figcraft/src/startup.rs new file mode 100644 index 000000000..8a50d7a5a --- /dev/null +++ b/apps/figcraft/src/startup.rs @@ -0,0 +1,44 @@ +//! When the window can't open. +//! +//! Release builds on Windows have no console, so an error from `eframe::run_native` (no usable +//! graphics adapter, a surface or device the driver refuses) went to a standard error nobody sees +//! and the app quit without a word (#261). Now it is logged and shown in a message box. + +/// Where a report goes. +const ISSUES: &str = "https://github.com/storytold/figcraft/issues"; + +/// The text shown when the window couldn't open with `error`. `backend_set`: `WGPU_BACKEND` named +/// the graphics API, so the hint is to unset it rather than to try another one. +pub fn message(error: &str, backend_set: bool) -> String { + let hint = if backend_set { + "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." + } else if cfg!(windows) { + // Direct3D 12 and OpenGL are both offered (`graphics.rs`), but wgpu keeps the Direct3D 12 + // adapter it picked even when its device then fails, so naming OpenGL alone can still help. + "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:\n\nset WGPU_BACKEND=gl\nfigcraft.exe" + } else { + "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." + }; + format!("FigCraft couldn't open its window: {error}\n\n{hint}\n\nIf it keeps happening, please report it at {ISSUES} with this message.") +} + +/// Log the failure and, unless `quiet` (started for an agent with `--control`: a modal box would +/// keep the caller waiting, and it reads standard error), show it in a message box. +pub fn failed(error: &eframe::Error, quiet: bool) { + let text = message(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some()); + // Standard error even when RUST_LOG turned errors off. + if log::log_enabled!(log::Level::Error) { + log::error!("{text}"); + } else { + eprintln!("figcraft: {text}"); + } + if quiet { + return; + } + let _ = rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title("FigCraft") + .set_description(text) + .set_buttons(rfd::MessageButtons::Ok) + .show(); +} diff --git a/apps/figcraft/src/startup_tests.rs b/apps/figcraft/src/startup_tests.rs new file mode 100644 index 000000000..6818ae777 --- /dev/null +++ b/apps/figcraft/src/startup_tests.rs @@ -0,0 +1,35 @@ +//! The message shown when the window can't open: it carries the error, a next step and where to +//! report it, instead of the app quitting silently (#261). + +use crate::startup::message; + +const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}"; + +#[test] +fn the_message_carries_the_error_and_where_to_report_it() { + let text = message(ERROR, false); + assert!(text.contains(ERROR), "the error itself: {text}"); + assert!(text.contains("https://github.com/storytold/figcraft/issues"), "where to report it: {text}"); +} + +#[test] +fn without_wgpu_backend_it_suggests_the_driver_and_another_graphics_api() { + let text = message(ERROR, false); + assert!(text.contains("graphics driver"), "{text}"); + assert!(text.contains("WGPU_BACKEND"), "another graphics API to try: {text}"); + assert!(!text.contains("unsetting"), "{text}"); +} + +#[cfg(windows)] +#[test] +fn on_windows_it_shows_how_to_start_with_opengl() { + let text = message(ERROR, false); + assert!(text.contains("set WGPU_BACKEND=gl"), "a release build has no console to look this up from: {text}"); +} + +#[test] +fn with_wgpu_backend_set_it_suggests_unsetting_it() { + let text = message(ERROR, true); + assert!(text.contains("unsetting"), "{text}"); + assert!(!text.contains("graphics driver"), "the chosen backend is the likelier cause: {text}"); +} From de5ba60bc47fdce0149a24e6ddfb60c33ebd33b1 Mon Sep 17 00:00:00 2001 From: fede9234 Date: Wed, 14 Oct 2026 16:44:20 +0200 Subject: [PATCH 031/120] Text: colour-glyph comments cite the right source and size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The placement formula is Skia's own fontations port (src/ports/fontations/src/bitmap.rs) reading the sbix spec's lower-left-corner rule; the earlier comment named CoreText, which that source does not mention. Apple Color Emoji measures ~180 MB on current macOS (the 190 MB figure is a hosted copy of the file), so the comments say ~180 MB. No behaviour change: comments only. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- crates/text/src/color.rs | 7 ++++--- crates/text/src/fontdb.rs | 4 ++-- crates/text/src/layout.rs | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/text/src/color.rs b/crates/text/src/color.rs index 101dabc97..855092141 100644 --- a/crates/text/src/color.rs +++ b/crates/text/src/color.rs @@ -136,9 +136,10 @@ impl Face { made } - /// Read a strike bitmap and place it. The formula is the one Skia (and CoreText behind it) - /// uses: bearings are in font units and scale with the em, the origin offsets and the image - /// with the strike's ppem, and an sbix image is measured from its lower-left corner. + /// Read a strike bitmap and place it the way Skia's fontations port does + /// (`src/ports/fontations/src/bitmap.rs`): the outer bearings are in font units and scale with + /// the em, the inner ones are pixels at the strike's ppem, and an sbix image is measured from + /// its lower-left corner (the spec places the graphic's origin at the glyph box's xMin, yMin). fn read_bitmap(&self, gid: u32, size: f64) -> Option { let gid = GlyphId::new(gid); let font = FontRef::from_index(self.bytes.data(), self.index).ok()?; diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index a83fa0fe5..24e6495bc 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -639,7 +639,7 @@ impl FontDb { } /// Colour emoji faces, for characters no other fallback covers. Kept out of - /// [`fallbacks`](Self::fallbacks) because the files are large (Apple Color Emoji is ~190 MB) + /// [`fallbacks`](Self::fallbacks) because the files are large (Apple Color Emoji is ~180 MB) /// and only texts that actually show emoji need them; each family reads one face. pub fn emoji_fallbacks(&mut self) -> Vec> { let mut out = Vec::new(); @@ -725,7 +725,7 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap, box_height: Option) for s in &styles { faces.push(dbw.find(&s.font_family, &s.font_style).or_else(|| dbw.default_face())); } - // Colour emoji are their own, large faces (Apple Color Emoji is ~190 MB): they are read only + // Colour emoji are their own, large faces (Apple Color Emoji is ~180 MB): they are read only // for a text whose characters nothing above covers. let covered = |c: char| c == '\n' || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c)); if chars.iter().any(|c| !covered(*c)) { From 00ac44f6caf4dd8f6081aa96927c507081c35ba1 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Wed, 14 Oct 2026 16:48:56 +0200 Subject: [PATCH 032/120] M1: save files automatically Preferences > Save files automatically (pref.autoSave, off by default; hidden in the browser, which has no files to save to). With it on, each open file that has a path is saved 3 s after it changes, never during a gesture, a live text preview or while a version is shown. A failed save says why in a toast and tries again a minute later. Version history checkpoints (version.autosave, every 30 minutes) only ran for files with unsaved changes, so a file that saves itself would stop getting them; with auto-save on, edits made since the file was opened count. Closes #296 --- ROADMAP.md | 4 ++- crates/engine/src/autosave_tests.rs | 19 +++++++++++++ crates/engine/src/cmd/file.rs | 7 +++-- crates/engine/src/cmd/view.rs | 1 + crates/engine/src/lib.rs | 4 +++ crates/ui-egui/locales/de.toml | 3 +++ crates/ui-egui/locales/en.toml | 2 ++ crates/ui-egui/locales/es.toml | 3 +++ crates/ui-egui/locales/fr.toml | 3 +++ crates/ui-egui/locales/ja.toml | 3 +++ crates/ui-egui/locales/ko.toml | 3 +++ crates/ui-egui/locales/pt-BR.toml | 3 +++ crates/ui-egui/locales/zh-Hans.toml | 3 +++ crates/ui-egui/src/dialogs.rs | 4 +++ crates/ui-egui/src/idle_tests.rs | 23 ++++++++++++++++ crates/ui-egui/src/lib.rs | 42 +++++++++++++++++++++++++++++ docs/mcp.md | 2 ++ docs/roadmap.md | 3 ++- 18 files changed, 128 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29b..f19a72e4d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (auto-save (#296); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-14** · Auto-save (#296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only) saves each open file that has a path 3 s after it changes, never during a gesture, a live preview or while a version is shown; a failed save says why and tries again a minute later. Version history checkpoints keep coming every 30 minutes for files that save themselves. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. - **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged. @@ -312,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Auto-save (#296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only) saves each open file that has a path 3 s after it changes, never during a gesture, a live preview or while a version is shown; a failed save says why and tries again a minute later. Version history checkpoints keep coming every 30 minutes for files that save themselves. Numbers unchanged | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/crates/engine/src/autosave_tests.rs b/crates/engine/src/autosave_tests.rs index a02c661c1..e9f0427d7 100644 --- a/crates/engine/src/autosave_tests.rs +++ b/crates/engine/src/autosave_tests.rs @@ -73,3 +73,22 @@ fn autosaves_keep_their_flag_in_the_file() { assert!(!autosave(&mut t)); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn files_that_save_themselves_still_get_checkpoints() { + let dir = std::env::temp_dir().join(format!("figcraft-autosave-on-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("auto.figcraft").to_string_lossy().to_string(); + let mut s = Session::new(); + s.execute("pref.autoSave", &json!({"on": true})).unwrap(); + rect(&mut s, 0.0); + s.execute("file.save", &json!({"path": path})).unwrap(); + assert!(autosave(&mut s), "saved, but changed since the last version"); + assert!(!autosave(&mut s), "no change since the checkpoint"); + // Just opened: nothing to checkpoint. + let mut t = Session::empty(); + t.execute("pref.autoSave", &json!({"on": true})).unwrap(); + t.execute("file.open", &json!({"path": path})).unwrap(); + assert!(!autosave(&mut t)); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 781ed6b07..1b6fe49c0 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -195,7 +195,7 @@ pub fn specs() -> Vec { spec!( "version.autosave", "Autosave version", - "{} save an automatic checkpoint when the document has unsaved changes since the last version (the app does this every 30 minutes); keeps the newest 20 autosaves → {saved, version?}", + "{} save an automatic checkpoint when the document has unsaved changes since the last version (with `pref.autoSave` on: changes made since it was opened); the app does this every 30 minutes; keeps the newest 20 autosaves → {saved, version?}", false, autosave ), @@ -257,8 +257,11 @@ pub const MAX_AUTOSAVES: usize = 20; /// `version.autosave`: a checkpoint of a document with unsaved changes since the last version. fn autosave(s: &mut Session, _: &Value) -> Result { + let auto_save = s.prefs.auto_save; let ds = s.ds_mut()?; - if ds.preview.is_some() || !ds.is_dirty() || !ds.changed_since_checkpoint() { + // A file that saves itself is rarely unsaved: edits since it was opened count then. + let edited = ds.is_dirty() || (auto_save && !ds.history.undo.is_empty()); + if ds.preview.is_some() || !edited || !ds.changed_since_checkpoint() { return Ok(json!({"saved": false})); } Ok(match add_version(ds, AUTOSAVE, "", true) { diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 6135e5dfe..9c1371cc1 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -334,6 +334,7 @@ pub fn specs() -> Vec { spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)), spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)), spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)), + spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)), spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| { s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0); s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a906..9ee0c372e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -564,6 +564,9 @@ pub struct Prefs { /// Reduce motion: the interface's own animations are off (the system's setting turns them off /// too). Prototype transitions still play. pub reduce_motion: bool, + /// Files that have a path are saved by themselves a few seconds after they change (the app's + /// timer; off: only File ▸ Save writes them). + pub auto_save: bool, /// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed /// builds, off for development builds. pub invite_links: Option, @@ -616,6 +619,7 @@ impl Default for Prefs { language: "system".into(), user_name: cmd::comment::DEFAULT_AUTHOR.into(), reduce_motion: false, + auto_save: false, invite_links: None, plugins: Vec::new(), plugin_settings: BTreeMap::new(), diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 7552cca36..aad1d6431 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -301,6 +301,7 @@ status = "draft — needs native review" "Corner radius" = "Eckenradius" "Corner smoothing" = "Eckenglättung" "Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat." +"Couldn't save automatically: {error}" = "Automatisches Speichern fehlgeschlagen: {error}" "Count" = "Anzahl" "Create" = "Erstellen" "Create a variable collection first" = "Erstellen Sie zuerst eine Variablensammlung" @@ -1070,6 +1071,7 @@ status = "draft — needs native review" "Save" = "Speichern" "Save as" = "Speichern unter" "Save as preset…" = "Als Vorgabe speichern…" +"Save files automatically" = "Dateien automatisch speichern" "Save version {key}" = "Version speichern {key}" "Saved" = "Gespeichert" "Saved {name}" = "{name} gespeichert" @@ -1784,6 +1786,7 @@ status = "draft — needs native review" "plugin.ui" = "Plugin-Bereich" "plugins.manage" = "Plugins verwalten…" "plugins.runLast" = "Letztes Plugin ausführen" +"pref.autoSave" = "Dateien automatisch speichern" "pref.highlightHover" = "Ebenen beim Überfahren hervorheben" "pref.keepTool" = "Werkzeug nach Verwendung ausgewählt lassen" "pref.language" = "Sprache der Benutzeroberfläche" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index c35b16776..550268f73 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -310,6 +310,7 @@ name = "English" "Corner radius" = "Corner radius" "Corner smoothing" = "Corner smoothing" "Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers." +"Couldn't save automatically: {error}" = "Couldn't save automatically: {error}" "Count" = "Count" "Create" = "Create" "Create a variable collection first" = "Create a variable collection first" @@ -1112,6 +1113,7 @@ name = "English" "Rulers" = "Rulers" "Run" = "Run" "Run {label}" = "Run {label}" +"Save files automatically" = "Save files automatically" "Search templates" = "Search templates" "See all templates" = "See all templates" "Select layers and they space themselves as their content changes" = "Select layers and they space themselves as their content changes" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 6514ac7be..fa5e5ff25 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -311,6 +311,7 @@ status = "draft — needs native review" "Corner radius" = "Radio de esquina" "Corner smoothing" = "Suavizado de esquinas" "Couldn't reach {server}: showing what this computer remembers." = "No se pudo contactar con {server}: se muestra lo que este ordenador recuerda." +"Couldn't save automatically: {error}" = "No se pudo guardar automáticamente: {error}" "Count" = "Número" "Create" = "Crear" "Create a variable collection first" = "Crea primero una colección de variables" @@ -1113,6 +1114,7 @@ status = "draft — needs native review" "Rulers" = "Reglas" "Run" = "Ejecutar" "Run {label}" = "Ejecutar {label}" +"Save files automatically" = "Guardar archivos automáticamente" "Search templates" = "Buscar plantillas" "See all templates" = "Ver todas las plantillas" "Select layers and they space themselves as their content changes" = "Selecciona capas y se espaciarán solas cuando cambie su contenido" @@ -1865,6 +1867,7 @@ status = "draft — needs native review" "plugin.ui" = "Panel del plugin" "plugins.manage" = "Gestionar plugins…" "plugins.runLast" = "Ejecutar el último plugin" +"pref.autoSave" = "Guardar archivos automáticamente" "pref.highlightHover" = "Resaltar capas al pasar el cursor" "pref.keepTool" = "Mantener la herramienta tras usarla" "pref.language" = "Idioma de la interfaz" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index c86659345..e3022acb7 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -301,6 +301,7 @@ status = "draft — needs native review" "Corner radius" = "Rayon d’angle" "Corner smoothing" = "Lissage des angles" "Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient." +"Couldn't save automatically: {error}" = "Impossible d’enregistrer automatiquement : {error}" "Count" = "Nombre" "Create" = "Créer" "Create a variable collection first" = "Créez d’abord une collection de variables" @@ -1070,6 +1071,7 @@ status = "draft — needs native review" "Save" = "Enregistrer" "Save as" = "Enregistrer sous" "Save as preset…" = "Enregistrer comme préréglage…" +"Save files automatically" = "Enregistrer les fichiers automatiquement" "Save version {key}" = "Enregistrer une version {key}" "Saved" = "Enregistré" "Saved {name}" = "{name} enregistré" @@ -1784,6 +1786,7 @@ status = "draft — needs native review" "plugin.ui" = "Panneau du plugin" "plugins.manage" = "Gérer les plugins…" "plugins.runLast" = "Exécuter le dernier plugin" +"pref.autoSave" = "Enregistrer les fichiers automatiquement" "pref.highlightHover" = "Mettre en évidence les calques au survol" "pref.keepTool" = "Garder l’outil sélectionné après usage" "pref.language" = "Langue de l’interface" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index f7b9cf9fd..36bdfd43b 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -303,6 +303,7 @@ status = "draft — needs native review" "Corner radius" = "角の半径" "Corner smoothing" = "角のスムージング" "Couldn't reach {server}: showing what this computer remembers." = "{server} に接続できない:このコンピューターが覚えている内容を表示中。" +"Couldn't save automatically: {error}" = "自動保存できませんでした:{error}" "Count" = "数" "Create" = "作成" "Create a variable collection first" = "先に変数コレクションを作成" @@ -1072,6 +1073,7 @@ status = "draft — needs native review" "Save" = "保存" "Save as" = "名前を付けて保存" "Save as preset…" = "プリセットとして保存…" +"Save files automatically" = "ファイルを自動保存" "Save version {key}" = "バージョンを保存 {key}" "Saved" = "保存済み" "Saved {name}" = "{name} を保存済み" @@ -1786,6 +1788,7 @@ status = "draft — needs native review" "plugin.ui" = "プラグインパネル" "plugins.manage" = "プラグインを管理…" "plugins.runLast" = "最後のプラグインを実行" +"pref.autoSave" = "ファイルを自動保存" "pref.highlightHover" = "ホバー時にレイヤーをハイライト" "pref.keepTool" = "使用後もツールを選択したままにする" "pref.language" = "インターフェースの言語" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 59bb23868..c81420b30 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -302,6 +302,7 @@ status = "draft — needs native review" "Corner radius" = "모서리 반경" "Corner smoothing" = "모서리 스무딩" "Couldn't reach {server}: showing what this computer remembers." = "{server}에 연결할 수 없음: 이 컴퓨터가 기억하는 내용을 표시함." +"Couldn't save automatically: {error}" = "자동으로 저장할 수 없음: {error}" "Count" = "개수" "Create" = "만들기" "Create a variable collection first" = "먼저 변수 컬렉션을 만드세요" @@ -1071,6 +1072,7 @@ status = "draft — needs native review" "Save" = "저장" "Save as" = "다른 이름으로 저장" "Save as preset…" = "프리셋으로 저장…" +"Save files automatically" = "파일 자동 저장" "Save version {key}" = "버전 저장 {key}" "Saved" = "저장됨" "Saved {name}" = "{name} 저장됨" @@ -1785,6 +1787,7 @@ status = "draft — needs native review" "plugin.ui" = "플러그인 패널" "plugins.manage" = "플러그인 관리…" "plugins.runLast" = "마지막 플러그인 실행" +"pref.autoSave" = "파일 자동 저장" "pref.highlightHover" = "마우스를 올리면 레이어 강조" "pref.keepTool" = "사용 후에도 도구 선택 유지" "pref.language" = "인터페이스 언어" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index a692a8cee..ea0904d79 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -300,6 +300,7 @@ status = "draft — needs native review" "Corner radius" = "Raio do canto" "Corner smoothing" = "Suavização de cantos" "Couldn't reach {server}: showing what this computer remembers." = "Não foi possível alcançar {server}: mostrando o que este computador lembra." +"Couldn't save automatically: {error}" = "Não foi possível salvar automaticamente: {error}" "Count" = "Quantidade" "Create" = "Criar" "Create a variable collection first" = "Crie primeiro uma coleção de variáveis" @@ -1069,6 +1070,7 @@ status = "draft — needs native review" "Save" = "Salvar" "Save as" = "Salvar como" "Save as preset…" = "Salvar como predefinição…" +"Save files automatically" = "Salvar arquivos automaticamente" "Save version {key}" = "Salvar versão {key}" "Saved" = "Salvo" "Saved {name}" = "{name} salvo" @@ -1783,6 +1785,7 @@ status = "draft — needs native review" "plugin.ui" = "Painel do plugin" "plugins.manage" = "Gerenciar plugins…" "plugins.runLast" = "Executar o último plugin" +"pref.autoSave" = "Salvar arquivos automaticamente" "pref.highlightHover" = "Destacar camadas ao passar o cursor" "pref.keepTool" = "Manter a ferramenta selecionada após o uso" "pref.language" = "Idioma da interface" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index d2431df09..08246546d 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -303,6 +303,7 @@ status = "draft — needs native review" "Corner radius" = "圆角半径" "Corner smoothing" = "圆角平滑" "Couldn't reach {server}: showing what this computer remembers." = "无法连接 {server}:显示的是这台电脑记录的内容。" +"Couldn't save automatically: {error}" = "无法自动保存:{error}" "Count" = "数量" "Create" = "创建" "Create a variable collection first" = "请先创建变量集合" @@ -1072,6 +1073,7 @@ status = "draft — needs native review" "Save" = "保存" "Save as" = "另存为" "Save as preset…" = "存储为预设…" +"Save files automatically" = "自动保存文件" "Save version {key}" = "保存版本 {key}" "Saved" = "已保存" "Saved {name}" = "已保存 {name}" @@ -1786,6 +1788,7 @@ status = "draft — needs native review" "plugin.ui" = "插件面板" "plugins.manage" = "管理插件…" "plugins.runLast" = "运行上一个插件" +"pref.autoSave" = "自动保存文件" "pref.highlightHover" = "悬停时高亮图层" "pref.keepTool" = "使用后保持工具选中" "pref.language" = "界面语言" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 221ee6361..0b712ccb7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -163,6 +163,10 @@ pub fn show(app: &mut FigApp, ctx: &Context) { if app.services.reduce_motion() { crate::widgets::label(ui, tr("Your system asks for reduced motion: animations stay off."), t.text_secondary); } + // The browser has no files to save to. + if !cfg!(target_arch = "wasm32") && crate::widgets::checkbox(ui, prefs.auto_save, tr("Save files automatically")) { + toggles.push("pref.autoSave"); + } for c in toggles { app.run(c, json!({})); } diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 2d4e57579..33a9086ce 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -288,3 +288,26 @@ fn an_animated_gif_wakes_only_the_presenter_and_only_at_its_frames() { h.app.ui.present = None; h.assert_idle("the presenter closed"); } + +#[test] +fn auto_save_writes_a_changed_file_then_rests() { + let dir = std::env::temp_dir().join(format!("figcraft-auto-save-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("auto.figcraft").to_string_lossy().to_string(); + let (mut h, _) = document(); + h.run("file.save", json!({"path": path})); + h.run("pref.autoSave", json!({"on": true})); + h.assert_idle("a saved file"); + h.run("shape.rectangle", json!({"x": 500, "y": 300, "width": 40, "height": 40})); + assert!(h.frame(vec![]) <= Duration::from_secs_f64(crate::AUTO_SAVE_SECS), "wakes up to save it"); + assert!(h.app.session.ds().unwrap().is_dirty(), "not saved at once"); + h.time += crate::AUTO_SAVE_SECS; + h.frame(vec![]); + assert!(!h.app.session.ds().unwrap().is_dirty(), "saved"); + let mut t = Session::empty(); + t.execute("file.open", &json!({"path": path})).unwrap(); + let ds = t.ds().unwrap(); + assert!(ds.doc.page_content_bounds(ds.page).unwrap().x1 >= 540.0, "the new rectangle is in the file"); + h.assert_idle("an auto-saved file"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f98..6ba2d5da7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -241,6 +241,11 @@ pub enum TitleBar { /// How often open documents get an autosave checkpoint (seconds). pub const AUTOSAVE_SECS: f64 = 30.0 * 60.0; +/// With auto-save on: how long after a file changes it is saved, and how long a failed save waits +/// before trying again (seconds). +pub const AUTO_SAVE_SECS: f64 = 3.0; +const AUTO_SAVE_RETRY_SECS: f64 = 60.0; + /// How long a toast shows (seconds). const TOAST_SECS: f64 = 3.0; @@ -376,6 +381,8 @@ pub struct UiState { pub last_copy: Option, /// When (UI clock, seconds) open documents next get an autosave checkpoint. pub next_autosave: f64, + /// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved. + pub auto_save_at: Option, /// The Assets panel shows the libraries view (publish this file, enabled libraries). pub libraries: bool, /// Keyboard navigation: the region with the keyboard, keyboard mode (F6, Tab). @@ -460,6 +467,7 @@ impl Default for UiState { last_copy: None, next_autosave: 0.0, + auto_save_at: None, libraries: false, nav: a11y::Nav::default(), plugins_window: false, @@ -921,6 +929,39 @@ impl FigApp { repaint_in(ctx, AUTOSAVE_SECS); } + /// With auto-save on, files that have a path are saved [`AUTO_SAVE_SECS`] after they change + /// (not during a gesture, a live preview or while a version is shown). + fn auto_save_tick(&mut self, ctx: &egui::Context) { + let s = &self.session; + let unsaved = |d: &figcraft_engine::DocState| d.path.is_some() && d.branch_of.is_none() && d.preview.is_none() && d.is_dirty(); + let due: Vec = s.docs.iter().enumerate().filter(|(_, d)| unsaved(d)).map(|(i, _)| i).collect(); + if !s.prefs.auto_save || due.is_empty() { + self.ui.auto_save_at = None; + return; + } + let now = ctx.input(|i| i.time); + let at = *self.ui.auto_save_at.get_or_insert(now + AUTO_SAVE_SECS); + if now < at || s.docs.iter().any(|d| d.interaction.is_some() || d.text_preview.is_some()) { + // Due but busy: look again in a second. + repaint_in(ctx, if now < at { at - now } else { 1.0 }); + return; + } + let active = self.session.active; + let mut failed = None; + for i in due { + self.session.active = i; + if let Err(e) = self.session.execute("file.save", &json!({})) { + failed.get_or_insert(e.to_string()); + } + } + self.session.active = active; + if let Some(e) = failed { + self.ui.auto_save_at = Some(now + AUTO_SAVE_RETRY_SECS); + repaint_in(ctx, AUTO_SAVE_RETRY_SECS); + self.toast(i18n::trf("Couldn't save automatically: {error}", &[("error", &e)])); + } + } + /// Draw the whole UI: the main window (the other windows draw themselves). pub fn ui(&mut self, ui: &mut egui::Ui) { #[cfg(not(target_arch = "wasm32"))] @@ -930,6 +971,7 @@ impl FigApp { let first = !self.fonts_installed; self.logic(&ctx); self.autosave_tick(&ctx); + self.auto_save_tick(&ctx); if first { // New fonts apply from the next frame; draw nothing until then. ctx.request_repaint(); diff --git a/docs/mcp.md b/docs/mcp.md index 2b66657c2..352e96e0f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -349,6 +349,8 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: nested layers), Escape or a click drops it. - `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's accessibility setting turns it on too). Prototype transitions still play. +- `pref.autoSave {on?}`: files that have a path save themselves a few seconds after they change + (off by default; the desktop app's timer does it, so scripts still call `file.save`). - `pref.language {language: system|en|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system": the OS or browser language; a language without a catalog shows English). Command ids, labels and every result stay English. diff --git a/docs/roadmap.md b/docs/roadmap.md index 8e2780d68..e31aa387e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (auto-save (#296); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Auto-save (#296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only) saves each open file that has a path 3 s after it changes, never during a gesture, a live preview or while a version is shown; a failed save says why and tries again a minute later. Version history checkpoints keep coming every 30 minutes for files that save themselves. Numbers unchanged | | 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | From 3917c26802145f57a19ba56ea57d6aaaa247b00f Mon Sep 17 00:00:00 2001 From: fede9234 Date: Wed, 14 Oct 2026 16:55:07 +0200 Subject: [PATCH 033/120] Text: colour-glyph comments say only what the fonts are documented to carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Placeholder rectangles" was a claim about Apple Color Emoji's glyf outlines that no source I can show supports: the font's sbix chapter documents that most emoji are bitmaps while the flags are drawn from outlines, and it is Noto Color Emoji that has no outlines at all. The comments, the ROADMAP row and the parity row now say that instead. No behaviour change: comments and docs only. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- ROADMAP.md | 4 ++-- crates/text/src/color.rs | 9 +++++---- crates/text/src/fontdb.rs | 8 ++++---- docs/target-app-parity.md | 4 ++-- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 29f1f78a0..179d77182 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (colour emoji drawn from the system colour fonts (#268): bitmap strikes (sbix, CBDT/EBDT) and COLR v0 layers draw instead of the placeholder outlines; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (colour emoji drawn from the system colour fonts (#268): bitmap strikes (sbix, CBDT/EBDT) and COLR v0 layers draw instead of the outlines; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -312,7 +312,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-14 | minor | Colour emoji drawn (#268): a glyph that has colour ink draws that instead of its outline — an embedded bitmap strike (sbix, CBDT/EBDT: decoded to premultiplied RGBA, placed by the strike's bearings the way Skia places it and scaled to the size the run asks for) or a COLR v0 glyph's layers filled from the palette; fonts that carry only colour tables (Apple Color Emoji's placeholder outlines, Noto Color Emoji's none at all) are indexed again, "Segoe UI Emoji" and "Noto Color Emoji" join "Apple Color Emoji" and all three are read only for a text that nothing else covers; left for later: COLR v1 paint graphs, alpha-only strikes, emoji in the SVG and PDF exports (text is outlines there). 9 tests (8 in the text crate: placement, PNG and raw-BGRA decoding, scaling, the machine's own colour font; one in the renderer), the font-free ones run everywhere. Numbers unchanged | +| 2026-10-14 | minor | Colour emoji drawn (#268): a glyph that has colour ink draws that instead of its outline — an embedded bitmap strike (sbix, CBDT/EBDT: decoded to premultiplied RGBA, placed by the strike's bearings the way Skia places it and scaled to the size the run asks for) or a COLR v0 glyph's layers filled from the palette; fonts whose emoji are not their outlines (Apple Color Emoji draws most from `sbix`, Noto Color Emoji has no outlines at all) are indexed again, "Segoe UI Emoji" and "Noto Color Emoji" join "Apple Color Emoji" and all three are read only for a text that nothing else covers; left for later: COLR v1 paint graphs, alpha-only strikes, emoji in the SVG and PDF exports (text is outlines there). 9 tests (8 in the text crate: placement, PNG and raw-BGRA decoding, scaling, the machine's own colour font; one in the renderer), the font-free ones run everywhere. Numbers unchanged | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/crates/text/src/color.rs b/crates/text/src/color.rs index 855092141..16e0c7597 100644 --- a/crates/text/src/color.rs +++ b/crates/text/src/color.rs @@ -1,11 +1,12 @@ //! Colour glyphs: embedded bitmap strikes (sbix, CBDT/EBDT) and COLR v0 layers. //! -//! Colour emoji fonts don't draw like other glyphs: their outlines are placeholder rectangles -//! (Apple Color Emoji) or missing altogether (Noto Color Emoji), and the picture itself lives in a -//! bitmap strike or in a stack of coloured layers. This module gives the renderer either a +//! Colour emoji fonts don't draw like other glyphs: the picture itself lives in a bitmap strike +//! (sbix, CBDT/EBDT) or in a stack of coloured layers, not in the glyph's outline — in Apple Color +//! Emoji most emoji are `sbix` bitmaps while the flags are drawn from outlines, and Noto Color +//! Emoji has no outlines at all. This module gives the renderer either a //! premultiplied RGBA raster at the size it asked for, or the layer glyphs and the palette colours //! to fill with its own outlines. Anything else (COLR v1's paint graphs, alpha-only strikes) is -//! left to the outline pipeline, which draws the placeholders as before. +//! left to the outline pipeline, which draws whatever outlines the face has, as before. use std::collections::HashMap; use std::sync::Arc; diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 24e6495bc..525af0374 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -725,9 +725,9 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap Option> { } /// (index, family, style) of each face in a font file: outline fonts and colour fonts (Apple Color -/// Emoji has placeholder outlines, Noto Color Emoji has none at all). +/// Emoji draws most emoji from `sbix`, Noto Color Emoji has no outlines at all). fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> { let count = match FileRef::new(bytes) { Ok(FileRef::Collection(c)) => c.len(), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ac7fc428e..5cf1f8354 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -103,7 +103,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 | | 8 | Frames, groups, sections, masks | 5 | 97% | 80% | Visual QA with real files (a one-step frame/group convert, and a behaviour suite from the documentation landed: masked-out areas don't take clicks, mask group bounds and exports follow the mask, sections kept out of frames and groups, frames and sections drawn around layers take them, section resizing leaves contents in place, X/Y measured in the frame) | 1–3 | | 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around, cell alignment and wrapped-line alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 | -| 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed; colour emoji draw from the system colour fonts — bitmap strikes (sbix, CBDT/EBDT) or COLR v0 layers — where they used to draw the placeholder outlines, COLR v1 still falling back to outlines) | 3–7 | +| 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed; colour emoji draw from the system colour fonts — bitmap strikes (sbix, CBDT/EBDT) or COLR v0 layers — where what drew before was the glyph's outline data, COLR v1 still falling back to outlines) | 3–7 | | 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean and text properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 | | 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 | | 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 | @@ -628,7 +628,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| -| 2026-10-14 | minor | Colour emoji drawn (#268): a glyph with colour ink draws it instead of its outline — an embedded bitmap strike (sbix, CBDT/EBDT) decoded to premultiplied RGBA and placed the way Skia places it, or a COLR v0 glyph's layers filled from the palette; faces that carry only colour tables (Apple Color Emoji, Noto Color Emoji) are indexed again and "Segoe UI Emoji" and "Noto Color Emoji" join "Apple Color Emoji" in the fallbacks. Left: COLR v1 paint graphs, alpha-only strikes, the SVG and PDF exports. See [ui-parity](ui-parity.md); numbers unchanged | +| 2026-10-14 | minor | Colour emoji drawn (#268): a glyph with colour ink draws it instead of its outline — an embedded bitmap strike (sbix, CBDT/EBDT) decoded to premultiplied RGBA and placed the way Skia places it, or a COLR v0 glyph's layers filled from the palette; faces whose glyphs come from colour data (Apple Color Emoji, Noto Color Emoji) are indexed again and "Segoe UI Emoji" and "Noto Color Emoji" join "Apple Color Emoji" in the fallbacks. Left: COLR v1 paint graphs, alpha-only strikes, the SVG and PDF exports. See [ui-parity](ui-parity.md); numbers unchanged | | 2026-10-13 | minor | Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, spacing by octave folding fitted to measurements at several zoom levels, fixed to the shape); click, Shift-click and drag select and move areas with their points (drawn unselected); a click on an empty spot clears them and stays in edit mode (see [ui-parity](ui-parity.md)) | | 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. | | 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. | From a3a273cfd3957c3f3ab94293bd5f0b9adc093538 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 10:58:17 -0400 Subject: [PATCH 034/120] fix(pages): inherit the current page background when adding pages (M1) page.add gave every added page the interface theme's colour. Figma's documentation says added pages inherit the current page's background; the theme only decides a new file's page. Read the active page's colour before creating the new page, in the command, so the app, CLI, control channel and MCP agree. Refs #295 --- ROADMAP.md | 2 + crates/engine/src/cmd/page.rs | 7 ++- crates/engine/src/lib.rs | 6 +-- crates/engine/src/theme_pages_tests.rs | 67 +++++++++++++++++++++++--- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/text_enter_tests.rs | 4 +- docs/mcp.md | 6 +-- 7 files changed, 78 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29b..f26363392 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-14** · Added pages keep the page colour (M1, #295): `page.add` gives the new page the current page's background, as the documentation says, instead of the interface theme's; the theme still decides a new file's page, and saved pages keep their colour when the theme changes. The same in the app, CLI, control channel and MCP. Custom colours, both themes, headless, undo/redo and save/reopen are covered in `theme_pages_tests`. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. - **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged. @@ -312,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Added pages keep the page colour (M1, #295): `page.add` takes the current page's background instead of the theme's; the theme still decides a new file's page. Numbers unchanged | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index b2d46988e..d7f630b90 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -26,7 +26,12 @@ fn settle(s: &mut Session, cmd: &str) -> crate::Result<()> { pub fn specs() -> Vec { vec![ spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| { - let background = s.new_page_background(); + // An added page takes the current page's colour; the theme only decides new files. + let current = s.page().ok().and_then(|p| match s.doc().ok()?.node(p)?.kind { + NodeKind::Page { background, .. } => Some(background), + _ => None, + }); + let background = current.unwrap_or_else(|| s.new_page_background()); let doc = s.doc_mut()?; let name = string(p, "name").unwrap_or_else(|| format!("Page {}", doc.pages.len() + 1)); let id = doc.add_page(&name); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a906..f4a8a5bff 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -663,8 +663,8 @@ pub struct Session { pub prefs: Prefs, pub tool: tools::ToolState, pub ui_requests: Vec, - /// The interface's theme once the UI knows it ([`Session::set_ui_dark`]): new files and pages - /// get the dark page colour while it is dark. Headless sessions (CLI, MCP, exports) have no + /// The interface's theme once the UI knows it ([`Session::set_ui_dark`]): new files get the + /// dark page colour while it is dark (added pages take the current page's). Headless sessions (CLI, MCP, exports) have no /// theme, so what scripts make is the same everywhere. pub ui_dark: Option, /// Command log (id + params) for actions/automation. @@ -721,7 +721,7 @@ impl Session { } } - /// The background new pages get: the dark page colour while the interface is dark. + /// The background a new file's page gets: the dark page colour while the interface is dark. pub fn new_page_background(&self) -> figcraft_color::Color { if self.ui_dark == Some(true) { figcraft_color::Color::DARK_PAGE } else { figcraft_color::Color::DEFAULT_PAGE } } diff --git a/crates/engine/src/theme_pages_tests.rs b/crates/engine/src/theme_pages_tests.rs index e4c1071ae..219f6120a 100644 --- a/crates/engine/src/theme_pages_tests.rs +++ b/crates/engine/src/theme_pages_tests.rs @@ -1,7 +1,7 @@ -//! The page colour of new files and pages follows the interface's theme when they are made (dark: -//! #1E1E1E, light: #F5F5F5) and is the page's own from then on; a session without an interface -//! (CLI, MCP, exports) has no theme and keeps the light default. Text drawn with the Text tool is -//! white on a dark page and black on a light one. +//! The page colour of a new file follows the interface's theme when it is made (dark: #1E1E1E, +//! light: #F5F5F5) and is the page's own from then on; an added page takes the colour of the page +//! it was added from. A session without an interface (CLI, MCP, exports) has no theme and keeps the +//! light default. Text drawn with the Text tool is white on a dark page and black on a light one. use figcraft_color::{Color, Paint}; use figcraft_doc::{NodeId, NodeKind}; @@ -38,8 +38,8 @@ fn draw_text(s: &mut Session, x: f64, y: f64) -> NodeId { } #[test] -fn new_files_and_pages_take_the_theme_they_are_made_in() { - // (theme the interface reported, colour of a new file's page and of an added page) +fn new_files_take_the_theme_they_are_made_in_and_added_pages_follow() { + // (theme the interface reported, colour of a new file's page and of a page added to it) for (dark, want) in [(None, "#F5F5F5"), (Some(false), "#F5F5F5"), (Some(true), "#1E1E1E")] { let mut s = Session::empty(); if let Some(d) = dark { @@ -62,8 +62,10 @@ fn a_change_of_theme_leaves_existing_pages_alone() { ex(&mut s, "file.new", json!({})); s.set_ui_dark(false); ex(&mut s, "page.add", json!({})); + assert_eq!(backgrounds(&s), ["#1E1E1E", "#1E1E1E"], "an added page follows its page, not the theme"); + ex(&mut s, "page.setBackground", json!({"color": "#F5F5F5"})); s.set_ui_dark(true); - assert_eq!(backgrounds(&s), ["#1E1E1E", "#F5F5F5"], "each page keeps the colour it was made with"); + assert_eq!(backgrounds(&s), ["#1E1E1E", "#F5F5F5"], "each page keeps its colour"); // A copy of a page is that page's colour, whatever the theme. let first = s.doc().unwrap().pages[0]; s.set_ui_dark(false); @@ -71,6 +73,57 @@ fn a_change_of_theme_leaves_existing_pages_alone() { assert_eq!(background(&s, copy), Color::DARK_PAGE); } +#[test] +fn an_added_page_takes_the_current_pages_colour() { + // Whatever the theme, and headless too: the page it was added from decides. + for dark in [None, Some(false), Some(true)] { + let mut s = Session::empty(); + if let Some(d) = dark { + s.set_ui_dark(d); + } + ex(&mut s, "file.new", json!({})); + ex(&mut s, "page.setBackground", json!({"color": "#336699"})); + ex(&mut s, "page.add", json!({})); + assert_eq!(backgrounds(&s), ["#336699", "#336699"], "theme {dark:?}"); + // From the second page, now current, after giving it its own colour. + ex(&mut s, "page.setBackground", json!({"color": "#FFE08A"})); + ex(&mut s, "page.add", json!({"name": "Third"})); + assert_eq!(backgrounds(&s), ["#336699", "#FFE08A", "#FFE08A"], "theme {dark:?}"); + // One undo step, the colour with it. + ex(&mut s, "edit.undo", json!({})); + assert_eq!(backgrounds(&s), ["#336699", "#FFE08A"], "theme {dark:?} after undo"); + ex(&mut s, "edit.redo", json!({})); + assert_eq!(backgrounds(&s), ["#336699", "#FFE08A", "#FFE08A"], "theme {dark:?} after redo"); + } + // From a page other than the first: go there, then add. + let mut s = Session::new(); + s.set_ui_dark(true); + ex(&mut s, "page.add", json!({})); + ex(&mut s, "page.setBackground", json!({"color": "#0B1F3A"})); + ex(&mut s, "page.select", json!({"index": 0})); + ex(&mut s, "page.add", json!({})); + assert_eq!(backgrounds(&s), ["#1E1E1E", "#0B1F3A", "#1E1E1E"], "the current page decides, not the last"); +} + +#[test] +fn added_page_colours_survive_save_and_reopen() { + let dir = std::env::temp_dir().join(format!("figcraft-added-pages-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("pages.figcraft"); + let mut s = Session::new(); + ex(&mut s, "page.setBackground", json!({"color": "#336699"})); + ex(&mut s, "page.add", json!({})); + ex(&mut s, "file.saveAs", json!({"path": path.to_string_lossy()})); + let mut s = Session::empty(); + s.set_ui_dark(true); + ex(&mut s, "file.open", json!({"path": path.to_string_lossy()})); + assert_eq!(backgrounds(&s), ["#336699", "#336699"]); + // And a page added to the reopened file in the dark theme follows the file, not the theme. + ex(&mut s, "page.add", json!({})); + assert_eq!(backgrounds(&s), ["#336699", "#336699", "#336699"]); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn the_file_the_app_opened_with_takes_the_theme_once_it_is_known() { // Made before the interface knew its theme, nothing done to it yet. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f98..3f698a0e5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -892,7 +892,7 @@ impl FigApp { _ => false, }; self.ui.dark = dark; - // New files and pages take the theme's page colour (existing pages keep theirs). + // New files take the theme's page colour (existing pages keep theirs). self.session.set_ui_dark(dark); theme::apply_with(ctx, if dark { theme::Tokens::dark() } else { theme::Tokens::light() }, self.reduce_motion()); if self.worker.is_none() { diff --git a/crates/ui-egui/src/text_enter_tests.rs b/crates/ui-egui/src/text_enter_tests.rs index 2a0f3d228..5de0522df 100644 --- a/crates/ui-egui/src/text_enter_tests.rs +++ b/crates/ui-egui/src/text_enter_tests.rs @@ -75,5 +75,7 @@ fn a_new_file_takes_the_page_colour_of_the_theme_and_keeps_it() { u.app.run("view.themeDark", json!({})); u.frame(vec![]); u.app.run("page.add", json!({})); - assert_eq!(page_colour(&u), Color::DARK_PAGE, "a page added in the dark theme"); + assert_eq!(page_colour(&u), Color::DEFAULT_PAGE, "a page added in the dark theme follows its light page"); + u.app.run("file.new", json!({})); + assert_eq!(page_colour(&u), Color::DARK_PAGE, "a file made in the dark theme"); } diff --git a/docs/mcp.md b/docs/mcp.md index 2b66657c2..64982fd26 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -301,9 +301,9 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: given, are or are in; default the selection) out of the library the file publishes, or let them back in (`on: false`; without `on` it toggles). The sign of it is the name: hiding puts a `.` before it, showing takes the leading `.` and `_` away. Returns `{ids, hidden}`; one undo step. -- New files and pages: `file.new` and `page.add` give a page the colour `#F5F5F5`. Only the app - with its dark theme showing makes them `#1E1E1E`; headless sessions (the CLI, this server) have - no theme. Text made with `text.create` or `node.create` is black whatever the page here; in the +- New files and pages: `file.new` gives its page the colour `#F5F5F5`. Only the app with its dark + theme showing makes it `#1E1E1E`; headless sessions (the CLI, this server) have no theme. + `page.add` gives the new page the current page's colour, wherever it runs. Text made with `text.create` or `node.create` is black whatever the page here; in the app (the control channel, `--attach`) it is white on a dark page unless `props` name a `fill`, as the Text tool (`pointer` with `tool.text`) makes it everywhere. - `transform.rotationOrigin {on?}` (`Alt+R`): show or hide the selection's rotation origin handle From f1f7da1f40c27b2476ee287a3aea045f0ba861c1 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:01:41 +0300 Subject: [PATCH 035/120] engine: refuse page.move for node ids that are not pages --- crates/engine/src/cmd/page.rs | 3 +++ crates/engine/src/panels_behaviour_tests.rs | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index b2d46988e..3fd03746d 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -102,6 +102,9 @@ pub fn specs() -> Vec { let id = id(p, "id").unwrap_or(s.page()?); let k = req_num("page.move", p, "index")?.max(0.0) as usize; let doc = s.doc_mut()?; + if !doc.pages.contains(&id) { + return Err(bad("page.move", format!("{id} is not a page"))); + } doc.pages.retain(|x| *x != id); let k = k.min(doc.pages.len()); doc.pages.insert(k, id); diff --git a/crates/engine/src/panels_behaviour_tests.rs b/crates/engine/src/panels_behaviour_tests.rs index f28024615..99b1caa85 100644 --- a/crates/engine/src/panels_behaviour_tests.rs +++ b/crates/engine/src/panels_behaviour_tests.rs @@ -337,6 +337,17 @@ fn pages_by_the_documented_rules() { assert_ne!(s.page().unwrap(), div, "deleting the open page opens a page, never a divider"); } +#[test] +fn page_move_refuses_what_is_not_a_page() { + let mut s = Session::new(); + let r = rect(&mut s, "r", 30.0, 40.0, None); + let before = pages(&mut s); + assert!(s.execute("page.move", &json!({"id": r, "index": 0})).is_err(), "a layer id is not a page"); + assert!(s.execute("page.move", &json!({"id": 999999, "index": 0})).is_err(), "a page that isn't there is not a page"); + assert_eq!(pages(&mut s), before, "the page list is untouched"); + assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out"); +} + #[test] fn assets_panel_lists_files_and_drops_by_the_documented_rules() { let mut s = Session::new(); From 4c23cb71c09f5c1a1962a729a569c623151b10dc Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 11:08:55 -0400 Subject: [PATCH 036/120] perf(canvas): render coarse zoom previews before refinement (M8) A new zoom can't reuse the retained canvas image, and the previous one, re-projected, covers little of the view after a zoom out (1/64 of it from 800% to 100%), so the page background showed until the full render finished. When renders from scratch have been slow (a frame or more), the worker first renders the whole requested view with a quarter of the pixels each way, in a Retained of its own so pans and edits still build on the full image, then refines it unless a newer request is already waiting (that one gets its own preview). The web build renders the preview and the refined image in consecutive frames. Pixel preview gets none. Finished images carry the file, page and whether they are coarse: the canvas never shows an image of another file or page, and a late preview never replaces a refined image already at the zoom asked for. ui.inspect reports the canvas quality and the latency from request to the frame showing the preview and the refined image. Refs #295 --- crates/ui-egui/src/canvas.rs | 59 +++++- crates/ui-egui/src/canvas_fixes_tests.rs | 3 + crates/ui-egui/src/idle_tests.rs | 3 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/render_worker.rs | 152 ++++++++++++++-- crates/ui-egui/src/zoom_preview_tests.rs | 219 +++++++++++++++++++++++ 6 files changed, 413 insertions(+), 26 deletions(-) create mode 100644 crates/ui-egui/src/zoom_preview_tests.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb23c8de0..90a993843 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -11,16 +11,26 @@ use serde_json::json; use crate::FigApp; use crate::i18n::{tr, trf, trn}; -use crate::render_worker::{Job, Worker}; +use crate::render_worker::{Done, Job, Worker}; use crate::theme::{ACCENT, COMPONENT, Tokens, doc_c32, regular, semibold}; pub struct CanvasState { pub rect: Rect, texture: Option, tex_view: Option, + /// The file ([`figcraft_engine::DocState::uid`]) and page the texture shows: it is drawn only + /// on them. + tex_of: Option<(u64, NodeId)>, + /// The texture is a coarse preview (see [`crate::render_worker`]). + pub tex_coarse: bool, submitted: Option, + /// The scale (device pixels per page unit) of the last render asked for. + want_scale: f64, /// What the last canvas render had to do (shown by `ui.inspect`). pub render_work: Option, + /// From asking for a render to the frame that shows its preview, and its refined image (ms). + pub preview_latency_ms: Option, + pub refine_latency_ms: Option, dragging: bool, space_pan: bool, middle_pan: Option<(Pos2, f64, f64)>, @@ -45,8 +55,13 @@ impl Default for CanvasState { rect: Rect::NOTHING, texture: None, tex_view: None, + tex_of: None, + tex_coarse: false, submitted: None, + want_scale: 0.0, render_work: None, + preview_latency_ms: None, + refine_latency_ms: None, dragging: false, space_pan: false, middle_pan: None, @@ -169,10 +184,22 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { let own_cursor = input(app, ui, &resp); crate::live::track_pointer(app, &ctx); app.sync_preedit(); - request_render(app, worker, ppp); + let now = ctx.input(|i| i.time); + request_render(app, worker, ppp, now); // Every finished render is shown, even when a newer one is already on its way: renders finish // in order, and an edit every frame (a drag) would otherwise never show until it stopped. - if let Some(done) = worker.poll() { + let shown = app.session.ds().map(|ds| (ds.uid, ds.page)).ok(); + if let Some(done) = worker.poll() + && shows(&app.canvas, &done, shown) + { + let latency = Some((now - done.at).max(0.0) * 1000.0); + if done.coarse { + app.canvas.preview_latency_ms = latency; + } else { + app.canvas.refine_latency_ms = latency; + app.canvas.render_work = Some(done.work); + app.ui.render_ms = done.ms; + } // A pixel preview render is magnified with hard pixel edges. let filter = if app.session.ds().ok().and_then(|d| d.view.pixel_preview_scale()) == Some(done.view.scale) { egui::TextureOptions::NEAREST @@ -184,8 +211,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { None => app.canvas.texture = Some(ctx.load_texture("figcraft-canvas", done.image, filter)), } app.canvas.tex_view = Some(done.view); - app.canvas.render_work = Some(done.work); - app.ui.render_ms = done.ms; + app.canvas.tex_of = Some((done.uid, done.page)); + app.canvas.tex_coarse = done.coarse; } let painter = ui.painter_at(rect); let t = Tokens::get(&ctx); @@ -200,7 +227,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { }) .unwrap_or(t.canvas); painter.rect_filled(rect, CornerRadius::ZERO, bg); - if let (Some(tex), Some(v)) = (&app.canvas.texture, &app.canvas.tex_view) { + if let (Some(tex), Some(v), true) = (&app.canvas.texture, &app.canvas.tex_view, shown.is_some() && app.canvas.tex_of == shown) { // Re-project the last render onto the current view. let tl = app.to_screen(v.origin.0, v.origin.1); let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0); @@ -268,7 +295,17 @@ fn describe_box([x0, y0, x1, y1]: [f64; 4]) -> String { ) } -fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { +/// Whether a finished render goes on screen: an image of the file and page shown (`shown`), and +/// not a coarse preview over a refined image already at the zoom asked for. +fn shows(c: &CanvasState, done: &Done, shown: Option<(u64, NodeId)>) -> bool { + if shown != Some((done.uid, done.page)) { + return false; + } + let refined_here = !c.tex_coarse && c.tex_of == shown && c.tex_view.as_ref().is_some_and(|v| v.scale.to_bits() == c.want_scale.to_bits()); + !(done.coarse && refined_here) +} + +fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32, now: f64) { let Ok(ds) = app.session.ds() else { return }; let rect = app.canvas.rect; let (mut ox, mut oy) = ds.view.origin(); @@ -313,7 +350,11 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { skip: vec![], solo: key.solo, proof, + uid: ds.uid, + zoom_preview: ds.view.pixel_preview_scale().is_none(), + at: now, }; + app.canvas.want_scale = scale; worker.submit(job); app.canvas.submitted = Some(key); } @@ -1342,3 +1383,7 @@ pub fn fmt(v: f64) -> String { let r = (v * 100.0).round() / 100.0; if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") } } + +#[cfg(test)] +#[path = "zoom_preview_tests.rs"] +mod zoom_preview_tests; diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs index 37b03e554..1c4938d73 100644 --- a/crates/ui-egui/src/canvas_fixes_tests.rs +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -62,6 +62,9 @@ fn big_job(doc: &Arc, page: figcraft_doc::NodeId, scale: skip: vec![], solo: None, proof: false, + uid: 0, + zoom_preview: false, + at: 0.0, } } diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 2d4e57579..4a277ae21 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -212,6 +212,9 @@ fn a_finished_background_render_wakes_the_ui() { skip: vec![], solo: None, proof: false, + uid: 0, + zoom_preview: false, + at: 0.0, }); let start = Instant::now(); while wakes.load(Ordering::SeqCst) == 0 { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f98..585697003 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1181,6 +1181,9 @@ impl FigApp { figcraft_render::Work::Partial { pixels } => json!({"kind": "partial", "pixels": pixels}), figcraft_render::Work::Full => json!({"kind": "full"}), }), + "quality": if self.canvas.tex_coarse { "preview" } else { "refined" }, + "previewLatencyMs": self.canvas.preview_latency_ms, + "refineLatencyMs": self.canvas.refine_latency_ms, }, "live": live::inspect(self), "window": self.windows.current, diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index 12a0ba62b..ee9788906 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -3,6 +3,11 @@ //! until the next one arrives. //! The renderer keeps its last image ([`figcraft_render::Retained`]): a pan renders only the //! uncovered strips and an edit only the areas of the layers that changed. +//! A new zoom can't reuse that image, and the old one, re-projected, covers little of the view +//! after a zoom out (an eighth of the width from 800% to 100%). When full renders are slow, the +//! whole view is first rendered coarsely ([`PREVIEW_FACTOR`] times fewer pixels each way, kept +//! apart from the full image so pans and edits still build on it), then refined unless a newer +//! request is already waiting. use std::sync::Arc; @@ -10,6 +15,11 @@ use figcraft_color::ColorProfile; use figcraft_doc::{Document, NodeId}; use figcraft_render::{Pixmap, Retained, View, Work}; +/// A preview has this many times fewer pixels each way than the view it stands in for. +pub const PREVIEW_FACTOR: f64 = 4.0; +/// Full renders quicker than this (about a frame) show without a preview first. +const PREVIEW_AFTER_MS: f64 = 12.0; + #[derive(Clone)] pub struct Job { pub doc: Arc, @@ -25,6 +35,13 @@ pub struct Job { pub solo: Option, /// Soft-proof sRGB: stripe the colours sRGB can't show. pub proof: bool, + /// The document's [`figcraft_engine::DocState::uid`]: images of other files are never shown. + pub uid: u64, + /// A coarse preview may come first when the zoom changes (not in pixel preview, whose pixels + /// are the point). + pub zoom_preview: bool, + /// When the UI asked for it (`egui` input time, seconds): how long the image took to show. + pub at: f64, } pub struct Done { @@ -34,6 +51,13 @@ pub struct Done { pub view: View, pub ms: f64, pub work: Work, + /// What it is an image of ([`Job::uid`], [`Job::page`]). + pub uid: u64, + pub page: NodeId, + /// A coarse preview, refined by a later image unless a newer request replaced it. + pub coarse: bool, + /// [`Job::at`]. + pub at: f64, } /// A render of a document in `profile` as a texture image for the screen (sRGB). @@ -58,7 +82,8 @@ fn mark_outside_srgb(image: &mut egui::ColorImage, pix: &Pixmap, profile: ColorP } } -fn run(job: &Job, retained: &mut Retained) -> Option { +/// Render `view` of the job (its own view, or the coarse one standing in for it). +fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option { #[cfg(not(target_arch = "wasm32"))] let t0 = std::time::Instant::now(); // The canvas is a preview: what's too small to see in detail at this zoom is approximated. @@ -71,7 +96,7 @@ fn run(job: &Job, retained: &mut Retained) -> Option { lod: true, ..Default::default() }; - let (view, pix, work) = retained.render(&job.doc, job.page, &job.view, &opts)?; + let (view, pix, work) = retained.render(&job.doc, job.page, view, &opts)?; let mut image = screen_image(pix, job.doc.color_profile); if job.proof { mark_outside_srgb(&mut image, pix, job.doc.color_profile); @@ -80,7 +105,80 @@ fn run(job: &Job, retained: &mut Retained) -> Option { let ms = t0.elapsed().as_secs_f64() * 1000.0; #[cfg(target_arch = "wasm32")] let ms = 0.0; - Some(Done { image, view, ms, work }) + Some(Done { image, view, ms, work, uid: job.uid, page: job.page, coarse, at: job.at }) +} + +/// `view` with [`PREVIEW_FACTOR`] times fewer pixels each way, showing at least the same area. +pub fn coarse_view(view: &View) -> View { + let shrink = |n: u32| (f64::from(n) / PREVIEW_FACTOR).ceil().max(1.0) as u32; + View { origin: view.origin, scale: view.scale / PREVIEW_FACTOR, width: shrink(view.width), height: shrink(view.height) } +} + +/// What the render thread keeps between jobs: the canvas image, the previews standing in for it +/// while the zoom changes, and what the last full render drew and took. +pub struct Renders { + full: Retained, + preview: Retained, + /// The last full render's document, page and scale. + last: Option<(u64, NodeId, f64)>, + /// How long the last render from scratch took. + full_ms: f64, + /// Previews come first only when renders from scratch take at least this long. + preview_after_ms: f64, +} + +impl Default for Renders { + fn default() -> Self { + Self::with_threshold(PREVIEW_AFTER_MS) + } +} + +impl Renders { + /// Previews when renders from scratch take at least `ms` (tests: 0, always). + pub fn with_threshold(ms: f64) -> Self { + Self { full: Retained::default(), preview: Retained::default(), last: None, full_ms: 0.0, preview_after_ms: ms } + } + + /// Whether `job` gets a preview first: the kept image is of another zoom, page or file (so the + /// full render starts from scratch) and those have been slow. The first render has no + /// measure yet and comes without one. + fn wants_preview(&self, job: &Job) -> bool { + job.zoom_preview + && self.full_ms >= self.preview_after_ms + && self.last.is_some_and(|(uid, page, scale)| uid != job.uid || page != job.page || scale.to_bits() != job.view.scale.to_bits()) + } + + /// The coarse preview of the whole of `job`'s view, when one should come first. + pub fn preview(&mut self, job: &Job) -> Option { + if !self.wants_preview(job) { + return None; + } + let view = coarse_view(&job.view); + guarded(&mut self.preview, |r| run(job, &view, r, true)) + } + + /// The image of `job`'s view, built on the kept one where it can be. + pub fn refine(&mut self, job: &Job) -> Option { + let done = guarded(&mut self.full, |r| run(job, &job.view, r, false)); + if let Some(d) = &done { + self.last = Some((job.uid, job.page, job.view.scale)); + if d.work == Work::Full { + self.full_ms = d.ms; + } + } + done + } +} + +/// Run `f` on `retained`; after a panic, forget the image it may have left half drawn. +fn guarded(retained: &mut Retained, f: impl FnOnce(&mut Retained) -> Option) -> Option { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(retained))) { + Ok(d) => d, + Err(_) => { + retained.clear(); + None + } + } } #[cfg(not(target_arch = "wasm32"))] @@ -104,22 +202,26 @@ impl Worker { // The window that renders with it (each window has its own worker). let viewport = ctx.viewport_id(); let spawned = std::thread::Builder::new().name("figcraft-render".into()).spawn(move || { - let mut retained = Retained::default(); - while let Ok((mut seq, mut job)) = jobs.recv() { + let mut renders = Renders::default(); + let mut next = None; + loop { + let Some((mut seq, mut job)) = next.take().or_else(|| jobs.recv().ok()) else { break }; // Skip to the newest queued job. while let Ok(newer) = jobs.try_recv() { (seq, job) = newer; } - let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&job, &mut retained))); - let done = match res { - Ok(d) => d, - Err(_) => { - // Don't build on an image a panic may have left half drawn. - retained.clear(); - None + if let Some(d) = renders.preview(&job) { + if done_tx.send(d).is_err() { + break; } - }; - if let Some(d) = done + ctx.request_repaint_of(viewport); + // A newer request makes refining this one moot: it gets its own preview. + if let Ok(newer) = jobs.try_recv() { + next = Some(newer); + continue; + } + } + if let Some(d) = renders.refine(&job) && done_tx.send(d).is_err() { break; @@ -155,30 +257,42 @@ impl Worker { } } +/// Renders inline, one image a frame: a preview first when one is due, the refined image in the +/// next frame unless a newer request replaced the job. #[cfg(target_arch = "wasm32")] pub struct Worker { + ctx: egui::Context, pending: Option, - retained: Retained, + previewed: bool, + renders: Renders, } #[cfg(target_arch = "wasm32")] impl Worker { - pub fn new(_ctx: egui::Context) -> Self { - Self { pending: None, retained: Retained::default() } + pub fn new(ctx: egui::Context) -> Self { + Self { ctx, pending: None, previewed: false, renders: Renders::default() } } pub fn submit(&mut self, job: Job) { self.pending = Some(job); + self.previewed = false; } pub fn in_flight(&self) -> bool { self.pending.is_some() } pub fn poll(&mut self) -> Option { + if !self.previewed { + self.previewed = true; + if let Some(d) = self.pending.as_ref().and_then(|job| self.renders.preview(job)) { + self.ctx.request_repaint(); + return Some(d); + } + } let job = self.pending.take()?; - run(&job, &mut self.retained) + self.renders.refine(&job) } } /// Render synchronously from scratch (tests, screenshots). pub fn render_now(job: &Job) -> Option { - run(job, &mut Retained::default()) + run(job, &job.view, &mut Retained::default(), false) } diff --git a/crates/ui-egui/src/zoom_preview_tests.rs b/crates/ui-egui/src/zoom_preview_tests.rs new file mode 100644 index 000000000..cd17a70a3 --- /dev/null +++ b/crates/ui-egui/src/zoom_preview_tests.rs @@ -0,0 +1,219 @@ +//! Coarse previews while the zoom changes (#295): a new zoom first shows the whole view coarsely, +//! then the refined image, which matches a render from scratch; pans and edits still build on the +//! kept image; previews never replace a refined image already at the zoom asked for, and no image +//! of another file or page goes on screen. + +use std::sync::Arc; + +use figcraft_doc::{Document, NodeId}; +use figcraft_engine::Session; +use figcraft_engine::params::id_of; +use figcraft_render::{View, Work}; +use serde_json::json; + +use super::{CanvasState, shows}; +use crate::render_worker::{Done, Job, PREVIEW_FACTOR, Renders, coarse_view, render_now}; + +/// A page of ellipses with a rectangle on top; the session and the rectangle. +fn document() -> (Session, NodeId) { + let mut s = Session::new(); + for k in 0..12 { + let k = f64::from(k); + s.execute("shape.ellipse", &json!({"x": k * 60.0, "y": k * 40.0, "width": 120, "height": 90})).unwrap(); + } + let made = s.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 300, "height": 200})).unwrap(); + let rect = id_of(&made["id"]).unwrap(); + (s, rect) +} + +/// A `w` × `h` device-pixel view at `scale` centred on page point `c`. +fn view_at(c: (f64, f64), scale: f64, w: u32, h: u32) -> View { + View { origin: (c.0 - f64::from(w) / scale / 2.0, c.1 - f64::from(h) / scale / 2.0), scale, width: w, height: h } +} + +fn job(doc: &Arc, page: NodeId, uid: u64, view: View) -> Job { + Job { + doc: doc.clone(), + page, + view, + outline: false, + outline_extras: Default::default(), + outlined: vec![], + skip: vec![], + solo: None, + proof: false, + uid, + zoom_preview: true, + at: 0.0, + } +} + +/// Every pixel of `a` and `b` alike. +fn same_pixels(a: &Done, b: &Done) -> bool { + a.image.size == b.image.size && a.image.pixels == b.image.pixels +} + +fn covers(outer: &View, inner: &View) -> bool { + let (o, i) = (outer.page_rect(), inner.page_rect()); + let eps = 1e-6; + o.x0 <= i.x0 + eps && o.y0 <= i.y0 + eps && o.x1 >= i.x1 - eps && o.y1 >= i.y1 - eps +} + +#[test] +fn a_zoom_out_previews_the_whole_view_then_refines_to_a_fresh_render() { + let (s, _) = document(); + let ds = s.ds().unwrap(); + let mut r = Renders::with_threshold(0.0); + let zoomed_in = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 8.0, 400, 300)); + assert!(r.preview(&zoomed_in).is_none(), "the first render has nothing to measure previews by"); + assert!(r.refine(&zoomed_in).is_some()); + // 800% → 100% about the same centre: the old image covers 1/64 of the new view. + let out = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 1.0, 400, 300)); + let p = r.preview(&out).expect("a preview first"); + assert!(p.coarse && (p.uid, p.page) == (ds.uid, ds.page)); + assert!(covers(&p.view, &out.view), "the preview shows the whole view asked for"); + let pixels = |d: &Done| d.image.size[0] * d.image.size[1]; + let fresh = render_now(&out).unwrap(); + assert!( + (pixels(&p) as f64) * PREVIEW_FACTOR * PREVIEW_FACTOR < 1.5 * pixels(&fresh) as f64, + "a sixteenth of the pixels, give or take the snapping" + ); + let refined = r.refine(&out).unwrap(); + assert!(!refined.coarse && refined.work == Work::Full); + assert!(same_pixels(&refined, &fresh), "the settled image is a render from scratch"); +} + +/// Zoom, pan and edit with `r`; whether each step was previewed, and the pan's and the edit's +/// images. +fn zoom_pan_edit(r: &mut Renders) -> (Vec, Done, Done) { + let (mut s, rect) = document(); + let (doc, page, uid) = { + let ds = s.ds().unwrap(); + (ds.doc.clone(), ds.page, ds.uid) + }; + r.refine(&job(&doc, page, uid, view_at((300.0, 200.0), 4.0, 1280, 960))).unwrap(); + let mut previewed = vec![]; + // A zoom; the preview is kept apart from the full image. + let zoomed = job(&doc, page, uid, view_at((300.0, 200.0), 2.0, 1280, 960)); + previewed.push(r.preview(&zoomed).is_some()); + r.refine(&zoomed).unwrap(); + // A pan at that zoom. + let mut pan = zoomed; + pan.view.origin.0 += 10.0; + previewed.push(r.preview(&pan).is_some()); + let panned = r.refine(&pan).unwrap(); + // An edit there. + s.execute("node.set", &json!({"id": rect, "props": {"x": 120}})).unwrap(); + let edit = job(&s.ds().unwrap().doc, page, uid, pan.view); + previewed.push(r.preview(&edit).is_some()); + let edited = r.refine(&edit).unwrap(); + (previewed, panned, edited) +} + +#[test] +fn pans_and_edits_get_no_preview_and_build_on_the_kept_image() { + let (previewed, panned, edited) = zoom_pan_edit(&mut Renders::with_threshold(0.0)); + assert_eq!(previewed, [true, false, false], "the zoom only"); + assert!(matches!(panned.work, Work::Partial { .. }), "a pan after a previewed zoom renders strips only: {:?}", panned.work); + assert!(matches!(edited.work, Work::Partial { .. }), "an edit renders its area only: {:?}", edited.work); + // The same steps without previews: the same work and the same pixels. + let (none, panned_alone, edited_alone) = zoom_pan_edit(&mut Renders::with_threshold(f64::INFINITY)); + assert_eq!(none, [false, false, false]); + assert_eq!((panned.work, edited.work), (panned_alone.work, edited_alone.work)); + assert!(same_pixels(&panned, &panned_alone) && same_pixels(&edited, &edited_alone)); +} + +#[test] +fn previews_come_for_other_pages_and_files_but_not_in_pixel_preview_or_when_renders_are_quick() { + let (mut s, _) = document(); + let (doc, page, uid) = { + let ds = s.ds().unwrap(); + (ds.doc.clone(), ds.page, ds.uid) + }; + let v = view_at((300.0, 200.0), 1.0, 200, 150); + let mut r = Renders::with_threshold(0.0); + r.refine(&job(&doc, page, uid, v.clone())).unwrap(); + s.execute("page.add", &json!({})).unwrap(); + let ds = s.ds().unwrap(); + assert!(r.preview(&job(&ds.doc, ds.page, uid, v.clone())).is_some(), "another page"); + assert!(r.preview(&job(&doc, page, uid + 1, v.clone())).is_some(), "another file"); + let zoom = view_at((300.0, 200.0), 2.0, 200, 150); + let mut pixel_preview = job(&doc, page, uid, zoom.clone()); + pixel_preview.zoom_preview = false; + assert!(r.preview(&pixel_preview).is_none(), "pixel preview shows real pixels only"); + let mut quick = Renders::with_threshold(f64::INFINITY); + quick.refine(&job(&doc, page, uid, v)).unwrap(); + assert!(quick.preview(&job(&doc, page, uid, zoom)).is_none(), "quick renders show without one"); +} + +#[test] +fn previews_cover_the_view_for_any_zoom_and_density() { + let (s, _) = document(); + let ds = s.ds().unwrap(); + let mut r = Renders::with_threshold(0.0); + r.refine(&job(&ds.doc, ds.page, ds.uid, view_at((0.0, 0.0), 0.37, 64, 64))).unwrap(); + let mut n = 0; + // Zooms from 2% to 6400% at 1x, 1.5x and 2x displays, odd sizes, centres off the pixel grid. + for zoom in [0.02, 0.1, 0.333, 1.0, 2.5, 8.0, 64.0] { + for ppp in [1.0, 1.5, 2.0] { + for (w, h) in [(1, 1), (3, 7), (401, 299), (1280, 720)] { + let v = view_at((123.456, -78.9), zoom * ppp, w, h); + let c = coarse_view(&v); + assert!(covers(&c, &v), "coarse view of {v:?}"); + let p = r.preview(&job(&ds.doc, ds.page, ds.uid, v.clone())).expect("a preview"); + assert!(covers(&p.view, &v), "preview of {v:?} shows {:?}", p.view); + n += 1; + } + } + } + assert_eq!(n, 84); +} + +#[test] +fn outline_mode_proofing_and_glass_preview_and_settle_like_fresh_renders() { + let (mut s, rect) = document(); + s.execute("effect.add", &json!({"ids": [rect], "type": "GLASS"})).unwrap(); + let ds = s.ds().unwrap(); + for (outline, proof) in [(false, false), (true, false), (false, true)] { + let mut r = Renders::with_threshold(0.0); + let mut first = job(&ds.doc, ds.page, ds.uid, view_at((250.0, 150.0), 3.0, 300, 200)); + (first.outline, first.proof) = (outline, proof); + r.refine(&first).unwrap(); + let mut next = first.clone(); + next.view = view_at((250.0, 150.0), 0.75, 300, 200); + let p = r.preview(&next).expect("a preview"); + assert!(p.coarse && covers(&p.view, &next.view)); + let d = r.refine(&next).unwrap(); + assert!(same_pixels(&d, &render_now(&next).unwrap()), "outline {outline}, proof {proof}"); + } +} + +#[test] +fn the_canvas_shows_images_of_its_own_page_and_never_a_coarser_one_at_its_zoom() { + let (s, _) = document(); + let ds = s.ds().unwrap(); + let here = Some((ds.uid, ds.page)); + let mut r = Renders::with_threshold(0.0); + let near = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 2.0, 200, 150)); + let refined = r.refine(&near).unwrap(); + let far = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 0.5, 200, 150)); + let preview = r.preview(&far).unwrap(); + + let mut c = CanvasState { want_scale: near.view.scale, ..Default::default() }; + assert!(shows(&c, &refined, here)); + assert!(!shows(&c, &refined, Some((ds.uid + 1, ds.page))), "another file"); + assert!(!shows(&c, &refined, Some((ds.uid, NodeId(ds.page.0 + 1)))), "another page"); + assert!(!shows(&c, &refined, None), "no file open"); + // Showing the refined image at the zoom asked for: a preview arriving late stays off. + (c.tex_view, c.tex_of, c.tex_coarse) = (Some(refined.view.clone()), here, false); + assert!(!shows(&c, &preview, here)); + // Once the zoom asked for moves on, the preview is the better match. + c.want_scale = far.view.scale; + assert!(shows(&c, &preview, here)); + // Over another preview, or a refined image of another page, it goes on. + c.want_scale = near.view.scale; + c.tex_coarse = true; + assert!(shows(&c, &preview, here)); + (c.tex_coarse, c.tex_of) = (false, Some((ds.uid, NodeId(ds.page.0 + 1)))); + assert!(shows(&c, &preview, here)); +} From ed0e7a9b2d6b0bd82a49a3939c9d7daaca911183 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 17:11:20 +0200 Subject: [PATCH 037/120] M1: leaving Design ends the pen path and goes back to Move (fixes #307) --- crates/ui-egui/src/lib.rs | 20 ++++++ crates/ui-egui/src/pen_mode_tests.rs | 94 ++++++++++++++++++++++++++++ crates/ui-egui/src/toolbar.rs | 2 +- 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/pen_mode_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f98..f851adb58 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -122,6 +122,8 @@ mod panel_reorder_tests; #[cfg(test)] mod panel_width_tests; #[cfg(test)] +mod pen_mode_tests; +#[cfg(test)] mod perf_tests; #[cfg(test)] mod plugin_panel_tests; @@ -280,6 +282,8 @@ pub struct UiState { pub left_width: f32, pub right_width: f32, pub right_tab: RightTab, + /// The tab of the previous frame, to see the right panel change mode. + pub last_right_tab: RightTab, pub left_tab: LeftTab, pub pages_open: bool, pub expanded: BTreeSet, @@ -400,6 +404,7 @@ impl Default for UiState { left_width: PANEL_DEFAULT, right_width: PANEL_DEFAULT, right_tab: RightTab::Design, + last_right_tab: RightTab::Design, left_tab: LeftTab::File, pages_open: true, expanded: BTreeSet::new(), @@ -610,6 +615,19 @@ impl FigApp { } } + /// Leaving Design ends a pen path as Escape does and goes back to Move: Prototype and Inspect + /// don't draw (#307). Checked as the frame starts (the control channel) and after the panels + /// and shortcuts (the mode switcher, the tabs, Shift+E/Shift+D), before the canvas sees more. + fn end_pen_outside_design(&mut self) { + if self.ui.right_tab == self.ui.last_right_tab { + return; + } + self.ui.last_right_tab = self.ui.right_tab; + if self.ui.right_tab != RightTab::Design && self.session.tool.tool == figcraft_engine::tools::ToolId::Pen { + self.run("tool.set", json!({"tool": "move"})); + } + } + /// Once a frame, after the panels: a preview nothing asked for this frame ends. fn end_unwanted_text_preview(&mut self, ctx: &egui::Context) { if !std::mem::take(&mut self.ui.text_preview_wanted) { @@ -984,6 +1002,7 @@ impl FigApp { widgets::set_nudge(&ctx, self.session.prefs.nudge_small, self.session.prefs.nudge_big); // The Inspect tab measures on hover without Alt. self.session.tool.redlines = self.ui.right_tab == RightTab::Inspect; + self.end_pen_outside_design(); if self.ui.present.is_some() { present::show(self, ui); // What went wrong while presenting (a widget's plugin failing) shows over it. @@ -1086,6 +1105,7 @@ impl FigApp { variable_ui::picker(self, &ctx); style_picker::show(self, &ctx); self.end_unwanted_text_preview(&ctx); + self.end_pen_outside_design(); dialogs::show(self, &ctx); self.toast_ui(&ctx); // A rename started after the layers panel drew (Cmd/Ctrl+R, a double-click) opens its field diff --git a/crates/ui-egui/src/pen_mode_tests.rs b/crates/ui-egui/src/pen_mode_tests.rs new file mode 100644 index 000000000..6fa54959d --- /dev/null +++ b/crates/ui-egui/src/pen_mode_tests.rs @@ -0,0 +1,94 @@ +//! Leaving Design with a pen path open (#307): the path ends as Escape ends it, the tool goes back +//! to Move, and the canvas of Prototype or Inspect takes no more pen points. Driven by the panel +//! keys and by the toolbar's mode switcher. + +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, Pos2, pos2}; +use figcraft_engine::Session; +use figcraft_engine::tools::ToolId; +use serde_json::json; + +use crate::RightTab; +use crate::layers_drag_tests::Ui; + +fn press(u: &mut Ui, key: Key, modifiers: Modifiers) { + u.frame(vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ]); +} + +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); + u.frame(vec![]); +} + +fn layers(u: &Ui) -> usize { + let doc = u.app.session.doc().unwrap(); + doc.node(u.app.session.page().unwrap()).unwrap().children.len() +} + +/// The middle of the toolbar's mode button named `label`, found in the AccessKit tree. +fn mode_button(u: &mut Ui, label: &str) -> Pos2 { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some(label)).expect(label); + let b = n.bounds().expect("bounds"); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +/// A pen path with two anchors still open on an empty page. +fn drawing() -> Ui { + let mut u = Ui::new(Session::new()); + u.app.run("tool.set", json!({"tool": "pen"})); + click(&mut u, pos2(600.0, 400.0)); + click(&mut u, pos2(700.0, 450.0)); + assert_eq!(u.app.session.tool.pen.as_ref().map(|p| p.points.len()), Some(2), "the path is open"); + u +} + +#[test] +fn switching_mode_ends_the_pen_path_as_escape_does() { + let mut esc = drawing(); + press(&mut esc, Key::Escape, Modifiers::NONE); + assert!(esc.app.session.tool.pen.is_none()); + let finished = layers(&esc); + assert_eq!(finished, 1, "Escape keeps the two-anchor path"); + + // Shift+E is Prototype, Shift+D is Inspect. + for key in [Key::E, Key::D] { + let mut u = drawing(); + press(&mut u, key, Modifiers::SHIFT); + assert_ne!(u.app.ui.right_tab, RightTab::Design, "{key:?}"); + assert!(u.app.session.tool.pen.is_none(), "{key:?}: the path is finished"); + assert_eq!(u.app.session.tool.tool, ToolId::Move, "{key:?}: back to Move"); + assert_eq!(layers(&u), finished, "{key:?}: the path is kept as Escape keeps it"); + // The canvas takes no more pen points. + click(&mut u, pos2(800.0, 300.0)); + assert!(u.app.session.tool.pen.is_none(), "{key:?}: a click starts no path"); + assert_eq!(layers(&u), finished, "{key:?}: a click adds nothing"); + } +} + +#[test] +fn the_mode_switcher_ends_the_pen_path_too() { + let finished = { + let mut esc = drawing(); + press(&mut esc, Key::Escape, Modifiers::NONE); + layers(&esc) + }; + // Design is already the mode while the pen draws: pressing it still leaves the pen, as it leaves + // the pencil of Draw. + for (label, tab) in [("Prototype", RightTab::Prototype), ("Inspect", RightTab::Inspect), ("Design", RightTab::Design)] { + let mut u = drawing(); + let at = mode_button(&mut u, label); + click(&mut u, at); + assert_eq!(u.app.ui.right_tab, tab, "{label}"); + assert!(u.app.session.tool.pen.is_none(), "{label}: the path is finished"); + assert_eq!(u.app.session.tool.tool, ToolId::Move, "{label}: back to Move"); + assert_eq!(layers(&u), finished, "{label}: the path is kept as Escape keeps it"); + } +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 1e6e38aa1..24af4a640 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -174,7 +174,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { } 1 => { app.ui.right_tab = crate::RightTab::Design; - if app.session.tool.tool == ToolId::Pencil { + if matches!(app.session.tool.tool, ToolId::Pencil | ToolId::Pen) { app.run("tool.set", json!({"tool": "move"})); } } From d72e5e0516c45e45bcb83655fd75d298e2d167ca Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 11:15:11 -0400 Subject: [PATCH 038/120] docs: zoom previews in the roadmap, gaps and control protocol (M8) Refs #295 --- ROADMAP.md | 2 ++ docs/control-protocol.md | 5 ++++- docs/gaps.md | 4 +++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29b..826a625d8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-14** · Coarse zoom previews (M8, #295): a new zoom can't reuse the retained canvas image, and the old one, re-projected, covers 1/64 of the view after a zoom out from 800% to 100%, so the page colour showed until the full render was done. When renders from scratch take a frame or more, the render thread first renders the whole view with a quarter of the pixels each way (kept apart from the full image, so pans and edits still build on it) and then refines it unless a newer request waits; the web build does the two in consecutive frames; pixel preview gets none. The canvas never shows an image of another file or page, and a late preview never replaces a refined image at the zoom asked for. `ui.inspect` gives `canvas.quality`, `previewLatencyMs` and `refineLatencyMs`; `zoom_preview_tests`. Measured on Windows (600 layers with shadows, background blurs and glass, 904×860 canvas): 800% → 100% shows the whole view 7–15 ms after the request and the refined image at 63–69 ms (a 56 ms render). Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. - **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged. @@ -312,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Coarse zoom previews (M8, #295): when renders from scratch are slow, a new zoom shows the whole view coarsely first, then the refined image; images of another file or page never show. Numbers unchanged | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9fc9937d9..1b6cef46f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -93,7 +93,10 @@ ellipse, its arc handles `arcHandles`, for a selected rectangle, frame, polygon an idle window draws none) with the most frequent repaint requests behind them (`repaintCauses`: `[{at: "file:line", count}]`, every request made in the frame before each one, delayed ones such as the autosave deadline included; egui's `context.rs` begin-pass line means input events) and what the last canvas render had to do (`canvas.renderWork`: `full`, `partial` with -the `pixels` re-rendered after a pan or an edit, or `reused`). While a prototype plays, `presenter` +the `pixels` re-rendered after a pan or an edit, or `reused`), whether the canvas shows a coarse +zoom preview or the refined image (`canvas.quality`: `preview` or `refined`) and the time from +asking for a render to the frame showing its preview and its refined image (`canvas.previewLatencyMs`, +`canvas.refineLatencyMs`; `null` until there was one). While a prototype plays, `presenter` (full window) and `preview` (the preview pane) hold the player's state: the `current` frame and its `name`, `start`, `history`, open `overlays`, how many delayed mouse triggers are `waiting`, the screen's `scroll` offset and `scrollRange`, the `device` (and whether it is turned to `landscape`, its `deviceColor`) and the `viewport` of the frame diff --git a/docs/gaps.md b/docs/gaps.md index 34c4f4390..aab688066 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -193,7 +193,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ### 7. CPU canvas where Figma uses the GPU - **Missing:** GPU rendering of the canvas. FigCraft renders with tiny-skia on the CPU (banded, - parallel, retained) and shows the image through wgpu. + parallel, retained) and shows the image through wgpu. A new zoom can't reuse the retained + image; when renders from scratch take a frame or more, the whole view is previewed with a + sixteenth of the pixels first, then refined. - **Evidence (measured, `cargo xtask bench`):** a 50k-layer page ~8 ms zoomed out with levels of detail, single-layer edits at 10k layers ~0.2 ms; full 1440×900 renders 7–15 ms at 10k layers. Heavy effects (glass, background blur, noise) and 4K/5K canvases at 2x are where CPU time grows. From 4f321fb68de399c8b5993806903893f03fe8305f Mon Sep 17 00:00:00 2001 From: Barzabel Date: Wed, 14 Oct 2026 18:21:08 +0300 Subject: [PATCH 039/120] M1: creating a variable collection no longer closes the app (#224) Creating a collection (and adding a mode or a variable, double-clicking a row, or Rename in its menu) asked egui to focus the new name's field in the same frame, but that field is only drawn from the next frame on. For one frame the AccessKit update named a focused node that wasn't in the tree, and the platform adapter aborts on that, outside the app's frame guard; Windows keeps AccessKit on through UI Automation far more often, hence the silent close there. A rename now leaves a flag and its field takes the focus in the frame it's drawn, through widgets::rename_field, as the layer rows do. --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/variables_panel.rs | 42 +++++----- crates/ui-egui/src/variables_rename_tests.rs | 82 ++++++++++++++++++++ 3 files changed, 108 insertions(+), 18 deletions(-) create mode 100644 crates/ui-egui/src/variables_rename_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f98..65af6eb30 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -148,6 +148,8 @@ mod text_enter_tests; #[cfg(test)] mod titlebar_tests; #[cfg(test)] +mod variables_rename_tests; +#[cfg(test)] mod vector_tail_ui_tests; #[cfg(test)] mod windows_qa_tests; diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index 240a51a57..201374823 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -47,6 +47,8 @@ pub struct Editor { collection: Option, query: String, rename: Option, + /// The rename just started: its field takes the focus in the frame it's first drawn. + rename_start: bool, /// The variable whose details (name, description, scopes) are open. details: Option, /// The group the table shows ("space" or "color/brand"); None = every variable. @@ -240,15 +242,19 @@ fn rename_id(target: &Rename) -> Id { Id::new(("var_rename", target)) } -fn start_rename(ui: &Ui, st: &mut Editor, target: Rename) { - ui.memory_mut(|m| m.request_focus(rename_id(&target).with("te"))); +/// Start renaming `target`. The field isn't drawn yet (a new collection, mode or variable shows next +/// frame), and focusing it now would put a node that isn't there in the screen reader tree, which +/// AccessKit aborts on (#224): the field takes the focus when drawn. +fn start_rename(st: &mut Editor, target: Rename) { st.rename = Some(target); + st.rename_start = true; } /// The inline editor of a rename in progress: the new name once committed. The rename ends when /// the editor closes. fn rename_field(ui: &mut Ui, st: &mut Editor, target: &Rename, value: &str, rect: Rect, font: egui::FontId) -> Option { - let (out, open) = widgets::rename_field(ui, rename_id(target), value, rect, font, false); + let start = std::mem::take(&mut st.rename_start); + let (out, open) = widgets::rename_field(ui, rename_id(target), value, rect, font, start); if !open { st.rename = None; } @@ -271,7 +277,7 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, views: &[View], local: &Variables, st: Icon::Plus, tr("Create collection"), ) { - create_collection(app, ui, local, st); + create_collection(app, local, st); } egui::ScrollArea::vertical().id_salt("vars_collections").auto_shrink([false, false]).show(ui, |ui| { for view in views { @@ -332,11 +338,11 @@ fn collection_row(app: &mut FigApp, ui: &mut Ui, view: &View, c: &Collection, st } if view.editable() { if resp.double_clicked() { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); } crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, tr("Rename"), "") { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); ui.close(); } if menu_item(ui, tr("Delete collection"), "") { @@ -421,11 +427,11 @@ fn group_rows(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: continue; } if resp.double_clicked() { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); } crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, tr("Rename group"), "") { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); ui.close(); } if menu_item(ui, tr("Delete group"), "") { @@ -468,14 +474,14 @@ fn rename_group(app: &mut FigApp, members: &[&Variable], g: &str, new: &str, st: } } -fn create_collection(app: &mut FigApp, ui: &Ui, vars: &Variables, st: &mut Editor) { +fn create_collection(app: &mut FigApp, vars: &Variables, st: &mut Editor) { let name = unique("Collection", |n| vars.collections.iter().any(|c| c.name == n)); if let Some(id) = app.run("variable.createCollection", json!({"name": name, "modes": ["Mode 1"]})).and_then(|r| r["id"].as_str().map(str::to_string)) { st.library = None; st.collection = Some(id.clone()); - start_rename(ui, st, Rename::Collection(id)); + start_rename(st, Rename::Collection(id)); } } @@ -503,7 +509,7 @@ fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) { ); ui.add_space(12.0); if widgets::button(ui, tr("Create collection"), true, true).clicked() { - create_collection(app, ui, view.vars, st); + create_collection(app, view.vars, st); } }); }); @@ -599,18 +605,18 @@ fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: } ui.painter().with_clip_rect(cell).text(name_pos, Align2::LEFT_CENTER, &m.name, regular(11.0), t.text_secondary); if resp.double_clicked() { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); } crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, tr("Rename mode"), "") { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); ui.close(); } if menu_item(ui, tr("Duplicate mode"), "") { let name = unique(&format!("{} copy", m.name), |n| coll.modes.iter().any(|x| x.name == n)); let made = app.run("variable.addMode", json!({"collection": coll.id, "name": name, "from": m.id})); if let Some(id) = made.and_then(|r| r["id"].as_str().map(str::to_string)) { - start_rename(ui, st, Rename::Mode(coll.id.clone(), id)); + start_rename(st, Rename::Mode(coll.id.clone(), id)); } ui.close(); } @@ -645,7 +651,7 @@ fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: let name = unique("Mode", |n| coll.modes.iter().any(|m| m.name == n)); if let Some(id) = app.run("variable.addMode", json!({"collection": coll.id, "name": name})).and_then(|r| r["id"].as_str().map(str::to_string)) { - start_rename(ui, st, Rename::Mode(coll.id.clone(), id)); + start_rename(st, Rename::Mode(coll.id.clone(), id)); } } ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border)); @@ -679,7 +685,7 @@ fn var_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, v: &Va } if editable { if !inherited && resp.double_clicked() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| p.x < name_r.max.x) { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); } crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, tr("Edit variable"), "") { @@ -687,7 +693,7 @@ fn var_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, v: &Va ui.close(); } if !inherited && menu_item(ui, tr("Rename"), "") { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); ui.close(); } if !v.overrides.is_empty() && menu_item(ui, tr("Reset overrides"), "") { @@ -894,7 +900,7 @@ fn create_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: let params = json!({"collection": coll.id, "name": name, "type": kind.id()}); if let Some(id) = app.run("variable.create", params).and_then(|r| r["id"].as_str().map(str::to_string)) { st.query.clear(); - start_rename(ui, st, Rename::Variable(id)); + start_rename(st, Rename::Variable(id)); } ui.close(); } diff --git a/crates/ui-egui/src/variables_rename_tests.rs b/crates/ui-egui/src/variables_rename_tests.rs new file mode 100644 index 000000000..66a5e4e24 --- /dev/null +++ b/crates/ui-egui/src/variables_rename_tests.rs @@ -0,0 +1,82 @@ +//! Renames in the variables window, drawn headless with the screen reader tree on: creating a +//! collection opens its name for editing, and so does a double click on its row. On every frame +//! the focused node must be in the AccessKit tree; the platform adapter (UI Automation on Windows) +//! aborts the app when it isn't (#224). + +use egui::{Event, Key, Modifiers, PointerButton, Pos2}; +use figcraft_engine::Session; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, text_center}; + +/// The variables window open on a document without collections, with AccessKit on. +fn variables_window() -> Ui { + let mut u = Ui::new(Session::new()); + u.app.ui.variables = Some(Default::default()); + u.ctx.enable_accesskit(); + for _ in 0..10 { + u.frame(vec![]); + } + u +} + +/// One frame with `events`, checking the focused node is in the screen reader tree. +fn checked_frame(u: &mut Ui, events: Vec, k: usize) { + let out = u.frame_output(events); + let tree = out.platform_output.accesskit_update.expect("an AccessKit update"); + assert!(tree.nodes.iter().any(|(i, _)| *i == tree.focus), "frame {k}: the focused node {:?} isn't in the tree", tree.focus); +} + +/// `clicks` clicks at `p` (two make a double click) and the frames that follow, each checked. +fn click_checking_focus(u: &mut Ui, p: Pos2, clicks: usize) { + for _ in 0..10 { + u.frame(vec![Event::PointerMoved(p)]); + } + let presses = [true, false].into_iter().cycle().take(clicks * 2).map(Some); + for (k, pressed) in presses.chain(std::iter::repeat_n(None, 20)).enumerate() { + let events = pressed.map(|pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + checked_frame(u, events.into_iter().collect(), k); + } +} + +/// Type `name` and press Enter (each frame checked). +fn type_name(u: &mut Ui, name: &str) { + checked_frame(u, vec![Event::Text(name.into())], 0); + for pressed in [true, false] { + checked_frame(u, vec![Event::Key { key: Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }], 1); + } + for k in 0..5 { + checked_frame(u, vec![], k + 2); + } +} + +fn collection_names(u: &Ui) -> Vec { + u.app.session.doc().unwrap().variables.collections.iter().map(|c| c.name.clone()).collect() +} + +#[test] +fn creating_a_collection_puts_its_name_in_edit_with_the_focus_in_the_screen_reader_tree() { + let mut u = variables_window(); + let shapes = flat(&u.frame_output(vec![]).shapes); + click_checking_focus(&mut u, text_center(&shapes, "Create collection"), 1); + assert_eq!(collection_names(&u), ["Collection"], "a click creates one collection"); + // The keyboard is in its name: typing renames it. + type_name(&mut u, "Brand"); + assert_eq!(collection_names(&u), ["Brand"]); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); +} + +#[test] +fn double_clicking_a_collection_renames_it_with_the_focus_in_the_screen_reader_tree() { + let mut u = variables_window(); + u.app.session.execute("variable.createCollection", &serde_json::json!({"name": "Spacing"})).unwrap(); + for _ in 0..10 { + u.frame(vec![]); + } + let shapes = flat(&u.frame_output(vec![]).shapes); + click_checking_focus(&mut u, text_center(&shapes, "Spacing"), 2); + // The row's text was replaced by the field, whose text is selected: typing replaces it. + type_name(&mut u, "Space"); + assert_eq!(collection_names(&u), ["Space"]); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); +} From 3405a07d333820ed333c8ce3c0a0962dabb1ad44 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 11:24:31 -0400 Subject: [PATCH 040/120] Contributors: add tylerjenningsw's names --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index e61cfd31a..8062be9da 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -16,3 +16,7 @@ # # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. + +[people.tylerjenningsw] +real_name = "Tyler Jennings" +display_name = "tylerjenningsw" From f9b7aa03ca227d505d012c26b1ffad326d6a93e3 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Wed, 14 Oct 2026 17:27:01 +0200 Subject: [PATCH 041/120] M0.9: answer control requests while the window is hidden A minimised or covered main window draws no frames, and control requests were only answered inside a frame, so every control-channel and attached MCP call (save_file among them) timed out after 60 s while the app was out of sight. eframe still calls the app's logic then whenever a repaint is requested, which the control server already does for each request. The desktop app's logic now answers, while the window is hidden, the requests that need no frame: engine.execute, engine.commands, document.inspect, node.get, window.list and ui.render. Screenshots, input and the interface's own state still wait for the window, and their timeout now says that the window has to be on screen. Closes #269 --- ROADMAP.md | 4 +++- apps/figcraft/src/control_server.rs | 2 +- apps/figcraft/src/main.rs | 1 + crates/ui-egui/src/control.rs | 25 ++++++++++++++++++++++ crates/ui-egui/src/idle_tests.rs | 33 +++++++++++++++++++++++++++++ docs/control-protocol.md | 6 ++++++ docs/roadmap.md | 3 ++- 7 files changed, 71 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29b..4b0c22c50 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (control requests answered while the window is hidden (#269); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-14** · Control requests while the window is out of sight (M0.9, #269): a minimised or covered main window draws no frames, so every control and attached-MCP request (`save_file` among them) timed out after 60 s; the app's logic, which keeps running, now answers `engine.execute`, `engine.commands`, `document.inspect`, `node.get`, `window.list` and `ui.render` at once, and screenshots and input still wait for the window, timing out with a message that says it must be on screen. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. - **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged. @@ -312,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Control requests while the window is out of sight (M0.9, #269): a minimised or covered main window draws no frames, so every control and attached-MCP request (`save_file` among them) timed out after 60 s; the app's logic, which keeps running, now answers `engine.execute`, `engine.commands`, `document.inspect`, `node.get`, `window.list` and `ui.render` at once, and screenshots and input still wait for the window, timing out with a message that says it must be on screen. Numbers unchanged | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs index 5cfe12924..995240f50 100644 --- a/apps/figcraft/src/control_server.rs +++ b/apps/figcraft/src/control_server.rs @@ -107,7 +107,7 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s }, Err(_) => { cancelled.store(true, std::sync::atomic::Ordering::Relaxed); - json!({"id": id, "ok": false, "error": "timed out waiting for the UI (is the window visible?)"}) + json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"}) } } } diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 0332f7d83..b608fedc8 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -55,6 +55,7 @@ impl eframe::App for App { } let title = with_app(|fig| { control_server::drain_queue(&self.queue, &mut fig.control_queue); + figcraft_ui_egui::control::drain_hidden(fig, ctx); // Closing the window asks about unsaved changes (and keeps the app running while other // windows are open). figcraft_ui_egui::windows::on_close_request(fig, ctx); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 480824d0c..28f216715 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -136,6 +136,31 @@ pub fn drain(app: &mut FigApp, ctx: &Context) { } } +/// Methods that need no frame drawn (#269): a hidden window answers them too. +const NO_FRAME: &[&str] = &["engine.execute", "engine.commands", "document.inspect", "node.get", "window.list", "ui.render"]; + +/// While the main window is hidden (minimised, or covered by other windows) it draws no frames, +/// but the desktop app's `logic` still runs: it answers the requests for that window that need no +/// frame, so an attached agent can edit, inspect and save. The rest (screenshots, input, the +/// interface's own state) wait for the window to show. +pub fn drain_hidden(app: &mut FigApp, ctx: &Context) { + if ctx.input(|i| i.viewport().visible()) != Some(false) { + return; + } + for r in std::mem::take(&mut app.control_queue) { + if r.is_cancelled() { + continue; + } + let main = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused) == 0; + if !main || !NO_FRAME.contains(&r.method.as_str()) { + app.control_queue.push(r); + continue; + } + let res = handle(app, ctx, &r.method, &r.params); + let _ = r.reply.send(ControlReply { id: r.id, result: res }); + } +} + fn save_color_image(img: &egui::ColorImage, path: &str) -> Result<(), String> { let [w, h] = img.size; let mut rgba = Vec::with_capacity(w * h * 4); diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 2d4e57579..fb0759a13 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -288,3 +288,36 @@ fn an_animated_gif_wakes_only_the_presenter_and_only_at_its_frames() { h.app.ui.present = None; h.assert_idle("the presenter closed"); } + +#[test] +fn a_hidden_window_answers_requests_that_need_no_frame() { + // #269: minimised or covered, the window draws no frames, but the app's logic still runs: it + // answers edits, inspection and saves; what needs a frame waits for the window to show. + let dir = std::env::temp_dir().join(format!("figcraft-hidden-save-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("hidden.figcraft"); + let (mut h, _) = document(); + h.settle(); + let (tx, rx) = std::sync::mpsc::channel(); + let request = |id: u64, method: &str, params: Value| ControlRequest { + id: json!(id), + method: method.into(), + params, + reply: tx.clone(), + cancelled: Default::default(), + }; + h.app.control_queue.push(request(1, "engine.execute", json!({"command": "file.save", "params": {"path": path.to_string_lossy()}}))); + h.app.control_queue.push(request(2, "ui.inspect", json!({}))); + let mut hidden = RawInput::default(); + hidden.viewports.insert(ViewportId::ROOT, egui::ViewportInfo { minimized: Some(true), ..Default::default() }); + let app = &mut h.app; + let _ = h.ctx.run_logic(&hidden, |ctx| crate::control::drain_hidden(app, ctx)); + let saved = rx.try_recv().unwrap(); + assert_eq!(saved.id, json!(1)); + assert!(saved.result.is_ok() && path.exists(), "saved while hidden: {:?}", saved.result); + assert!(rx.try_recv().is_err(), "the interface's state waits for a frame"); + // Shown again: the next frame answers it. + h.frame(vec![]); + assert_eq!(rx.try_recv().unwrap().id, json!(2)); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9fc9937d9..f2c049ce4 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -35,6 +35,12 @@ instead of dispatching the new request. These limits apply to the opt-in loopbac requests still queued after the UI timeout are discarded, including requests deferred to a hidden window. A command already running is not interrupted. +A minimised or covered main window draws no frames, but still answers `engine.execute`, +`engine.commands`, `document.inspect`, `node.get`, `window.list` and `ui.render`, so an attached +agent can edit, inspect and save while the app is out of sight. Screenshots, input (`ui.input`, +`ui.pointer`, `ui.key`, `ui.type`) and the interface's own state wait for the window to show, and +time out after 60 s if it doesn't. + Coordinates are page units, y down. `x`/`y` in `node.set`/`node.create` are relative to the parent frame (groups have no coordinate space of their own). diff --git a/docs/roadmap.md b/docs/roadmap.md index 8e2780d68..39633f640 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (control requests answered while the window is hidden (#269); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Control requests while the window is out of sight (M0.9, #269): a minimised or covered main window draws no frames, so every control and attached-MCP request (`save_file` among them) timed out after 60 s; the app's logic, which keeps running, now answers `engine.execute`, `engine.commands`, `document.inspect`, `node.get`, `window.list` and `ui.render` at once, and screenshots and input still wait for the window, timing out with a message that says it must be on screen. Numbers unchanged | | 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | From 1aeb5abb74b4b057af82d76bc7c9b42dd9233c66 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 17:43:32 +0200 Subject: [PATCH 042/120] M1: a click on the toolbar no longer reaches the canvas under it (#307) --- crates/ui-egui/src/pen_mode_tests.rs | 16 ++++++++++++++++ crates/ui-egui/src/toolbar.rs | 4 ++++ 2 files changed, 20 insertions(+) diff --git a/crates/ui-egui/src/pen_mode_tests.rs b/crates/ui-egui/src/pen_mode_tests.rs index 6fa54959d..31af1c663 100644 --- a/crates/ui-egui/src/pen_mode_tests.rs +++ b/crates/ui-egui/src/pen_mode_tests.rs @@ -92,3 +92,19 @@ fn the_mode_switcher_ends_the_pen_path_too() { assert_eq!(layers(&u), finished, "{label}: the path is kept as Escape keeps it"); } } + +#[test] +fn a_press_on_the_mode_switcher_does_not_reach_the_canvas() { + // The switcher floats over the canvas: the pen must not place an anchor under it. + for label in ["Prototype", "Design"] { + let mut u = Ui::new(Session::new()); + u.app.run("tool.set", json!({"tool": "pen"})); + let at = mode_button(&mut u, label); + u.button(at, true); + assert!(u.app.session.tool.pen.is_none(), "{label}: the press starts no path"); + u.button(at, false); + u.frame(vec![]); + assert!(u.app.session.tool.pen.is_none(), "{label}: no path after the click"); + assert_eq!(layers(&u), 0, "{label}: the click adds nothing"); + } +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 24af4a640..3e4a224e1 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -329,6 +329,10 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) { /// toolbar region keyboard navigation moves through. fn pill(ui: &Ui, nav: &mut Nav, canvas: Rect, width: f32, t: &Tokens) -> Rect { let rect = Rect::from_center_size(pos2(canvas.center().x, canvas.max.y - 12.0 - 24.0), vec2(width, 48.0)); + // The pill takes the press and the drag over it: its buttons sense clicks only, and egui gives + // a drag the canvas under them would sense to the canvas, so the pen placed an anchor there. + // Not focusable: the keyboard walks the buttons, not the pill. + ui.interact(rect, ui.id().with("pill"), Sense::CLICK | Sense::DRAG); a11y::area(nav, Region::Toolbar, rect); a11y::container_bounds(ui, rect); let painter = ui.painter(); From 698e249fbf2ef679f58d1c81e0efd51bbb37fff6 Mon Sep 17 00:00:00 2001 From: Barzabel Date: Wed, 14 Oct 2026 18:45:15 +0300 Subject: [PATCH 043/120] M1: drop shadows under a background blur (#255) A layer with a background blur (or glass) drew its blurred backdrop into its own pixels before casting its drop shadows from them, so a translucent fill cast a shadow as dark as an opaque one, and with "show behind transparent areas" the shadow was cast from the backdrop's pixels. The backdrop was also read before the shadows were drawn, so a shadow showing behind was never seen through the blur. When a layer has both, it now casts its shadows from what it paints (with the layer blur, noise and texture it will get), draws them, and only then reads the backdrop, which goes under its content; the content still sees the first backdrop under it while it's drawn. Layers without both draw as before. --- crates/render/src/backdrop_shadow_tests.rs | 79 ++++++++++++++++++++ crates/render/src/draw.rs | 86 +++++++++++++++------- crates/render/src/lib.rs | 2 + 3 files changed, 142 insertions(+), 25 deletions(-) create mode 100644 crates/render/src/backdrop_shadow_tests.rs diff --git a/crates/render/src/backdrop_shadow_tests.rs b/crates/render/src/backdrop_shadow_tests.rs new file mode 100644 index 000000000..9c5fb5627 --- /dev/null +++ b/crates/render/src/backdrop_shadow_tests.rs @@ -0,0 +1,79 @@ +//! A drop shadow under a layer with a background blur (#255): the shadow is cast by what the layer +//! paints (a translucent fill casts a faint one), not by the blurred backdrop drawn through its +//! shape, and the blur reads what lies under the layer with its own shadow there, so a shadow +//! that shows behind transparent areas is seen, blurred, through the layer. + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Blur, Document, Effect, EffectKind, NodeId, NodeKind, Shadow}; + +use super::*; + +/// A white 240 × 200 frame with blue and green stripes and, on top, a pane at (60, 40) 120 × 80 +/// filled 20% black, with a hard black drop shadow 40 down (below it and under its lower half) +/// and, when `blur`, a background blur. +fn scene(show_behind: bool, blur: bool) -> (Document, NodeId) { + let mut d = Document::new("shadow behind blur"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "Frame", 0.0, 0.0, 240.0, 200.0).unwrap(); + d.get_mut(f).unwrap().fills = vec![Paint::solid(Color::from_hex("FFFFFF").unwrap())]; + for (i, x) in [20.0, 80.0, 140.0, 200.0].into_iter().enumerate() { + let s = d.create(f, NodeKind::Rectangle, "Stripe", x, 0.0, 20.0, 200.0).unwrap(); + d.get_mut(s).unwrap().fills = vec![Paint::solid(Color::from_hex(if i % 2 == 0 { "2040E0" } else { "20C060" }).unwrap())]; + } + let pane = d.create(f, NodeKind::Rectangle, "Pane", 60.0, 40.0, 120.0, 80.0).unwrap(); + let n = d.get_mut(pane).unwrap(); + n.fills = vec![Paint::solid(Color::new(0.0, 0.0, 0.0, 0.2))]; + let shadow = Shadow { color: Color::new(0.0, 0.0, 0.0, 1.0), offset: (0.0, 40.0), radius: 0.0, show_behind, ..Shadow::default() }; + n.effects = vec![Effect { kind: EffectKind::DropShadow(shadow), visible: true }]; + if blur { + n.effects.push(Effect { kind: EffectKind::BackgroundBlur(Blur::uniform(8.0)), visible: true }); + } + (d, f) +} + +fn px(p: &Pixmap, x: u32, y: u32) -> [u8; 4] { + let i = ((y * p.width() + x) * 4) as usize; + [p.data()[i], p.data()[i + 1], p.data()[i + 2], p.data()[i + 3]] +} + +/// The largest channel difference between `a` and `b` over the shadow below the pane +/// (60..180 × 122..160). +fn max_diff_below_pane(a: &Pixmap, b: &Pixmap) -> u8 { + let mut worst = 0; + for y in 122..160 { + for x in 60..180 { + worst = worst.max(px(a, x, y).iter().zip(px(b, x, y)).map(|(c, d)| c.abs_diff(d)).max().unwrap()); + } + } + worst +} + +#[test] +fn a_background_blur_doesnt_change_the_shadow_its_layer_casts() { + for show_behind in [false, true] { + let (with_blur, f) = scene(show_behind, true); + let (without, _) = scene(show_behind, false); + let got = render_node(&with_blur, f, 1.0).unwrap(); + let want = render_node(&without, f, 1.0).unwrap(); + let diff = max_diff_below_pane(&got, &want); + assert!(diff <= 2, "show behind {show_behind}: the shadow below the pane differs by {diff}"); + // A 20% fill casts a faint shadow: the white between the stripes stays light. + let light = px(&got, 120, 140); + assert!(light[0] > 180, "show behind {show_behind}: {light:?}"); + } +} + +#[test] +fn a_shadow_showing_behind_is_seen_through_the_background_blur() { + let (d, f) = scene(true, true); + let got = render_node(&d, f, 1.0).unwrap(); + // Between the stripes, the pane's top (no shadow under it) and its lower half (its own shadow + // under it, from y = 80). + let (top, low) = (px(&got, 120, 50), px(&got, 120, 110)); + assert!(top[0] > low[0] + 20, "the shadow under the pane doesn't show through: top {top:?}, lower half {low:?}"); + // Hidden behind the layer, it doesn't. + let (d, f) = scene(false, true); + let got = render_node(&d, f, 1.0).unwrap(); + let (top, low) = (px(&got, 120, 50), px(&got, 120, 110)); + assert!(top[0].abs_diff(low[0]) <= 2, "a hidden shadow shows: top {top:?}, lower half {low:?}"); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index be839b277..af73aeebb 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -423,16 +423,15 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep let shape = node_outline(ctx.doc, n); // Background blur and glass: the whole backdrop under the layer (every surface below this // one, composited), through the shape, under the content. - if backdrop::has_effects(n) - && let Some(sh) = &backdrop::outline(ctx.doc, n) - { - let pad = backdrop::pad(n, ctx.scale); - let p = i32::try_from(pad).unwrap_or(0); - if let Some(back) = backdrop::backdrop(pix, ctx.under, lx.saturating_sub(p), ly.saturating_sub(p), lw + pad * 2, lh + pad * 2) - && let Some(b) = backdrop::effects(n, sh, &back, pad, lw, lh, lxf, ctx.scale) - { - layer.draw_pixmap(0, 0, b.as_ref(), &PixmapPaint::default(), Transform::identity(), None); - } + let back_shape = if backdrop::has_effects(n) { backdrop::outline(ctx.doc, n) } else { None }; + let read_backdrop = |pix: &Pixmap| back_shape.as_ref().and_then(|sh| backdrop_layer(ctx, pix, n, sh, (lx, ly), (lw, lh), lxf)); + // With drop shadows too, the layer casts them from what it paints, not from the backdrop, and + // the backdrop is read again once they lie under it (a shadow showing behind is seen through + // it). Until then, the layer's content sees the backdrop read first under it (#255). + let casts = back_shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::DropShadow(_))); + let first_back = read_backdrop(pix); + if !casts && let Some(b) = &first_back { + layer.draw_pixmap(0, 0, b.as_ref(), &PixmapPaint::default(), Transform::identity(), None); } let op = n.opacity.clamp(0.0, 1.0); { @@ -448,9 +447,12 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep } }; let has_inner = shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::InnerShadow(_))); - // Backdrops further down see this layer composited onto `pix`. + // Backdrops further down see this layer composited onto `pix` (over its backdrop, when + // that is kept apart for now). let under = Under { opacity: op, blend: n.blend, ..Under::new(pix, (lx, ly), ctx.under) }; - draw_content(&Ctx { under: Some(&under), ..*ctx }, &mut layer, n, lxf, depth, has_inner.then_some(&inner as &dyn Fn(&mut Pixmap))); + let over_back = first_back.as_ref().filter(|_| casts).map(|b| Under::new(b, (0, 0), Some(&under))); + let content_under = over_back.as_ref().unwrap_or(&under); + draw_content(&Ctx { under: Some(content_under), ..*ctx }, &mut layer, n, lxf, depth, has_inner.then_some(&inner as &dyn Fn(&mut Pixmap))); } // A group is no outline of its own: its inner shadows are cast from around what its layers // draw together, over them. @@ -465,24 +467,58 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep } } // Layer blur, noise and texture, in list order, over everything else. + if !casts { + layer_effects(ctx, &mut layer, n, id, lxf); + } + // Drop shadows go under the layer, in effect-list order. + { + // Kept apart, the backdrop isn't in the layer yet: the shadows are cast from the content + // with the layer effects it will get. + let content = casts.then(|| { + let mut c = layer.clone(); + layer_effects(ctx, &mut c, n, id, lxf); + c + }); + let caster = shadow::Caster::new(ctx.doc, n, content.as_ref().unwrap_or(&layer), lxf, ctx.scale); + for e in n.effects.iter().filter(|e| e.visible) { + if let EffectKind::DropShadow(s) = &e.kind + && let Some(sh) = shadow::drop_shadow(&caster, s) + { + composite(pix, (lx, ly), &sh, op, s.blend); + } + } + } + if casts { + // The backdrop with the shadows under it, the content over it, then the layer effects. + if let Some(mut b) = read_backdrop(pix) { + b.draw_pixmap(0, 0, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), None); + layer = b; + } + layer_effects(ctx, &mut layer, n, id, lxf); + } + composite(pix, (lx, ly), &layer, op, n.blend); +} + +/// Node `n`'s backdrop effects through its backdrop outline `sh`, for its `size` layer at `at` on +/// `pix` (local → layer pixels through `lxf`): what `pix` holds there now, over what lies under it. +fn backdrop_layer(ctx: &Ctx, pix: &Pixmap, n: &Node, sh: &shadow::Outline, at: (i32, i32), size: (u32, u32), lxf: Affine) -> Option { + let ((lx, ly), (lw, lh)) = (at, size); + let pad = backdrop::pad(n, ctx.scale); + let p = i32::try_from(pad).unwrap_or(0); + let back = backdrop::backdrop(pix, ctx.under, lx.saturating_sub(p), ly.saturating_sub(p), lw + pad * 2, lh + pad * 2)?; + backdrop::effects(n, sh, &back, pad, lw, lh, lxf, ctx.scale) +} + +/// Node `n`'s layer blur, noise and texture on its `layer`, in list order. +fn layer_effects(ctx: &Ctx, layer: &mut Pixmap, n: &Node, id: NodeId, lxf: Affine) { for e in n.effects.iter().filter(|e| e.visible) { match &e.kind { - EffectKind::LayerBlur(b) => blur::apply(&mut layer, b, (n.width, n.height), lxf, ctx.scale), - EffectKind::Noise(nz) => grain::noise(&mut layer, nz, lxf, id.0 as u32), - EffectKind::Texture(t) => grain::texture(&mut layer, t, lxf, ctx.scale, id.0 as u32), + EffectKind::LayerBlur(b) => blur::apply(layer, b, (n.width, n.height), lxf, ctx.scale), + EffectKind::Noise(nz) => grain::noise(layer, nz, lxf, id.0 as u32), + EffectKind::Texture(t) => grain::texture(layer, t, lxf, ctx.scale, id.0 as u32), _ => {} } } - // Drop shadows go under the layer, in effect-list order. - let caster = shadow::Caster::new(ctx.doc, n, &layer, lxf, ctx.scale); - for e in n.effects.iter().filter(|e| e.visible) { - if let EffectKind::DropShadow(s) = &e.kind - && let Some(sh) = shadow::drop_shadow(&caster, s) - { - composite(pix, (lx, ly), &sh, op, s.blend); - } - } - composite(pix, (lx, ly), &layer, op, n.blend); } fn draw_outline(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c2bf9dda0..1025d507e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -425,6 +425,8 @@ pub fn png_insert_chunk(png: Vec, kind: &[u8; 4], data: &[u8]) -> Vec { out } +#[cfg(test)] +mod backdrop_shadow_tests; #[cfg(test)] mod backdrop_tests; #[cfg(test)] From 72390d7b51a9ed9cfdbe335176097eb433638036 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 11:57:58 -0400 Subject: [PATCH 044/120] fix(canvas): preview in the browser and judge previews by coverage (M8) Review of the zoom previews found three problems: - The browser worker never previewed: wasm builds timed every render as 0 ms, below the 12 ms threshold. Renders are now timed with web-time's monotonic clock on every target (already in the tree via the engine), and the inline worker is built everywhere so a test runs it with the production threshold: preview one frame, refine the next, a newer request skips the old refinement. - A preview was refused whenever the texture on screen was refined at the same zoom, even when that texture showed another part of the page (back at 100% about another point after 800%: 1/64 of the view). It now has to show the whole view asked for, not just its zoom. - The latencies read the frame's start time before the inline render and before the texture was set. They now run on the same clock from the request to the image handed to egui as the canvas texture. Refs #295 --- Cargo.lock | 1 + crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/canvas.rs | 48 ++++++++----- crates/ui-egui/src/canvas_fixes_tests.rs | 2 +- crates/ui-egui/src/idle_tests.rs | 2 +- crates/ui-egui/src/render_worker.rs | 26 ++++---- crates/ui-egui/src/zoom_preview_tests.rs | 85 +++++++++++++++++++++--- 7 files changed, 124 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e9c6f684..c97678dbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1707,6 +1707,7 @@ dependencies = [ "serde_json", "toml", "wat", + "web-time", ] [[package]] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index b14c04f21..65a3592d8 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -25,6 +25,7 @@ log = { workspace = true } base64 = { workspace = true } image = { workspace = true } egui_extras = { workspace = true } +web-time = "1.1.0" [build-dependencies] serde_json = { workspace = true } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 90a993843..b42c940b9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -24,11 +24,12 @@ pub struct CanvasState { /// The texture is a coarse preview (see [`crate::render_worker`]). pub tex_coarse: bool, submitted: Option, - /// The scale (device pixels per page unit) of the last render asked for. - want_scale: f64, + /// The view of the last render asked for. + want_view: Option, /// What the last canvas render had to do (shown by `ui.inspect`). pub render_work: Option, - /// From asking for a render to the frame that shows its preview, and its refined image (ms). + /// From asking for a render to its preview, and its refined image, handed to egui as the + /// canvas texture (ms; the texture is uploaded when that frame is painted). pub preview_latency_ms: Option, pub refine_latency_ms: Option, dragging: bool, @@ -58,7 +59,7 @@ impl Default for CanvasState { tex_of: None, tex_coarse: false, submitted: None, - want_scale: 0.0, + want_view: None, render_work: None, preview_latency_ms: None, refine_latency_ms: None, @@ -184,19 +185,14 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { let own_cursor = input(app, ui, &resp); crate::live::track_pointer(app, &ctx); app.sync_preedit(); - let now = ctx.input(|i| i.time); - request_render(app, worker, ppp, now); + request_render(app, worker, ppp); // Every finished render is shown, even when a newer one is already on its way: renders finish // in order, and an edit every frame (a drag) would otherwise never show until it stopped. let shown = app.session.ds().map(|ds| (ds.uid, ds.page)).ok(); if let Some(done) = worker.poll() && shows(&app.canvas, &done, shown) { - let latency = Some((now - done.at).max(0.0) * 1000.0); - if done.coarse { - app.canvas.preview_latency_ms = latency; - } else { - app.canvas.refine_latency_ms = latency; + if !done.coarse { app.canvas.render_work = Some(done.work); app.ui.render_ms = done.ms; } @@ -210,6 +206,12 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { Some(t) => t.set(done.image, filter), None => app.canvas.texture = Some(ctx.load_texture("figcraft-canvas", done.image, filter)), } + let latency = Some(done.at.elapsed().as_secs_f64() * 1000.0); + if done.coarse { + app.canvas.preview_latency_ms = latency; + } else { + app.canvas.refine_latency_ms = latency; + } app.canvas.tex_view = Some(done.view); app.canvas.tex_of = Some((done.uid, done.page)); app.canvas.tex_coarse = done.coarse; @@ -296,16 +298,30 @@ fn describe_box([x0, y0, x1, y1]: [f64; 4]) -> String { } /// Whether a finished render goes on screen: an image of the file and page shown (`shown`), and -/// not a coarse preview over a refined image already at the zoom asked for. +/// not a coarse preview over a refined image that already shows the whole view asked for at its +/// zoom (the same zoom elsewhere on the page is no such image). fn shows(c: &CanvasState, done: &Done, shown: Option<(u64, NodeId)>) -> bool { if shown != Some((done.uid, done.page)) { return false; } - let refined_here = !c.tex_coarse && c.tex_of == shown && c.tex_view.as_ref().is_some_and(|v| v.scale.to_bits() == c.want_scale.to_bits()); + let refined_here = !c.tex_coarse + && c.tex_of == shown + && c.tex_view + .as_ref() + .zip(c.want_view.as_ref()) + .is_some_and(|(have, want)| have.scale.to_bits() == want.scale.to_bits() && covers(have, want)); !(done.coarse && refined_here) } -fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32, now: f64) { +/// Whether `outer` shows all of the page that `inner` does. +fn covers(outer: &figcraft_render::View, inner: &figcraft_render::View) -> bool { + let (o, i) = (outer.page_rect(), inner.page_rect()); + // Rounding in the snapped origin, well under a device pixel. + let eps = 1e-6 / inner.scale.max(1e-9); + o.x0 <= i.x0 + eps && o.y0 <= i.y0 + eps && o.x1 >= i.x1 - eps && o.y1 >= i.y1 - eps +} + +fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { let Ok(ds) = app.session.ds() else { return }; let rect = app.canvas.rect; let (mut ox, mut oy) = ds.view.origin(); @@ -352,9 +368,9 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32, now: f64) { proof, uid: ds.uid, zoom_preview: ds.view.pixel_preview_scale().is_none(), - at: now, + at: web_time::Instant::now(), }; - app.canvas.want_scale = scale; + app.canvas.want_view = Some(job.view.clone()); worker.submit(job); app.canvas.submitted = Some(key); } diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs index 1c4938d73..0220b353f 100644 --- a/crates/ui-egui/src/canvas_fixes_tests.rs +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -64,7 +64,7 @@ fn big_job(doc: &Arc, page: figcraft_doc::NodeId, scale: proof: false, uid: 0, zoom_preview: false, - at: 0.0, + at: web_time::Instant::now(), } } diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 4a277ae21..0b8487471 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -214,7 +214,7 @@ fn a_finished_background_render_wakes_the_ui() { proof: false, uid: 0, zoom_preview: false, - at: 0.0, + at: web_time::Instant::now(), }); let start = Instant::now(); while wakes.load(Ordering::SeqCst) == 0 { diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index ee9788906..3ad9c54c3 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -14,6 +14,8 @@ use std::sync::Arc; use figcraft_color::ColorProfile; use figcraft_doc::{Document, NodeId}; use figcraft_render::{Pixmap, Retained, View, Work}; +// A monotonic clock in the browser too: preview decisions rest on how long renders take there. +use web_time::Instant; /// A preview has this many times fewer pixels each way than the view it stands in for. pub const PREVIEW_FACTOR: f64 = 4.0; @@ -40,8 +42,8 @@ pub struct Job { /// A coarse preview may come first when the zoom changes (not in pixel preview, whose pixels /// are the point). pub zoom_preview: bool, - /// When the UI asked for it (`egui` input time, seconds): how long the image took to show. - pub at: f64, + /// When the UI asked for it: how long the image took to reach the screen. + pub at: Instant, } pub struct Done { @@ -57,7 +59,7 @@ pub struct Done { /// A coarse preview, refined by a later image unless a newer request replaced it. pub coarse: bool, /// [`Job::at`]. - pub at: f64, + pub at: Instant, } /// A render of a document in `profile` as a texture image for the screen (sRGB). @@ -84,8 +86,7 @@ fn mark_outside_srgb(image: &mut egui::ColorImage, pix: &Pixmap, profile: ColorP /// Render `view` of the job (its own view, or the coarse one standing in for it). fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option { - #[cfg(not(target_arch = "wasm32"))] - let t0 = std::time::Instant::now(); + let t0 = Instant::now(); // The canvas is a preview: what's too small to see in detail at this zoom is approximated. let opts = figcraft_render::Options { outline_mode: job.outline, @@ -101,10 +102,7 @@ fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option< if job.proof { mark_outside_srgb(&mut image, pix, job.doc.color_profile); } - #[cfg(not(target_arch = "wasm32"))] let ms = t0.elapsed().as_secs_f64() * 1000.0; - #[cfg(target_arch = "wasm32")] - let ms = 0.0; Some(Done { image, view, ms, work, uid: job.uid, page: job.page, coarse, at: job.at }) } @@ -257,10 +255,10 @@ impl Worker { } } -/// Renders inline, one image a frame: a preview first when one is due, the refined image in the -/// next frame unless a newer request replaced the job. -#[cfg(target_arch = "wasm32")] -pub struct Worker { +/// The browser's worker: renders inline, one image a frame: a preview first when one is due, the +/// refined image in the next frame unless a newer request replaced the job. Built everywhere so +/// tests run it. +pub struct Inline { ctx: egui::Context, pending: Option, previewed: bool, @@ -268,7 +266,9 @@ pub struct Worker { } #[cfg(target_arch = "wasm32")] -impl Worker { +pub type Worker = Inline; + +impl Inline { pub fn new(ctx: egui::Context) -> Self { Self { ctx, pending: None, previewed: false, renders: Renders::default() } } diff --git a/crates/ui-egui/src/zoom_preview_tests.rs b/crates/ui-egui/src/zoom_preview_tests.rs index cd17a70a3..687045715 100644 --- a/crates/ui-egui/src/zoom_preview_tests.rs +++ b/crates/ui-egui/src/zoom_preview_tests.rs @@ -1,7 +1,8 @@ //! Coarse previews while the zoom changes (#295): a new zoom first shows the whole view coarsely, //! then the refined image, which matches a render from scratch; pans and edits still build on the -//! kept image; previews never replace a refined image already at the zoom asked for, and no image -//! of another file or page goes on screen. +//! kept image; previews never replace a refined image that already shows the whole view asked +//! for, and no image of another file or page goes on screen. The browser's inline worker previews +//! by the same measure as the render thread. use std::sync::Arc; @@ -12,7 +13,7 @@ use figcraft_render::{View, Work}; use serde_json::json; use super::{CanvasState, shows}; -use crate::render_worker::{Done, Job, PREVIEW_FACTOR, Renders, coarse_view, render_now}; +use crate::render_worker::{Done, Inline, Job, PREVIEW_FACTOR, Renders, coarse_view, render_now}; /// A page of ellipses with a rectangle on top; the session and the rectangle. fn document() -> (Session, NodeId) { @@ -44,7 +45,7 @@ fn job(doc: &Arc, page: NodeId, uid: u64, view: View) -> Job { proof: false, uid, zoom_preview: true, - at: 0.0, + at: web_time::Instant::now(), } } @@ -189,7 +190,7 @@ fn outline_mode_proofing_and_glass_preview_and_settle_like_fresh_renders() { } #[test] -fn the_canvas_shows_images_of_its_own_page_and_never_a_coarser_one_at_its_zoom() { +fn the_canvas_shows_images_of_its_own_page_and_never_a_coarser_one_of_its_view() { let (s, _) = document(); let ds = s.ds().unwrap(); let here = Some((ds.uid, ds.page)); @@ -199,21 +200,85 @@ fn the_canvas_shows_images_of_its_own_page_and_never_a_coarser_one_at_its_zoom() let far = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 0.5, 200, 150)); let preview = r.preview(&far).unwrap(); - let mut c = CanvasState { want_scale: near.view.scale, ..Default::default() }; + let mut c = CanvasState { want_view: Some(near.view.clone()), ..Default::default() }; assert!(shows(&c, &refined, here)); assert!(!shows(&c, &refined, Some((ds.uid + 1, ds.page))), "another file"); assert!(!shows(&c, &refined, Some((ds.uid, NodeId(ds.page.0 + 1)))), "another page"); assert!(!shows(&c, &refined, None), "no file open"); - // Showing the refined image at the zoom asked for: a preview arriving late stays off. + // Showing the refined image of the view asked for: a preview arriving late stays off. (c.tex_view, c.tex_of, c.tex_coarse) = (Some(refined.view.clone()), here, false); assert!(!shows(&c, &preview, here)); - // Once the zoom asked for moves on, the preview is the better match. - c.want_scale = far.view.scale; + // Once the view asked for moves on, the preview is the better match. + c.want_view = Some(far.view.clone()); assert!(shows(&c, &preview, here)); // Over another preview, or a refined image of another page, it goes on. - c.want_scale = near.view.scale; + c.want_view = Some(near.view.clone()); c.tex_coarse = true; assert!(shows(&c, &preview, here)); (c.tex_coarse, c.tex_of) = (false, Some((ds.uid, NodeId(ds.page.0 + 1)))); assert!(shows(&c, &preview, here)); } + +#[test] +fn a_preview_replaces_a_refined_image_of_the_same_zoom_elsewhere_on_the_page() { + // 800 × 600: A at 100% from the page origin, B at 800% about its top-left, A' back at 100% + // about B's bottom-right corner. A shows 100 × 75 of A', 1/64 of it. + let (s, _) = document(); + let ds = s.ds().unwrap(); + let here = Some((ds.uid, ds.page)); + let view = |origin, scale| View { origin, scale, width: 800, height: 600 }; + let a = job(&ds.doc, ds.page, ds.uid, view((0.0, 0.0), 1.0)); + let b = job(&ds.doc, ds.page, ds.uid, view((0.0, 0.0), 8.0)); + let a2 = job(&ds.doc, ds.page, ds.uid, view((-700.0, -525.0), 1.0)); + let mut r = Renders::with_threshold(0.0); + let refined_a = r.refine(&a).unwrap(); + // The canvas shows A; B is asked for, then A' while B renders. The thread finishes B, then + // previews A' before the canvas polls: the queue holds [refined B, preview A'], and a poll + // takes the last. + let queue = [r.refine(&b).unwrap(), r.preview(&a2).expect("A' is a new zoom for the thread")]; + let polled = queue.last().unwrap(); + assert!(polled.coarse && covers(&polled.view, &a2.view)); + let c = CanvasState { + tex_view: Some(refined_a.view.clone()), + tex_of: here, + tex_coarse: false, + want_view: Some(a2.view.clone()), + ..Default::default() + }; + assert!(shows(&c, polled, here), "A at the same zoom shows 1/64 of A': the preview of all of it goes on"); + // Once A' itself is refined on screen, a preview of it would be a step down. + let refined_a2 = r.refine(&a2).unwrap(); + let c = CanvasState { tex_view: Some(refined_a2.view.clone()), ..c }; + assert!(!shows(&c, polled, here)); +} + +#[test] +fn the_browser_worker_previews_by_measured_render_time_then_refines_the_next_frame() { + // Big enough that a render from scratch takes well over a frame: blurs and glass on 2400². + let (mut s, rect) = document(); + s.execute("effect.add", &json!({"ids": [rect], "type": "GLASS"})).unwrap(); + s.execute("effect.add", &json!({"ids": [rect], "type": "BACKGROUND_BLUR"})).unwrap(); + let ds = s.ds().unwrap(); + let at = |scale| job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), scale, 2400, 2400)); + // The production threshold, as the browser has it. + let mut w = Inline::new(egui::Context::default()); + w.submit(at(2.0)); + let first = w.poll().expect("the first image"); + assert!(!first.coarse && !w.in_flight(), "the first render has no measure to preview by"); + assert!(first.ms >= 12.0, "renders are timed in the browser worker too (and this one is slow): {} ms", first.ms); + // A new zoom: the preview this frame, the refined image the next. + w.submit(at(1.0)); + let p = w.poll().expect("a preview"); + assert!(p.coarse && w.in_flight()); + let d = w.poll().expect("the refined image"); + assert!(!d.coarse && !w.in_flight()); + assert!(same_pixels(&d, &render_now(&at(1.0)).unwrap())); + // A newer request before the refinement: refining the old one is skipped. + w.submit(at(0.5)); + assert!(w.poll().is_some_and(|d| d.coarse)); + w.submit(at(0.25)); + let p = w.poll().expect("the newer one's preview"); + assert!(p.coarse && (p.view.scale - 0.25 / PREVIEW_FACTOR).abs() < 1e-12); + assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 0.25)); + assert!(w.poll().is_none() && !w.in_flight()); +} From 82d84d86b1eb431787b9b59673913ad608895d2b Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 11:59:24 -0400 Subject: [PATCH 045/120] docs: zoom preview latency boundary and measurements (M8) Refs #295 --- ROADMAP.md | 2 +- docs/control-protocol.md | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 826a625d8..03cbe6c43 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,7 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log -- **2026-10-14** · Coarse zoom previews (M8, #295): a new zoom can't reuse the retained canvas image, and the old one, re-projected, covers 1/64 of the view after a zoom out from 800% to 100%, so the page colour showed until the full render was done. When renders from scratch take a frame or more, the render thread first renders the whole view with a quarter of the pixels each way (kept apart from the full image, so pans and edits still build on it) and then refines it unless a newer request waits; the web build does the two in consecutive frames; pixel preview gets none. The canvas never shows an image of another file or page, and a late preview never replaces a refined image at the zoom asked for. `ui.inspect` gives `canvas.quality`, `previewLatencyMs` and `refineLatencyMs`; `zoom_preview_tests`. Measured on Windows (600 layers with shadows, background blurs and glass, 904×860 canvas): 800% → 100% shows the whole view 7–15 ms after the request and the refined image at 63–69 ms (a 56 ms render). Numbers unchanged. +- **2026-10-14** · Coarse zoom previews (M8, #295): a new zoom can't reuse the retained canvas image, and the old one, re-projected, covers 1/64 of the view after a zoom out from 800% to 100%, so the page colour showed until the full render was done. When renders from scratch take a frame or more, the render thread first renders the whole view with a quarter of the pixels each way (kept apart from the full image, so pans and edits still build on it) and then refines it unless a newer request waits; the web build does the two in consecutive frames (renders are timed with a monotonic clock there too); pixel preview gets none. The canvas never shows an image of another file or page, and a late preview never replaces a refined image that already shows the whole view asked for. `ui.inspect` gives `canvas.quality`, `previewLatencyMs` and `refineLatencyMs` (request to texture handed to egui); `zoom_preview_tests`. Measured on Windows (600 layers with shadows, background blurs and glass, 904×860 canvas): 800% → 100% has the preview at 14–15 ms and the refined image at 63–73 ms (a 50 ms render); 100% → 800% about 15 ms and 190–198 ms. Left: the first render, and the first slow one after quick ones, come without a preview (the measure is the previous render from scratch). Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. - **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1b6cef46f..df50a7fef 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -95,8 +95,10 @@ an idle window draws none) with the most frequent repaint requests behind them ( the autosave deadline included; egui's `context.rs` begin-pass line means input events) and what the last canvas render had to do (`canvas.renderWork`: `full`, `partial` with the `pixels` re-rendered after a pan or an edit, or `reused`), whether the canvas shows a coarse zoom preview or the refined image (`canvas.quality`: `preview` or `refined`) and the time from -asking for a render to the frame showing its preview and its refined image (`canvas.previewLatencyMs`, -`canvas.refineLatencyMs`; `null` until there was one). While a prototype plays, `presenter` +asking for a render to its preview and its refined image being handed to egui as the canvas +texture (`canvas.previewLatencyMs`, `canvas.refineLatencyMs`, on a monotonic clock, rendering +included in the browser too; the GPU upload and presenting the frame come after; `null` until +there was one). While a prototype plays, `presenter` (full window) and `preview` (the preview pane) hold the player's state: the `current` frame and its `name`, `start`, `history`, open `overlays`, how many delayed mouse triggers are `waiting`, the screen's `scroll` offset and `scrollRange`, the `device` (and whether it is turned to `landscape`, its `deviceColor`) and the `viewport` of the frame From d5fee489f5d24139156794245a1adc7323ec7ec0 Mon Sep 17 00:00:00 2001 From: Rob Coenen <753704+rcoenen@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:05:30 -0400 Subject: [PATCH 046/120] M7: recent files notice files deleted while the home screen is open The recent files' dates were only read again when the home screen came back into view, so a file deleted or moved in the meantime kept its card until then. They are now also read again every 2 seconds while in view, and at once when the window gets the focus back; a file that is gone shows "File not found" without leaving the home screen. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/home_recents.rs | 15 +++++++++- crates/ui-egui/src/home_recents_tests.rs | 35 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs index dbc585067..fc148e81e 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -319,8 +319,16 @@ pub struct Recents { pub selected: Option, /// The pass the recent files were last drawn in: coming back to them reads the dates again. drawn: u64, + /// When the dates were last read again while in view, and whether the window had the focus: + /// files deleted, moved or saved elsewhere show up within [`RECHECK_SECS`], or at once when + /// the window gets the focus back. + checked: f64, + focused: bool, } +/// How often the dates of the recent files in view are read again (seconds). +const RECHECK_SECS: f64 = 2.0; + enum Job { Stat(String), Preview(String, Option), @@ -423,10 +431,15 @@ impl Recents { /// files not looked at since the recent files came into view. `time`: the input time. fn refresh(&mut self, ctx: &egui::Context, paths: &[String], time: f64) { let pass = ctx.cumulative_pass_nr(); - if pass > self.drawn.saturating_add(1) { + let (focused, refocused) = ctx.input(|i| (i.focused, i.events.iter().any(|e| matches!(e, egui::Event::WindowFocused(true))))); + let back = pass > self.drawn.saturating_add(1) || refocused || (focused && !self.focused); + if back || time - self.checked >= RECHECK_SECS { self.files.values_mut().for_each(|k| k.asked = false); + self.checked = time; } self.drawn = pass; + self.focused = focused; + ctx.request_repaint_after(std::time::Duration::from_secs_f64(RECHECK_SECS)); let loader = self.loader.get_or_insert_with(|| Loader::new(ctx)); for reply in loader.poll() { match reply { diff --git a/crates/ui-egui/src/home_recents_tests.rs b/crates/ui-egui/src/home_recents_tests.rs index 6c8bf0b64..1c3235ddb 100644 --- a/crates/ui-egui/src/home_recents_tests.rs +++ b/crates/ui-egui/src/home_recents_tests.rs @@ -533,3 +533,38 @@ fn the_home_screen_draws_no_frames_once_the_pictures_are_in() { assert!(delay >= Duration::from_secs(1), "a frame in {delay:?}: {:?}", h.ctx.repaint_causes()); assert_eq!(file(&h.app.inspect()["homeRecents"], "pic")["picture"], "picture"); } + +#[test] +fn files_deleted_while_in_view_show_as_gone() { + let (_, paths) = files("deleted"); + let (mut h, _) = crate::live_tests::Harness::new(); + h.app.session.prefs.recent_files = paths.clone(); + h.app.session.prefs.getting_started_dismissed = true; + h.app.ui.home = true; + let exists = |h: &mut crate::live_tests::Harness, name: &str| file(&h.app.inspect()["homeRecents"], name)["exists"].clone(); + // Up to `n` frames (1/60 s of UI time each), giving the reader thread time between them. + let wait_n = |h: &mut crate::live_tests::Harness, name: &str, want: bool, n: usize| { + for _ in 0..n { + h.frame(vec![]); + if exists(h, name) == want { + return true; + } + std::thread::sleep(Duration::from_millis(5)); + } + false + }; + let wait = |h: &mut crate::live_tests::Harness, name: &str, want: bool| wait_n(h, name, want, 400); + assert!(wait(&mut h, "pic", true) && wait(&mut h, "art", true)); + // Deleted while the recent files stay in view: within a couple of seconds. + std::fs::remove_file(&paths[0]).unwrap(); + h.frames(5); + assert_eq!(exists(&mut h, "pic"), true, "not read again on every frame"); + h.skip(2.5); + assert!(wait(&mut h, "pic", false), "read again after a while"); + // Deleted while the window was in the background: as soon as it has the focus again. + std::fs::remove_file(&paths[2]).unwrap(); + h.frame(vec![egui::Event::WindowFocused(false)]); + h.frame(vec![egui::Event::WindowFocused(true)]); + // Well under the regular interval, so only the focus can have caused it. + assert!(wait_n(&mut h, "art", false, 30), "read again on getting the focus back"); +} From 9404226235d2f78a9116d16e05c62c2370dd120c Mon Sep 17 00:00:00 2001 From: Rob Coenen <753704+rcoenen@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:35:06 -0400 Subject: [PATCH 047/120] M4: vector edit mode keeps the canvas still under the pointer With no points picked, the Design panel's hint ("Select points to edit their position, radius and mirroring") didn't wrap and made the canvas 49 points wider, so entering vector edit mode moved the view by about 25 points and picking a region moved it back. The jump came in the middle of the click that picked the region: with the smallest pointer movement the release landed elsewhere on the page and the region was dragged by that distance (about 47 units at 70%). Both edit mode hints now wrap. Regression test: the canvas rectangle stays the same entering edit mode, picking a region and switching to the width tool. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/src/vector_panel.rs | 4 ++-- crates/ui-egui/src/vector_panel_tests.rs | 26 ++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 6 ++++++ 4 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/vector_panel_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f98..5441390ef 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -148,6 +148,8 @@ mod text_enter_tests; #[cfg(test)] mod titlebar_tests; #[cfg(test)] +mod vector_panel_tests; +#[cfg(test)] mod vector_tail_ui_tests; #[cfg(test)] mod windows_qa_tests; diff --git a/crates/ui-egui/src/vector_panel.rs b/crates/ui-egui/src/vector_panel.rs index 13f1ac8dd..5a327ecc1 100644 --- a/crates/ui-egui/src/vector_panel.rs +++ b/crates/ui-egui/src/vector_panel.rs @@ -40,7 +40,7 @@ fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { let t = crate::theme::Tokens::get(ui.ctx()); ui.horizontal(|ui| { ui.add_space(PAD); - widgets::label(ui, tr("Drag the stroke to add a width point"), t.text_secondary); + widgets::label_wrapped(ui, tr("Drag the stroke to add a width point"), t.text_secondary); }); ui.add_space(8.0); return; @@ -87,7 +87,7 @@ pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { let t = crate::theme::Tokens::get(ui.ctx()); ui.horizontal(|ui| { ui.add_space(PAD); - widgets::label(ui, tr("Select points to edit their position, radius and mirroring"), t.text_secondary); + widgets::label_wrapped(ui, tr("Select points to edit their position, radius and mirroring"), t.text_secondary); }); ui.add_space(8.0); return; diff --git a/crates/ui-egui/src/vector_panel_tests.rs b/crates/ui-egui/src/vector_panel_tests.rs new file mode 100644 index 000000000..d0a5e72e1 --- /dev/null +++ b/crates/ui-egui/src/vector_panel_tests.rs @@ -0,0 +1,26 @@ +//! The Design panel in vector edit mode keeps its width, so the canvas doesn't move under the +//! pointer when the panel's contents change. +use serde_json::json; + +#[test] +fn the_canvas_stays_put_entering_vector_edit_mode_and_picking_a_region() { + let (mut h, _) = crate::live_tests::Harness::new(); + h.app.session.execute("file.newFromTemplate", &json!({"template": "mobile-app"})).unwrap(); + // A wide vector with a filled area: the panel shows its long hint until something is picked. + let id = { + let doc = h.app.session.doc().unwrap(); + doc.nodes().filter(|n| n.name == "Near ridge").max_by(|a, b| a.height.total_cmp(&b.height)).map(|n| n.id).unwrap() + }; + let canvas = |h: &mut crate::live_tests::Harness| { + h.frames(4); + h.app.inspect()["canvas"]["rect"].clone() + }; + h.app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let before = canvas(&mut h); + h.app.session.execute("vector.edit.begin", &json!({})).unwrap(); + assert_eq!(canvas(&mut h), before, "the hint for an empty point selection wraps instead of widening the panel"); + h.app.session.execute("vector.edit.select", &json!({"regions": [0]})).unwrap(); + assert_eq!(canvas(&mut h), before, "picking a region doesn't move the canvas either"); + h.app.session.execute("vector.edit.tool", &json!({"tool": "width"})).unwrap(); + assert_eq!(canvas(&mut h), before, "nor does the width tool's hint"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 508e6fc48..73f8f95ff 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -934,6 +934,12 @@ pub fn label(ui: &mut Ui, text: &str, color: Color32) { ui.label(egui::RichText::new(text).font(regular(11.0)).color(color)); } +/// Text that wraps at the width it is given, so a long hint never widens its panel (a wider +/// panel would move the canvas under the pointer). +pub fn label_wrapped(ui: &mut Ui, text: &str, color: Color32) { + ui.add(egui::Label::new(egui::RichText::new(text).font(regular(11.0)).color(color)).wrap()); +} + /// Text shown in `rect` that turns into an editor on click (or when its editor has focus, e.g. /// after a double-click rename). Returns the new text on Enter / focus loss. pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egui::FontId) -> Option { From f1e0a758d1048373247f6c3fdfacff681eac607d Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Wed, 14 Oct 2026 08:43:19 +0200 Subject: [PATCH 048/120] Home: file rows no longer draw the path over the name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Open files and Recent files rows painted the detail (the file's path) right-aligned at its full width and only then elided the name into whatever was left, with a floor of 40 px. A path wider than the row ran straight over the name, and the name itself shrank to "Mae…". The row is now laid out name first: the name keeps its full width when both fit, otherwise at least 60% of the row; the detail gets the room left after a 16 px gap and is cut at its start ("…/02-technik/KNX/ file.figcraft"), which keeps the file and its nearest folders. A detail with less than 48 px of room is left out. Rows of Recents' own lists show name and path in full on hover when either was cut. The new widgets::elide_start_font mirrors elide_font, keeping the end. --- crates/ui-egui/src/home.rs | 37 ++++++++++++++++++++++++++------ crates/ui-egui/src/home_tests.rs | 36 +++++++++++++++++++++++++++++-- crates/ui-egui/src/widgets.rs | 15 +++++++++++++ 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 9b57cb0a1..e6c78947b 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -88,26 +88,49 @@ pub(crate) struct FileRow<'a> { pub detail_ink: Color32, } -/// Paint `row` in `r` (highlighted while `resp` is hovered): the name gives way to the detail. -pub(crate) fn file_row(ui: &Ui, t: &Tokens, r: Rect, resp: &Response, row: &FileRow) { +/// Paint `row` in `r` (highlighted while `resp` is hovered): the name first, the detail in the +/// room left after it, cut at its start; true when either is cut. +pub(crate) fn file_row(ui: &Ui, t: &Tokens, r: Rect, resp: &Response, row: &FileRow) -> bool { + /// Between the name and the detail. + const GAP: f32 = 16.0; + /// A detail with less room than this is left out rather than shown as a bare ellipsis. + const MIN_DETAIL: f32 = 48.0; if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } if let Some((icon, tint)) = row.mark { icons::paint(ui.painter(), Rect::from_min_size(pos2(r.min.x + 8.0, r.center().y - 8.0), vec2(16.0, 16.0)), icon, tint); } - let detail = ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, row.detail, regular(row.detail_size), row.detail_ink); - let name = widgets::elide_font(ui, row.name, (detail.min.x - r.min.x - 44.0).max(40.0), regular(12.0)); - ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, name, regular(12.0), row.ink); + let (name_font, detail_font) = (regular(12.0), regular(row.detail_size)); + let width = |s: &str, font: &egui::FontId| ui.painter().layout_no_wrap(s.to_string(), font.clone(), Color32::WHITE).size().x; + let room = (r.width() - 40.0).max(0.0); + let name_w = width(row.name, &name_font); + let detail_w = width(row.detail, &detail_font); + // When both don't fit, the name keeps at least 60% of the row and the detail gets the rest. + let name_max = if name_w + GAP + detail_w <= room { name_w } else { name_w.min((room - GAP - detail_w).max(room * 0.6)) }; + let name = widgets::elide_font(ui, row.name, name_max, name_font.clone()); + let detail_room = room - width(&name, &name_font) - GAP; + let detail = if row.detail.is_empty() || detail_room < MIN_DETAIL { + String::new() + } else { + widgets::elide_start_font(ui, row.detail, detail_room, detail_font.clone()) + }; + let cut = name != row.name || detail != row.detail; + ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, name, name_font, row.ink); + ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, detail, detail_font, row.detail_ink); + cut } /// A row of Recents' own lists: laid out in place, named for screen readers, ringed with the /// keyboard on it; true when chosen. fn listed(ui: &mut Ui, t: &Tokens, id: Id, height: f32, row: &FileRow) -> bool { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width().min(640.0), height), Sense::hover()); - let resp = ui.interact(r, id, Sense::click()); + let mut resp = ui.interact(r, id, Sense::click()); a11y::button(&resp, row.name); - file_row(ui, t, r, &resp, row); + if file_row(ui, t, r, &resp, row) { + // Cut to fit: in full on hover. + resp = resp.on_hover_text(format!("{}\n{}", row.name, row.detail)); + } focus_ring(ui, t, r, &resp, 5); resp.clicked() } diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index 1179b1c4e..953bfdebd 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -4,14 +4,14 @@ //! started card (closed, kept closed, and brought back from the Help menu). use egui::accesskit::Role; -use egui::{Event, Key, Modifiers, PointerButton, Pos2, Shape}; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, Shape}; use figcraft_engine::Session; use figcraft_engine::templates::TEMPLATES; use serde_json::json; use crate::home::{Section, cached_thumbnails}; use crate::layers_drag_tests::Ui; -use crate::live_tests::{flat, has_text, text_center}; +use crate::live_tests::{flat, has_text, text_center, texts}; /// The app as a first run finds it: no documents, no recent files. fn first_run() -> Ui { @@ -222,6 +222,38 @@ fn a_first_run_offers_templates_on_recents_and_recent_files_replace_them() { assert!(has_text(&s, "Open files"), "the document opened from the template is listed"); } +#[test] +fn a_long_name_and_path_share_their_row_without_overlapping() { + let mut u = first_run(); + let name = "Maengelliste-Fenster-Grad-Schlupf-2026-10-15-mit-einem-langen-Rest.figcraft"; + let long = format!("/Users/someone/dev/Personal/haus/ProjektHaus/02-Haus/04-maengel/2026-10-15/{name}"); + let short = "/tmp/stage1.figcraft"; + u.app.session.prefs.recent_files = vec![long.clone(), short.into()]; + u.app.session.prefs.getting_started_dismissed = true; + let s = settle(&mut u); + // A row that fits shows its name and path in full. + assert!(has_text(&s, "stage1.figcraft") && has_text(&s, short)); + // One that doesn't: the name, then the end of its path, side by side. + let all = texts(&s); + let rect = |(_, p, size): &(String, Pos2, egui::Vec2)| Rect::from_min_size(*p, *size); + let shown_name = all.iter().find(|(t, _, _)| t.starts_with("Ma")).expect("the name"); + let name_rect = rect(shown_name); + assert!(shown_name.0.starts_with("Maengelliste-Fenster") && shown_name.0.ends_with('…'), "{:?}", shown_name.0); + let detail = all + .iter() + .find(|e| e.0 != shown_name.0 && e.0.ends_with(".figcraft") && (rect(e).center().y - name_rect.center().y).abs() < 2.0) + .expect("the path on the name's row"); + let detail_rect = rect(detail); + assert!(name_rect.max.x + 12.0 <= detail_rect.min.x, "{:?} {name_rect:?} overlaps {:?} {detail_rect:?}", shown_name.0, detail.0); + assert!(detail.0.starts_with('…') && long.ends_with(detail.0.trim_start_matches('…')), "{:?}", detail.0); + // Hovered, the row shows them in full. + u.frame(vec![Event::PointerMoved(name_rect.center())]); + for _ in 0..60 { + u.frame(vec![]); + } + assert!(texts(&shapes(&mut u)).iter().any(|(t, _, _)| t.contains(&long)), "no tooltip with the full path"); +} + #[test] fn the_getting_started_card_closes_stays_closed_and_comes_back_from_help() { let mut u = first_run(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 508e6fc48..db696a1ef 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -625,6 +625,21 @@ pub fn elide_font(ui: &Ui, s: &str, max_w: f32, font: egui::FontId) -> String { .unwrap_or_else(|| "…".into()) } +/// [`elide_font`] cut at its start, keeping its end: a path keeps its file and the folders +/// nearest it. +pub fn elide_start_font(ui: &Ui, s: &str, max_w: f32, font: egui::FontId) -> String { + let fits = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), Color32::WHITE).size().x <= max_w; + if fits(s) { + return s.to_string(); + } + let chars: Vec = s.chars().collect(); + (1..chars.len()) + .rev() + .map(|n| format!("…{}", chars[chars.len() - n..].iter().collect::().trim_start())) + .find(|c| fits(c)) + .unwrap_or_else(|| "…".into()) +} + /// A row of icon buttons; returns the index clicked. pub fn segmented(ui: &mut Ui, items: &[(Icon, &str)], active: Option, cell: f32) -> Option { let names: Vec<&str> = items.iter().map(|(_, tip)| *tip).collect(); From 639c4d7db0803619ab536a0688ce0e8d12875fb2 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Wed, 14 Oct 2026 19:05:12 +0200 Subject: [PATCH 049/120] Home: test the file row layout on Open files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recent files are cards since #249, so the row test now opens two documents with synthetic paths and checks the Open files list: the row that fits shows name and path in full; the long one shows the name cut at its end, then the path cut at its start, at least 12 px apart, and both in full on hover. Without the row fix the name shows as "Quar…" under the path. --- crates/ui-egui/src/home_tests.rs | 34 ++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index 953bfdebd..1025ad8ed 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -223,27 +223,31 @@ fn a_first_run_offers_templates_on_recents_and_recent_files_replace_them() { } #[test] -fn a_long_name_and_path_share_their_row_without_overlapping() { - let mut u = first_run(); - let name = "Maengelliste-Fenster-Grad-Schlupf-2026-10-15-mit-einem-langen-Rest.figcraft"; - let long = format!("/Users/someone/dev/Personal/haus/ProjektHaus/02-Haus/04-maengel/2026-10-15/{name}"); - let short = "/tmp/stage1.figcraft"; - u.app.session.prefs.recent_files = vec![long.clone(), short.into()]; +fn an_open_files_long_name_and_path_share_their_row_without_overlapping() { + let name = "Quarterly planning: onboarding flow review with annotations and handoff notes"; + let long = "/Users/alex/Documents/Clients/Northwind Traders/2026 Website relaunch/Design reviews/Week 42/onboarding-flow-review.figcraft"; + let short = "/tmp/wireframes.figcraft"; + let mut s = Session::new(); + s.execute("file.rename", &json!({"name": name})).unwrap(); + s.docs[0].path = Some(long.into()); + s.execute("file.new", &json!({"name": "Wireframes"})).unwrap(); + s.docs[1].path = Some(short.into()); + let mut u = Ui::new(s); + u.app.ui.home = true; u.app.session.prefs.getting_started_dismissed = true; let s = settle(&mut u); + assert!(has_text(&s, "Open files")); // A row that fits shows its name and path in full. - assert!(has_text(&s, "stage1.figcraft") && has_text(&s, short)); + assert!(has_text(&s, "Wireframes") && has_text(&s, short)); // One that doesn't: the name, then the end of its path, side by side. let all = texts(&s); let rect = |(_, p, size): &(String, Pos2, egui::Vec2)| Rect::from_min_size(*p, *size); - let shown_name = all.iter().find(|(t, _, _)| t.starts_with("Ma")).expect("the name"); - let name_rect = rect(shown_name); - assert!(shown_name.0.starts_with("Maengelliste-Fenster") && shown_name.0.ends_with('…'), "{:?}", shown_name.0); - let detail = all - .iter() - .find(|e| e.0 != shown_name.0 && e.0.ends_with(".figcraft") && (rect(e).center().y - name_rect.center().y).abs() < 2.0) - .expect("the path on the name's row"); + let detail = all.iter().find(|(t, _, _)| t.ends_with("/onboarding-flow-review.figcraft")).expect("the path"); let detail_rect = rect(detail); + let shown_name = + all.iter().find(|e| e.0.starts_with('Q') && (rect(e).center().y - detail_rect.center().y).abs() < 2.0).expect("the name on the path's row"); + let name_rect = rect(shown_name); + assert!(shown_name.0.starts_with("Quarterly planning: onboarding") && shown_name.0.ends_with('…'), "{:?}", shown_name.0); assert!(name_rect.max.x + 12.0 <= detail_rect.min.x, "{:?} {name_rect:?} overlaps {:?} {detail_rect:?}", shown_name.0, detail.0); assert!(detail.0.starts_with('…') && long.ends_with(detail.0.trim_start_matches('…')), "{:?}", detail.0); // Hovered, the row shows them in full. @@ -251,7 +255,7 @@ fn a_long_name_and_path_share_their_row_without_overlapping() { for _ in 0..60 { u.frame(vec![]); } - assert!(texts(&shapes(&mut u)).iter().any(|(t, _, _)| t.contains(&long)), "no tooltip with the full path"); + assert!(texts(&shapes(&mut u)).iter().any(|(t, _, _)| t.contains(long)), "no tooltip with the full path"); } #[test] From 15ae2e911500f70f00fcfb8e693c3c9eab53a981 Mon Sep 17 00:00:00 2001 From: Barzabel Date: Wed, 14 Oct 2026 20:36:47 +0300 Subject: [PATCH 050/120] M1: memory budgets for decoded images and a file's images (#203, #189) --- crates/format/src/image_budget_tests.rs | 93 ++++++++++++++++++++ crates/format/src/lib.rs | 64 +++++++++++--- crates/render/src/image_budget_tests.rs | 111 ++++++++++++++++++++++++ crates/render/src/images.rs | 95 +++++++++++++++----- crates/render/src/lib.rs | 2 + 5 files changed, 331 insertions(+), 34 deletions(-) create mode 100644 crates/format/src/image_budget_tests.rs create mode 100644 crates/render/src/image_budget_tests.rs diff --git a/crates/format/src/image_budget_tests.rs b/crates/format/src/image_budget_tests.rs new file mode 100644 index 000000000..58bb4da1f --- /dev/null +++ b/crates/format/src/image_budget_tests.rs @@ -0,0 +1,93 @@ +//! The images of a native file within a budget (#189): however many members each within the +//! per-member limit a file holds, the images read from it together stay within a byte and a count +//! budget. The document's own images are read first, and the ones past the budget are reported. + +use std::io::Write; + +use figcraft_doc::Document; + +use super::*; + +fn png(k: u8) -> Vec { + let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([k, 7, 0, 255])); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// A saved document with `n` images, and their hashes and total size. +fn file_with_images(n: u8) -> (Vec, Vec, u64) { + let mut d = Document::new("File"); + let mut hashes = Vec::new(); + let mut total = 0; + for k in 0..n { + let bytes = png(k); + total += bytes.len() as u64; + hashes.push(add_image(&mut d, bytes).unwrap().0); + } + (save_bytes(&d, None).unwrap(), hashes, total) +} + +/// `bytes` with one more member, `name` holding `data`. +fn with_member(bytes: &[u8], name: &str, data: &[u8]) -> Vec { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let names: Vec = z.file_names().map(str::to_string).collect(); + let mut buf = std::io::Cursor::new(Vec::new()); + let mut w = zip::ZipWriter::new(&mut buf); + let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + for n in names { + let member = read_member(&mut z, &n, u64::MAX).unwrap(); + w.start_file(n, stored).unwrap(); + w.write_all(&member).unwrap(); + } + w.start_file(name, stored).unwrap(); + w.write_all(data).unwrap(); + w.finish().unwrap(); + buf.into_inner() +} + +#[test] +fn images_exactly_within_the_budget_all_load() { + let (bytes, hashes, total) = file_with_images(3); + let loaded = load_within(&bytes, ImageBudget { images: 3, bytes: total }).unwrap(); + assert!(loaded.issues.is_empty(), "{:?}", loaded.issues); + assert!(hashes.iter().all(|h| loaded.doc.images.contains_key(h))); +} + +#[test] +fn images_past_the_byte_budget_are_left_out_and_reported() { + let (bytes, hashes, total) = file_with_images(3); + let loaded = load_within(&bytes, ImageBudget { images: 100, bytes: total - 1 }).unwrap(); + let held: u64 = loaded.doc.images.values().map(|i| i.bytes.len() as u64).sum(); + assert!(held < total, "{held} bytes held"); + assert_eq!(loaded.doc.images.len(), 2); + let lost: Vec<&String> = hashes.iter().filter(|h| !loaded.doc.images.contains_key(*h)).collect(); + assert_eq!(lost.len(), 1); + assert!(loaded.issues.iter().any(|i| i.contains(lost[0].as_str()) && i.contains("together")), "{:?}", loaded.issues); +} + +#[test] +fn images_past_the_count_budget_are_left_out_and_reported() { + let (bytes, _, _) = file_with_images(4); + let loaded = load_within(&bytes, ImageBudget { images: 3, bytes: u64::MAX }).unwrap(); + assert_eq!(loaded.doc.images.len(), 3); + assert!(loaded.issues.iter().any(|i| i.contains("more than 3 images")), "{:?}", loaded.issues); +} + +#[test] +fn the_documents_own_images_are_read_before_others() { + let (bytes, hashes, total) = file_with_images(2); + // An image the document doesn't list, named to come first in the archive's order. + let extra = with_member(&bytes, "images/0000", &png(99)); + let loaded = load_within(&extra, ImageBudget { images: 2, bytes: total }).unwrap(); + assert!(hashes.iter().all(|h| loaded.doc.images.contains_key(h)), "{:?}", loaded.issues); + assert!(!loaded.issues.iter().any(|i| hashes.iter().any(|h| i.contains(h.as_str()))), "{:?}", loaded.issues); +} + +#[test] +fn a_file_within_the_shipped_budget_loads_as_before() { + let (bytes, hashes, _) = file_with_images(3); + let loaded = load_bytes_with_versions(&bytes).unwrap(); + assert!(loaded.issues.is_empty(), "{:?}", loaded.issues); + assert!(hashes.iter().all(|h| loaded.doc.images.contains_key(h))); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index a989f3f69..5313b2572 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -47,6 +47,8 @@ pub const MAX_VERSION_DESCRIPTION: usize = 2000; /// Largest version index and total size of the version snapshots read from one file. const MAX_VERSION_INDEX: u64 = 16 * 1024 * 1024; const MAX_VERSION_BYTES: u64 = 1024 * 1024 * 1024; +/// Most images, and image bytes together, read from one file (members past it are left out). +const IMAGE_BUDGET: ImageBudget = ImageBudget { images: 20_000, bytes: 4 * 1024 * 1024 * 1024 }; const BRANCH_INDEX: &str = "branches/index.json"; /// Most branches a file keeps: making more is refused, extra entries in a file are skipped. pub const MAX_BRANCHES: usize = 100; @@ -453,8 +455,20 @@ pub(crate) fn read_member(z: &mut zip::ZipArchive, name: &str Ok(data) } +/// How many images, and how many bytes of them together, one file may give. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ImageBudget { + pub images: usize, + pub bytes: u64, +} + /// Read a `.figcraft` archive (or bare JSON) with its version history. pub fn load_bytes_with_versions(bytes: &[u8]) -> Result { + load_within(bytes, IMAGE_BUDGET) +} + +/// [`load_bytes_with_versions`] reading at most `budget` of images. +pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result { let trimmed = bytes.iter().position(|b| !b.is_ascii_whitespace()).and_then(|i| bytes.get(i)); if trimmed == Some(&b'{') { let text = std::str::from_utf8(bytes).map_err(|e| FormatError::Corrupt(e.to_string()))?; @@ -464,18 +478,7 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result { let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| FormatError::Corrupt(e.to_string()))?; let text = String::from_utf8(read_member(&mut z, "document.json", MAX_MEMBER)?).map_err(|e| FormatError::Corrupt(e.to_string()))?; let (mut doc, mut issues, own_images) = parse_json(&text)?; - let names: Vec = z.file_names().filter(|n| n.starts_with("images/")).map(str::to_string).collect(); - let mut images = BTreeMap::new(); - let mut unread = Vec::new(); - for name in names { - let hash = name.trim_start_matches("images/").to_string(); - match read_member(&mut z, &name, MAX_MEMBER) { - Ok(data) => { - images.insert(hash, image_data(data)); - } - Err(e) => unread.push((hash, e)), - } - } + let (images, unread) = read_images(&mut z, own_images.as_ref(), budget); report_lost_images(&mut issues, &unread, own_images.as_ref(), &images); match library::read(&mut z, &images, u64::MAX) { Ok(lib) => doc.published = lib.map(Arc::new), @@ -497,6 +500,41 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result { Ok(Loaded { doc, issues, versions, branches }) } +/// The archive's `images/` members by hash, within `budget`, and the ones that couldn't be read +/// with why. The document's own images (`own`) are read first, so a file past the budget loses +/// the images only its history uses before its own. +fn read_images( + z: &mut zip::ZipArchive, + own: Option<&BTreeSet>, + budget: ImageBudget, +) -> (BTreeMap, Vec<(String, FormatError)>) { + let mut hashes: Vec = z.file_names().filter_map(|n| n.strip_prefix("images/")).map(str::to_string).collect(); + hashes.sort_by_key(|h| !own.is_some_and(|o| o.contains(h))); + let mut images = BTreeMap::new(); + let mut unread = Vec::new(); + let mut left = budget.bytes; + for (i, hash) in hashes.into_iter().enumerate() { + let name = format!("images/{hash}"); + if i >= budget.images { + unread.push((hash, FormatError::Corrupt(format!("the file has more than {} images", budget.images)))); + continue; + } + if z.by_name(&name).is_ok_and(|f| f.size() > left) { + let mb = budget.bytes / (1024 * 1024); + unread.push((hash, FormatError::Corrupt(format!("the file's images together pass {mb} MB")))); + continue; + } + match read_member(z, &name, left.min(MAX_MEMBER)) { + Ok(data) => { + left = left.saturating_sub(data.len() as u64); + images.insert(hash, image_data(data)); + } + Err(e) => unread.push((hash, e)), + } + } + (images, unread) +} + /// Most lost images reported one by one (the rest as a count). const MAX_IMAGE_ISSUES: usize = 20; @@ -673,6 +711,8 @@ pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec), Form #[cfg(test)] mod branch_tests; #[cfg(test)] +mod image_budget_tests; +#[cfg(test)] mod image_tests; #[cfg(test)] mod large_tests; diff --git a/crates/render/src/image_budget_tests.rs b/crates/render/src/image_budget_tests.rs new file mode 100644 index 000000000..ae654e09b --- /dev/null +++ b/crates/render/src/image_budget_tests.rs @@ -0,0 +1,111 @@ +//! Decoded images within a memory budget (#203): an image too large is refused from its header, +//! before its pixels are decoded, and the decoded-image caches hold a bounded number of bytes +//! however many images, filters and colour profiles a document uses. + +use std::sync::Arc; + +use figcraft_color::ColorProfile; +use tiny_skia::Pixmap; + +use super::*; + +fn png(w: u32, h: u32) -> Vec { + let img = image::RgbaImage::from_pixel(w, h, image::Rgba([200, 40, 40, 255])); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// A PNG whose header claims `w` × `h` but which holds no pixel data at all. +fn png_header_only(w: u32, h: u32) -> Vec { + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&w.to_be_bytes()); + ihdr.extend_from_slice(&h.to_be_bytes()); + ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); + let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); + out.extend_from_slice(&13u32.to_be_bytes()); + let chunk: Vec = b"IHDR".iter().copied().chain(ihdr).collect(); + out.extend_from_slice(&chunk); + out.extend_from_slice(&crc32(&chunk).to_be_bytes()); + out +} + +fn crc32(data: &[u8]) -> u32 { + let mut crc = !0u32; + for b in data { + crc ^= u32::from(*b); + for _ in 0..8 { + crc = if crc & 1 == 1 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 }; + } + } + !crc +} + +#[test] +fn an_image_within_the_limits_decodes() { + let (p, _) = images::decode_within(&png(30, 20), ColorProfile::Srgb, 30, 30 * 20 * 4).unwrap(); + assert_eq!((p.width(), p.height()), (30, 20)); +} + +#[test] +fn an_image_wider_or_taller_than_the_limit_is_refused() { + assert!(images::decode_within(&png(30, 20), ColorProfile::Srgb, 29, u64::MAX).is_none()); + assert!(images::decode_within(&png(20, 30), ColorProfile::Srgb, 29, u64::MAX).is_none()); +} + +#[test] +fn an_image_whose_pixels_pass_the_byte_budget_is_refused() { + assert!(images::decode_within(&png(30, 20), ColorProfile::Srgb, 1000, 30 * 20 * 4 - 1).is_none()); +} + +#[test] +fn a_huge_image_is_refused_from_its_header_alone() { + // 16 000 × 16 000 is within the side limit; its pixels (1 GB) aren't within a 1 MB budget, and + // the file has none to decode: only the header is read. + assert!(images::decode_within(&png_header_only(16_000, 16_000), ColorProfile::Srgb, 16_384, 1 << 20).is_none()); + // Past the side limit, the shipped decode refuses it the same way. + assert!(images::decode(&png_header_only(20_000, 10)).is_none()); +} + +fn key(name: &str) -> images::Key { + (name.to_string(), [0; 7], ColorProfile::Srgb) +} + +fn pixmap(side: u32) -> Arc { + Arc::new(Pixmap::new(side, side).unwrap()) +} + +#[test] +fn the_image_cache_keeps_within_its_byte_budget() { + // 10 × 10 pixmaps are 400 bytes; 1000 bytes hold two. + let mut c = images::PixmapCache::default(); + for name in ["a", "b", "c", "d", "e"] { + c.insert(key(name), pixmap(10), 1000); + assert!(c.bytes() <= 1000, "{} bytes held", c.bytes()); + assert!(c.get(&key(name)).is_some(), "{name} was just added"); + } +} + +#[test] +fn an_image_larger_than_the_budget_is_held_alone() { + let mut c = images::PixmapCache::default(); + c.insert(key("small"), pixmap(10), 1000); + c.insert(key("big"), pixmap(20), 1000); + assert!(c.get(&key("big")).is_some()); + assert!(c.get(&key("small")).is_none()); + assert_eq!(c.bytes(), 20 * 20 * 4); + // The next image takes its place. + c.insert(key("next"), pixmap(10), 1000); + assert_eq!(c.bytes(), 400); +} + +#[test] +fn filter_and_profile_variants_count_against_one_budget() { + let mut c = images::PixmapCache::default(); + for (i, profile) in [ColorProfile::Srgb, ColorProfile::DisplayP3].into_iter().enumerate() { + for f in 0..4 { + c.insert(("img".to_string(), [f, 0, 0, 0, 0, 0, i as i32], profile), pixmap(10), 1000); + assert!(c.bytes() <= 1000, "{} bytes held", c.bytes()); + } + } +} diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index abdb9e1f4..4892e508c 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -13,11 +13,54 @@ use figcraft_color::{ColorProfile, ImageFilters, PaintKind}; use figcraft_doc::{Document, ImageData, NodeId}; use tiny_skia::Pixmap; -type Key = (String, [i32; 7], ColorProfile); +pub(crate) type Key = (String, [i32; 7], ColorProfile); -fn cache() -> &'static Mutex>> { - static C: OnceLock>>> = OnceLock::new(); - C.get_or_init(|| Mutex::new(HashMap::new())) +/// The largest image side decoded. +const MAX_SIDE: u32 = 16_384; +/// The most bytes one image's decoded pixels take (a 16k × 16k 8-bit RGBA image). +const MAX_DECODED_BYTES: u64 = 1 << 30; +/// The most bytes of pixels each decoded-image cache holds (one image larger than this is held +/// alone). +const MAX_CACHED_BYTES: u64 = 512 << 20; +/// The most entries each decoded-image cache holds. +const MAX_CACHED_IMAGES: usize = 256; + +/// Decoded pixmaps by image, filters and profile, holding at most a byte budget of pixels: adding +/// one past the budget (or past [`MAX_CACHED_IMAGES`]) empties the cache first. +#[derive(Default)] +pub(crate) struct PixmapCache { + map: HashMap>, + bytes: u64, +} + +impl PixmapCache { + pub(crate) fn get(&self, key: &Key) -> Option> { + self.map.get(key).cloned() + } + + /// Add `p` under `key`, keeping the cache within `max_bytes` (or holding `p` alone). + pub(crate) fn insert(&mut self, key: Key, p: Arc, max_bytes: u64) { + let size = p.data().len() as u64; + if self.bytes.saturating_add(size) > max_bytes || self.map.len() >= MAX_CACHED_IMAGES { + self.map.clear(); + self.bytes = 0; + } + if let Some(old) = self.map.insert(key, p) { + self.bytes = self.bytes.saturating_sub(old.data().len() as u64); + } + self.bytes = self.bytes.saturating_add(size); + } + + /// The bytes of pixels held. + #[cfg(test)] + pub(crate) fn bytes(&self) -> u64 { + self.bytes + } +} + +fn cache() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(PixmapCache::default())) } fn fkey(f: &ImageFilters) -> [i32; 7] { @@ -34,7 +77,23 @@ pub fn decode(bytes: &[u8]) -> Option { /// image's own profile into `target` (`false`), or as sRGB when it has none (`true`: the sRGB → /// `target` conversion is still to do). fn decode_in(bytes: &[u8], target: ColorProfile) -> Option<(Pixmap, bool)> { - let mut dec = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?; + decode_within(bytes, target, MAX_SIDE, MAX_DECODED_BYTES) +} + +/// [`decode_in`] for images at most `max_side` wide and tall whose decoded pixels take at most +/// `max_bytes`; larger ones are refused from their header, before any pixels are decoded. +pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, max_bytes: u64) -> Option<(Pixmap, bool)> { + use image::ImageDecoder; + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(max_side); + limits.max_image_height = Some(max_side); + limits.max_alloc = Some(max_bytes); + reader.limits(limits); + let mut dec = reader.into_decoder().ok()?; + if dec.total_bytes() > max_bytes { + return None; + } let source = crate::image_profile::Source::read(bytes, &mut dec); let img = image::DynamicImage::from_decoder(dec).ok()?; match source.convert(&img, target) { @@ -66,7 +125,7 @@ pub fn profile_name(hash: &str, data: Option<&ImageData>) -> Option { /// Straight RGBA → a premultiplied pixmap (max 16k per side). fn premultiplied(rgba: &image::RgbaImage) -> Option { let (w, h) = rgba.dimensions(); - if w == 0 || h == 0 || w > 16_384 || h > 16_384 { + if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE { return None; } let mut p = Pixmap::new(w, h)?; @@ -87,17 +146,13 @@ fn premultiplied(rgba: &image::RgbaImage) -> Option { pub fn get(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile) -> Option> { let key = (hash.to_string(), fkey(filters), profile); if let Some(p) = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { - return Some(p.clone()); + return Some(p); } let (mut p, srgb) = decode_in(&data?.bytes, profile)?; // Images with their own profile are in `profile` already (sRGB → sRGB is the identity). finish(&mut p, filters, if srgb { profile } else { ColorProfile::Srgb }); let p = Arc::new(p); - let mut c = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if c.len() > 256 { - c.clear(); - } - c.insert(key, p.clone()); + cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, p.clone(), MAX_CACHED_BYTES); Some(p) } @@ -166,7 +221,7 @@ pub fn decode_frames(bytes: &[u8]) -> Option> { let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).ok()?; let (w, h) = dec.dimensions(); let frame_bytes = u64::from(w) * u64::from(h) * 4; - if frame_bytes == 0 || w > 16_384 || h > 16_384 { + if frame_bytes == 0 || w > MAX_SIDE || h > MAX_SIDE { return None; } let most = (MAX_ANIMATION_BYTES / frame_bytes).min(MAX_FRAMES as u64) as usize; @@ -304,22 +359,18 @@ pub fn pad(pm: &Pixmap) -> Option { Some(out) } -fn padded_cache() -> &'static Mutex>> { - static C: OnceLock>>> = OnceLock::new(); - C.get_or_init(|| Mutex::new(HashMap::new())) +fn padded_cache() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(PixmapCache::default())) } /// `pm` with a transparent 1-pixel border (cached). pub fn padded(hash: &str, filters: &ImageFilters, profile: ColorProfile, pm: &Pixmap) -> Option> { let key = (hash.to_string(), fkey(filters), profile); if let Some(p) = padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { - return Some(p.clone()); + return Some(p); } let out = Arc::new(pad(pm)?); - let mut c = padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if c.len() > 256 { - c.clear(); - } - c.insert(key, out.clone()); + padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, out.clone(), MAX_CACHED_BYTES); Some(out) } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c2bf9dda0..904e6dce2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -442,6 +442,8 @@ mod grain_tests; #[cfg(test)] mod group_effects_tests; #[cfg(test)] +mod image_budget_tests; +#[cfg(test)] mod image_profile_tests; #[cfg(test)] mod lod_tests; From ebf604518478735978544871cd3ecb4ec631b4c3 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 19:40:08 +0200 Subject: [PATCH 051/120] M1: SVG export keeps a nested frame's fill and stroke inside its parent's clip (fixes #325) --- crates/svg/src/lib.rs | 14 +++++++++++--- crates/svg/src/tests.rs | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index a5ae82adc..9f0b9f83d 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -86,6 +86,12 @@ fn clip_attr(clip: &Option) -> String { clip.as_ref().map(|c| format!(" clip-path=\"url(#{c})\"")).unwrap_or_default() } +/// `el` in a group with `clip` (a ` clip-path` attribute, or nothing). A clip path is in page +/// coordinates, and on an element with a `transform` it would be read after that transform. +fn clipped(clip: &str, el: String) -> String { + if clip.is_empty() { el } else { format!("{el}") } +} + fn num(v: f64) -> String { if !v.is_finite() { return "0".into(); @@ -433,7 +439,8 @@ impl W<'_> { let fr = if *rule == figcraft_render::FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" }; for p in &n.fills { if let Some(a) = self.paint_attr(p, w, h) { - let _ = write!(self.body, "", matrix(xf), path_d(path)); + let el = format!("", matrix(xf), path_d(path)); + self.body.push_str(&clipped(&paint_clip, el)); } } } @@ -505,7 +512,7 @@ impl W<'_> { if n.has_visible_stroke() && let Some(simple) = self.opts.simplify_stroke.then(|| self.simple_stroke(n, xf)).flatten() { - self.body.push_str(&simple); + self.body.push_str(&clipped(&paint_clip, simple)); } else if n.has_visible_stroke() { let geom = match &n.kind { NodeKind::BooleanOp { .. } | NodeKind::Text(_) => outline.as_ref().map(|(p, _)| p.clone()), @@ -517,7 +524,8 @@ impl W<'_> { let fr = if even { " fill-rule=\"evenodd\"" } else { "" }; for p in &n.strokes { if let Some(a) = self.paint_attr(p, w, h) { - let _ = write!(self.body, "", matrix(xf), path_d(&area)); + let el = format!("", matrix(xf), path_d(&area)); + self.body.push_str(&clipped(&paint_clip, el)); } } } diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 6c5bff973..360b95ca9 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -43,3 +43,42 @@ fn import_gradient_and_groups() { assert!(kinds.contains(&"GROUP")); assert!(kinds.contains(&"RECTANGLE") && kinds.contains(&"ELLIPSE"), "{kinds:?}"); } + +/// #325: a frame with children inside a clipping frame keeps its own paint inside the clip. The +/// clip path is in page coordinates, so it must sit on a group around the transformed path, never +/// on the path itself (where it would be read after the path's transform, offset by it). +#[test] +fn nested_frame_paint_is_clipped_in_page_space() { + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let page = d.create(p, NodeKind::Frame, "Page", 0.0, 0.0, 400.0, 300.0).unwrap(); + d.get_mut(page).unwrap().fills = vec![Paint::solid(Color::from_hex("FFFFFF").unwrap())]; + let card = d.create(page, NodeKind::Frame, "Card", 40.0, 60.0, 300.0, 120.0).unwrap(); + let c = d.get_mut(card).unwrap(); + c.fills = vec![Paint::solid(Color::from_hex("DDDDDD").unwrap())]; + c.corners.radii = [12.0; 4]; + c.clip = true; + let badge = d.create(card, NodeKind::Frame, "Badge", 200.0, 40.0, 80.0, 40.0).unwrap(); + let b = d.get_mut(badge).unwrap(); + b.fills = vec![Paint::solid(Color::from_hex("1E6F8E").unwrap())]; + b.strokes = vec![Paint::solid(Color::from_hex("AA0000").unwrap())]; + b.stroke.weight = 2.0; + let dot = d.create(badge, NodeKind::Rectangle, "Dot", 10.0, 10.0, 20.0, 20.0).unwrap(); + d.get_mut(dot).unwrap().fills = vec![Paint::solid(Color::from_hex("FFFFFF").unwrap())]; + for simplify_stroke in [true, false] { + let svg = export_svg(&d, &[page], &SvgOptions { simplify_stroke, ..SvgOptions::default() }).unwrap(); + for tag in svg.split('<').filter(|t| t.contains(" clip-path=")) { + assert!(!tag.contains(" transform="), "clip-path on a transformed element: <{tag}\n{svg}"); + } + // The card's clip: its rounded outline starts after the top-left radius. + let def = svg.find(""); + for color in ["#1E6F8E", "#AA0000"] { + let at = svg.find(color).unwrap_or_else(|| panic!("{color} missing\n{svg}")); + let path = svg[..at].rfind(" Date: Wed, 14 Oct 2026 14:30:10 -0400 Subject: [PATCH 052/120] M7: home screen test waits for the recent-file reader The first-run test settled the home screen with a fixed 12 frames, but the recent files are looked at on a thread of their own and a card shows no name until that thread has answered. On a busy CI runner it hadn't yet, so "ridgeline" was still a grey placeholder and the test failed (seen on this PR and on codex/issue-250-async-dialog). settle() now waits until every recent file has been looked at, as home_recents_tests does. Checked with a reader slowed to 200 ms: the old test fails at the same line as CI, the new one passes. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/home_tests.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index 1179b1c4e..a8f064763 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -22,8 +22,18 @@ fn shapes(u: &mut Ui) -> Vec { flat(&u.frame_output(vec![]).shapes) } -/// Enough frames for every thumbnail in view (they render one a frame) and what follows a click. +/// Enough frames for every thumbnail in view (they render one a frame) and what follows a click, +/// after every recent file has been looked at (the reader is a thread of its own, and a card +/// shows no name until it has answered). fn settle(u: &mut Ui) -> Vec { + for _ in 0..400 { + u.frame(vec![]); + let files = u.app.inspect()["homeRecents"]["files"].as_array().cloned().unwrap_or_default(); + if files.iter().all(|f| !f["exists"].is_null()) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } for _ in 0..12 { u.frame(vec![]); } From 1c1b69959852d664fafb25a299f5c0c387926d14 Mon Sep 17 00:00:00 2001 From: tydude001 Date: Wed, 14 Oct 2026 13:35:50 -0500 Subject: [PATCH 053/120] packaging: RPM declares the glibc 2.35 floor, so RHEL 9 refuses it FigCraft's RPM declares no glibc floor, though the deb has `libc6 (>= 2.35)`. On Rocky Linux 9 (glibc 2.34) the RPM installs, and then `figcraft-cli` fails with ``/lib64/libm.so.6: version `GLIBC_2.35' not found``. Co-Authored-By: Claude Opus 5.5 --- packaging/linux/nfpm.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index 69667a2b5..0adbfb04e 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -51,7 +51,11 @@ overrides: rpm: # Soname provides rather than package names, so the same requirements resolve on Fedora/RHEL # (libxkbcommon-x11, libX11-xcb, ...) and openSUSE (libxkbcommon-x11-0, libX11-xcb1, ...). + # The glibc floor matches the deb's libc6 (>= 2.35). It names libm because the binaries' + # GLIBC_2.35 symbols (hypot, hypotf) are libm's: RHEL 9's glibc 2.34 provides + # libc.so.6(GLIBC_2.35) through backports, but not this, so it refuses the install. depends: + - libm.so.6(GLIBC_2.35)(64bit) - libxkbcommon.so.0()(64bit) - libxkbcommon-x11.so.0()(64bit) - libwayland-client.so.0()(64bit) From a3048770b6fbd6627933687f2e34aa78a45be778 Mon Sep 17 00:00:00 2001 From: Barzabel Date: Wed, 14 Oct 2026 21:37:55 +0300 Subject: [PATCH 054/120] M1: deeply nested layers don't run the layout out of stack (#210) --- crates/layout/src/depth_tests.rs | 95 ++++++++++++++++++++++++++++++++ crates/layout/src/lib.rs | 29 ++++++++++ 2 files changed, 124 insertions(+) create mode 100644 crates/layout/src/depth_tests.rs diff --git a/crates/layout/src/depth_tests.rs b/crates/layout/src/depth_tests.rs new file mode 100644 index 000000000..f8b597a34 --- /dev/null +++ b/crates/layout/src/depth_tests.rs @@ -0,0 +1,95 @@ +//! Deeply nested layers and a resize (#210): constraints, group scaling and auto layout fill +//! follow a resize down to [`MAX_NESTING`] levels and stop there, instead of running out of stack. +//! Each case runs on a 1 MB stack, the main thread's on Windows; thousands of levels overflowed it. + +use super::*; +use figcraft_doc::{AutoLayout, Constraints, Document, HConstraint, NodeKind, VConstraint}; + +/// Far deeper than the nesting the layout follows. +const DEEP: usize = 5000; + +fn on_small_stack(f: impl FnOnce() + Send + 'static) { + std::thread::Builder::new().stack_size(1 << 20).spawn(f).unwrap().join().unwrap(); +} + +fn width(d: &Document, id: NodeId) -> f64 { + d.node(id).unwrap().width +} + +/// A 100 × 100 frame holding `depth` frames, each inside the last and stretching with it; the +/// root and the chain from the top. +fn stretching_frames(depth: usize) -> (Document, NodeId, Vec) { + let mut d = Document::new("deep"); + let page = d.first_page().unwrap(); + let root = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + let mut chain = Vec::new(); + let mut parent = root; + for _ in 0..depth { + let f = d.create(parent, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + d.get_mut(f).unwrap().constraints = Constraints { horizontal: HConstraint::LeftRight, vertical: VConstraint::TopBottom }; + chain.push(f); + parent = f; + } + (d, root, chain) +} + +#[test] +fn deeply_nested_frames_follow_their_constraints_without_running_out_of_stack() { + on_small_stack(|| { + let (mut d, root, chain) = stretching_frames(DEEP); + set_size(&mut d, root, 200.0, 200.0); + apply_constraints(&mut d, root, 100.0, 100.0); + assert_eq!(width(&d, chain[0]), 200.0); + assert_eq!(width(&d, chain[MAX_NESTING - 2]), 200.0); + assert_eq!(width(&d, chain[DEEP - 1]), 100.0, "past the nesting the layout follows, layers stay"); + }); +} + +#[test] +fn a_shallow_chain_follows_all_the_way_down() { + let (mut d, root, chain) = stretching_frames(20); + set_size(&mut d, root, 200.0, 200.0); + apply_constraints(&mut d, root, 100.0, 100.0); + assert!(chain.iter().all(|f| width(&d, *f) == 200.0)); +} + +#[test] +fn deeply_nested_groups_scale_without_running_out_of_stack() { + on_small_stack(|| { + let mut d = Document::new("deep"); + let mut parent = d.first_page().unwrap(); + let root = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap(); + parent = root; + let mut chain = Vec::new(); + for _ in 0..DEEP { + parent = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap(); + chain.push(parent); + } + scale_group_children(&mut d, root, 2.0, 2.0); + assert_eq!(width(&d, chain[0]), 200.0); + }); +} + +#[test] +fn deeply_nested_fill_frames_lay_out_without_running_out_of_stack() { + on_small_stack(|| { + let mut d = Document::new("deep"); + let page = d.first_page().unwrap(); + let root = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + d.get_mut(root).unwrap().layout = Some(AutoLayout { padding: [0.0; 4], ..AutoLayout::default() }); + let mut parent = root; + let mut chain = Vec::new(); + for _ in 0..DEEP { + let f = d.create(parent, NodeKind::Frame, "F", 0.0, 0.0, 10.0, 10.0).unwrap(); + let n = d.get_mut(f).unwrap(); + n.layout = Some(AutoLayout { padding: [0.0; 4], ..AutoLayout::default() }); + n.child_layout.sizing_h = Sizing::Fill; + chain.push(f); + parent = f; + } + d.get_mut(root).unwrap().width = 300.0; + layout_frame(&mut d, root, &NoMeasure); + assert_eq!(width(&d, chain[0]), 300.0); + assert_eq!(width(&d, chain[DEEP - 1]), 10.0, "past the nesting the layout follows, layers stay"); + }); +} diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index fd7ff00b2..02e7bdaf8 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -20,6 +20,30 @@ use figcraft_doc::{ }; use figcraft_geom::{Affine, Point, Rect}; +/// Most layout calls nested in one another (about one per level of layers): deeper layers don't +/// follow a resize, so a deeply nested document can't run the stack out (#210). +pub const MAX_NESTING: usize = 256; + +thread_local! { + static NESTING: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// One level of nested layout calls, released when dropped. +struct Nested; + +impl Nested { + /// Enter one more level; `None` when [`MAX_NESTING`] are in progress. + fn enter() -> Option { + NESTING.with(|n| (n.get() < MAX_NESTING).then(|| n.set(n.get() + 1)).map(|()| Self)) + } +} + +impl Drop for Nested { + fn drop(&mut self) { + NESTING.with(|n| n.set(n.get().saturating_sub(1))); + } +} + /// Measures text for a given width (auto-height text inside a Fill container). pub trait Measure { /// The height a text node needs at `width`. @@ -311,6 +335,7 @@ struct Item { /// Lay out one auto layout frame's children and size the frame if it hugs. pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { + let Some(_nested) = Nested::enter() else { return }; let Some(f) = doc.node(frame) else { return }; let Some(al) = f.layout.clone() else { return }; let horizontal = matches!(al.mode, LayoutMode::Horizontal); @@ -893,6 +918,7 @@ fn layout_grid(doc: &mut Document, frame: NodeId, al: &AutoLayout, m: &dyn Measu /// (recursively for children frames that resize). In an auto layout frame only the children that /// ignore auto layout (absolute position) follow constraints; the layout places the others. pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) { + let Some(_nested) = Nested::enter() else { return }; let Some(f) = doc.node(id) else { return }; let absolute_only = f.layout.is_some(); let (nw, nh) = (f.width, f.height); @@ -977,6 +1003,7 @@ pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) { /// Each child keeps its rotation and flip and scales along its own axes ([`axis_factors`]), its /// centre following the group's scale. pub fn scale_group_children(doc: &mut Document, id: NodeId, kx: f64, ky: f64) { + let Some(_nested) = Nested::enter() else { return }; let Some(n) = doc.node(id) else { return }; for c in n.children.clone() { let Some(cn) = doc.node(c) else { continue }; @@ -1101,6 +1128,8 @@ mod behaviour_tests; #[cfg(test)] mod content_align_tests; #[cfg(test)] +mod depth_tests; +#[cfg(test)] mod nested_tests; #[cfg(test)] mod tests; From b821b6f8f4a24aa8f028970c004a552ee5002a63 Mon Sep 17 00:00:00 2001 From: vxMacDev <77440838+vxMacDev@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:41:17 -0500 Subject: [PATCH 055/120] fix(recovery): keep the version history and branches in recovery copies (#185) Recovery copies were written with plain `save_bytes`, so a file recovered after a crash came back without its version history and branches, even though `file.recover` reads both. Copies now go through `save_bytes_with_history` like a normal save, with each open branch tab's latest edits (`branches::current`), since `sync` only puts those in before the next command. A copy was also only redone when the main file's revision changed, and a file with edits only in a branch tab didn't count as changed. Both checks now include the file's branch tabs. --- apps/figcraft/src/recovery.rs | 47 ++++++++++++++------- apps/figcraft/src/recovery_tests.rs | 65 +++++++++++++++++++++++++++++ crates/engine/src/branches.rs | 16 +++++++ 3 files changed, 112 insertions(+), 16 deletions(-) diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index 93335394f..afaa1d5a6 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -22,7 +22,8 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, MutexGuard}; use std::thread::JoinHandle; -use figcraft_engine::Session; +use figcraft_engine::{DocState, Session}; +use figcraft_format::{Branch, Version}; use serde_json::json; /// Seconds between copies of a document that keeps changing. @@ -41,8 +42,8 @@ pub struct Recovery { dir: PathBuf, /// Held while the app runs: a free lock means the run that wrote the folder is gone. _lock: File, - /// The revision each document was last copied at, and the write in flight. - written: HashMap>)>, + /// The [`stamp`] each document was last copied at, and the write in flight. + written: HashMap, Option>)>, /// When (UI clock) the next copies are due. next: f64, /// Copies of online files from earlier runs, kept until their outcome is known. @@ -88,7 +89,7 @@ impl Recovery { self.settle(s); // A join on its way settles within its timeouts: look again soon. let settling = (!self.waiting.is_empty()).then_some(0.5); - let pending = s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.0 != d.revision)); + let pending = s.docs.iter().any(|d| needs_copy(s, d) && self.written.get(&d.uid).is_none_or(|w| w.0 != stamp(s, d))); if now < self.next { return pending.then_some(self.next - now).or(settling); } @@ -124,26 +125,28 @@ impl Recovery { for d in &s.docs { let path = self.dir.join(format!("{}.figcraft", d.uid)); let note = path.with_extension(ONLINE); - if !needs_copy(d) { + if !needs_copy(s, d) { if self.written.remove(&d.uid).is_some() { let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(¬e); } continue; } - let entry = self.written.entry(d.uid).or_insert((0, None)); + let stamp = stamp(s, d); + let entry = self.written.entry(d.uid).or_insert((Vec::new(), None)); let busy = entry.1.as_ref().is_some_and(|h| !h.is_finished()); - if entry.0 == d.revision || busy { + if entry.0 == stamp || busy { continue; } let doc = d.doc.clone(); + let (versions, branches) = (d.versions.clone(), figcraft_engine::branches::current(s, d.uid)); // Which online file it is and what of the server's this very copy has (a line each: // the server, the file's id, then the session, client id and last change), while it // is one. Written after the copy, never before: a note must not promise more of the // server's changes than its copy holds. let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq)); let job = move || { - if !write_atomic(&path, &doc) { + if !write_atomic(&path, &doc, &versions, &branches) { return; } let _ = match online { @@ -157,7 +160,7 @@ impl Recovery { } else { entry.1 = std::thread::Builder::new().name("recovery".into()).spawn(job).ok(); } - entry.0 = d.revision; + entry.0 = stamp; } // Closed documents. let open: Vec = s.docs.iter().map(|d| d.uid).collect(); @@ -201,15 +204,27 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option { s.execute("collab.recoverFile", ¶ms).ok()?.get("session")?.as_u64() } -/// A document whose changes would be lost (a branch tab's edits are in its main file). -fn needs_copy(d: &figcraft_engine::DocState) -> bool { - d.is_dirty() && d.branch_of.is_none() +/// A file whose changes would be lost: in its document, its history or one of its branch tabs +/// (a branch tab's edits are saved with its main file, so the tab has no copy of its own). +fn needs_copy(s: &Session, d: &DocState) -> bool { + d.branch_of.is_none() && (d.is_dirty() || branch_tabs(s, d.uid).any(DocState::is_dirty)) } -/// Write `doc` to `path` through a temporary file; false when it couldn't be written (the copy -/// before it is still there). -fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document) -> bool { - let Ok(bytes) = figcraft_format::save_bytes(doc, None) else { return false }; +/// The tabs showing branches of the file in tab `uid`. +fn branch_tabs(s: &Session, uid: u64) -> impl Iterator { + s.docs.iter().filter(move |t| t.branch_of.as_ref().is_some_and(|(main, _)| *main == uid)) +} + +/// The revisions a copy of `d` is of: its own and its branch tabs' (a branch tab's edits change +/// only its own revision). A new copy is due when they change. +fn stamp(s: &Session, d: &DocState) -> Vec { + std::iter::once(d.revision).chain(branch_tabs(s, d.uid).map(|t| t.revision)).collect() +} + +/// Write `doc` with its version history and branches to `path` through a temporary file; false +/// when it couldn't be written (the copy before it is still there). +fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool { + let Ok(bytes) = figcraft_format::save_bytes_with_history(doc, None, versions, branches) else { return false }; let tmp = path.with_extension("tmp"); std::fs::write(&tmp, bytes).is_ok() && std::fs::rename(&tmp, path).is_ok() } diff --git a/apps/figcraft/src/recovery_tests.rs b/apps/figcraft/src/recovery_tests.rs index 95560319c..b70378713 100644 --- a/apps/figcraft/src/recovery_tests.rs +++ b/apps/figcraft/src/recovery_tests.rs @@ -62,3 +62,68 @@ fn copies_follow_saves_and_a_normal_quit_leaves_nothing() { assert!(!dir.exists(), "a normal quit removes the copies"); 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() +} + +/// The documents a crashed run's copies under `root` open as. +fn crash_and_recover(r: Recovery, root: &std::path::Path) -> Session { + drop(r); + let mut next = Session::empty(); + assert_eq!(recover(root, &mut next), 1); + next +} + +#[test] +fn copies_keep_the_version_history_and_branches() { + // #185: a copy has what a save would write, the latest edits on a branch tab included. + let root = root("history"); + let mut s = edited(); + s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap(); + s.execute("branch.create", &json!({"name": "Explore"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap(); + let mut r = Recovery::start(&root).unwrap(); + r.write(&s, true); + let next = crash_and_recover(r, &root); + let ds = next.docs.first().unwrap(); + assert_eq!(ds.versions.iter().map(|v| v.name.as_str()).collect::>(), ["v1"]); + assert_eq!(ds.branches.iter().map(|b| (b.name.as_str(), rectangles(&b.head))).collect::>(), [("Explore", 2)]); + let _ = std::fs::remove_dir_all(&root); +} + +/// The one copy under `root` (the run's folder), read back. +fn copy(root: &std::path::Path) -> figcraft_format::Loaded { + let dir = std::fs::read_dir(root).unwrap().flatten().next().unwrap().path(); + let file = std::fs::read_dir(dir).unwrap().flatten().map(|f| f.path()).find(|f| f.extension().is_some_and(|e| e == "figcraft")).unwrap(); + figcraft_format::load_bytes_with_versions(&std::fs::read(file).unwrap()).unwrap() +} + +#[test] +fn a_change_only_on_a_branch_tab_or_to_the_history_is_copied() { + let root = root("history-only"); + let mut s = edited(); + s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap(); + s.execute("branch.create", &json!({"name": "Explore"})).unwrap(); + for d in &mut s.docs { + d.mark_saved(); + } + let mut r = Recovery::start(&root).unwrap(); + // An edit on the branch tab only: the main file has no changes of its own yet. + s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap(); + r.write(&s, true); + assert_eq!(copy(&root).branches.iter().map(|b| rectangles(&b.head)).collect::>(), [2]); + s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap(); + r.write(&s, true); + assert_eq!(copy(&root).branches.iter().map(|b| rectangles(&b.head)).collect::>(), [3]); + // A version renamed on the main file leaves its document as it was. + s.execute("doc.switch", &json!({"index": 0})).unwrap(); + r.write(&s, true); + let id = s.docs[0].versions[0].id.clone(); + s.execute("version.rename", &json!({"id": id, "name": "v1 (renamed)"})).unwrap(); + r.write(&s, true); + assert_eq!(copy(&root).versions.iter().map(|v| v.name.as_str()).collect::>(), ["v1 (renamed)"]); + r.finish(); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/engine/src/branches.rs b/crates/engine/src/branches.rs index 75065b82b..8bff0568f 100644 --- a/crates/engine/src/branches.rs +++ b/crates/engine/src/branches.rs @@ -156,6 +156,22 @@ pub(crate) fn sync(s: &mut Session) { } } +/// The branches of the file in tab `uid` as saving it now would write them: [`sync`] only puts a +/// branch tab's latest edits in before the next command. +pub fn current(s: &Session, uid: u64) -> Vec { + let Some(ds) = s.docs.iter().find(|d| d.uid == uid) else { return Vec::new() }; + let mut branches = ds.branches.clone(); + for d in &s.docs { + let Some((_, id)) = d.branch_of.as_ref().filter(|(main, _)| *main == uid) else { continue }; + let Some(b) = branches.iter_mut().find(|b| b.id == *id) else { continue }; + b.head = d.document_itself().clone(); + if d.versions_changed { + b.versions = d.versions.clone(); + } + } + branches +} + /// The tab a save writes: the main file's for a branch tab, else the active tab. pub fn file_tab(s: &Session) -> usize { s.ds().ok().and_then(|d| d.branch_of.as_ref()).and_then(|(m, _)| s.docs.iter().position(|d| d.uid == *m)).unwrap_or(s.active) From 64eae8c8c5d78584a7980cb6ce52732ebbe948f7 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Wed, 14 Oct 2026 20:45:19 +0200 Subject: [PATCH 056/120] M0.9: every hidden window answers control requests, not only the main one (#269) `drain_hidden` answered only requests for the main window, and only while that window was hidden. A request addressed to another window (`window.new`, `window` param) that is minimised or covered still waited for that window's frame and timed out after 60 s, whether the main window was on screen or not. An agent that works in its own window lost it as soon as the window went behind others or to another Space. `drain_hidden` now looks at the target window of each request: if that window is hidden (`ViewportInfo::visible() == Some(false)` for its viewport) and the method needs no frame, it is answered from the main window's logic with the target window's state swapped in, as while it draws, so it acts on that window's file. `draw` and the new `windows::as_window` share the swap (`enter`, `adopt`, `leave`). The main window's logic runs on every request (the control server asks for a repaint), so this works while the main window is on screen too. What needs a frame keeps waiting, as before; a request for a window that doesn't exist is an error at once instead of waiting for a frame. --- crates/ui-egui/src/control.rs | 30 ++++++---- crates/ui-egui/src/windows.rs | 85 +++++++++++++++++++++-------- crates/ui-egui/src/windows_tests.rs | 62 ++++++++++++++++++++- docs/control-protocol.md | 7 ++- 4 files changed, 146 insertions(+), 38 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 28f216715..e25100ce3 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -139,25 +139,33 @@ pub fn drain(app: &mut FigApp, ctx: &Context) { /// Methods that need no frame drawn (#269): a hidden window answers them too. const NO_FRAME: &[&str] = &["engine.execute", "engine.commands", "document.inspect", "node.get", "window.list", "ui.render"]; -/// While the main window is hidden (minimised, or covered by other windows) it draws no frames, -/// but the desktop app's `logic` still runs: it answers the requests for that window that need no -/// frame, so an attached agent can edit, inspect and save. The rest (screenshots, input, the -/// interface's own state) wait for the window to show. +/// Whether window `id` draws no frames now: minimised, or covered by other windows (on macOS also +/// on another Space). +fn hidden(ctx: &Context, id: u64) -> bool { + ctx.input(|i| i.raw.viewports.get(&crate::windows::viewport(id)).and_then(egui::ViewportInfo::visible)) == Some(false) +} + +/// A hidden window (minimised, or covered by other windows) draws no frames, but the desktop app's +/// `logic` still runs, for the main window even while every window is hidden: it answers the +/// requests for any hidden window that need no frame, in that window's file, so an attached agent +/// can edit, inspect and save. The rest (screenshots, input, the interface's own state) wait for the +/// window to show. A request for a window that doesn't exist is an error at once. pub fn drain_hidden(app: &mut FigApp, ctx: &Context) { - if ctx.input(|i| i.viewport().visible()) != Some(false) { - return; - } for r in std::mem::take(&mut app.control_queue) { if r.is_cancelled() { continue; } - let main = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused) == 0; - if !main || !NO_FRAME.contains(&r.method.as_str()) { + let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused); + if !app.windows.exists(target) { + let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); + continue; + } + if !hidden(ctx, target) || !NO_FRAME.contains(&r.method.as_str()) { app.control_queue.push(r); continue; } - let res = handle(app, ctx, &r.method, &r.params); - let _ = r.reply.send(ControlReply { id: r.id, result: res }); + let res = crate::windows::as_window(app, target, |app| handle(app, ctx, &r.method, &r.params)); + let _ = r.reply.send(ControlReply { id: r.id, result: res.unwrap_or_else(|| Err(format!("no window {target}"))) }); } } diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 82da7555c..510c24f69 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -433,21 +433,74 @@ pub(crate) fn show(app: &FigApp, ctx: &Context) { } } -/// One frame of window `id`, with its state swapped in. -fn draw(app: &mut FigApp, id: u64, ui: &mut egui::Ui) { - let Some(k) = app.windows.list.iter().position(|w| w.id == id) else { return }; - let ctx = ui.ctx().clone(); +/// A window's state swapped into the app ([`enter`]) until [`leave`] swaps it back out. +struct Entered { + id: u64, + pane: Pane, + /// The newest open file before: files opened after it are the window's own. + newest: u64, +} + +/// Swap window `id`'s state into the app and show its file; `None` when there is no such window. +fn enter(app: &mut FigApp, id: u64) -> Option { + let k = app.windows.list.iter().position(|w| w.id == id)?; let main_active = app.session.ds().ok().map(|d| d.uid); let newest = app.session.docs.iter().map(|d| d.uid).max().unwrap_or(0); - let files_before = app.session.docs.len(); - let prefs = app.session.prefs.clone(); - let Some(w) = app.windows.list.get_mut(k) else { return }; + let w = app.windows.list.get_mut(k)?; let want = w.active; let mut pane = std::mem::take(&mut w.pane); exchange(app, &mut pane); app.windows.current = id; app.windows.main_active = main_active; show_own(app, want); + Some(Entered { id, pane, newest }) +} + +/// Files the entered window opened are its own. +fn adopt(app: &mut FigApp, e: &Entered) { + let new: Vec = app.session.docs.iter().map(|d| d.uid).filter(|u| *u > e.newest && app.windows.of(*u) == 0).collect(); + if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == e.id) { + w.docs.extend(new); + } +} + +/// Swap the entered window's state back out (with where it is on screen, when known) and the +/// main window's back in. +fn leave(app: &mut FigApp, mut e: Entered, outer: Option, inner: Option) { + let shown = app.session.ds().ok().map(|d| d.uid).filter(|u| app.windows.of(*u) == e.id); + exchange(app, &mut e.pane); + if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == e.id) { + w.active = shown.or(w.active); + w.pos = outer.map(|r| r.min).or(w.pos); + w.size = inner.map_or(w.size, |r| r.size()); + w.pane = e.pane; + } + app.windows.current = 0; + let main_active = app.windows.main_active.take(); + show_own(app, main_active); +} + +/// Run `f` as window `id` (0: the main window) outside its frame, with its state swapped in as +/// while it draws; `None` when there is no such window. For a window that draws nothing now +/// (minimized, or occluded: behind other windows or on another Space). +pub(crate) fn as_window(app: &mut FigApp, id: u64, f: impl FnOnce(&mut FigApp) -> R) -> Option { + if id == 0 { + // Between frames the app holds the main window's state. + return Some(f(app)); + } + let e = enter(app, id)?; + let r = f(app); + adopt(app, &e); + leave(app, e, None, None); + Some(r) +} + +/// One frame of window `id`, with its state swapped in. +fn draw(app: &mut FigApp, id: u64, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let files_before = app.session.docs.len(); + let prefs = app.session.prefs.clone(); + let Some(e) = enter(app, id) else { return }; if ctx.input(|i| i.viewport().focused == Some(true)) { app.windows.focused = id; } @@ -455,25 +508,11 @@ fn draw(app: &mut FigApp, id: u64, ui: &mut egui::Ui) { app.logic(&ctx); // Within the swap, so a panic leaves every window its own state. guard(app, &ctx, |app| app.frame(ui)); - // Files it opened are its own. - let new: Vec = app.session.docs.iter().map(|d| d.uid).filter(|u| *u > newest && app.windows.of(*u) == 0).collect(); - if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == id) { - w.docs.extend(new); - } + adopt(app, &e); settle(app, &ctx, files_before); if app.session.prefs != prefs { repaint_others(app, &ctx); } - let shown = app.session.ds().ok().map(|d| d.uid).filter(|u| app.windows.of(*u) == id); let (outer, inner) = ctx.input(|i| (i.viewport().outer_rect, i.viewport().inner_rect)); - exchange(app, &mut pane); - if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == id) { - w.active = shown.or(w.active); - w.pos = outer.map(|r| r.min).or(w.pos); - w.size = inner.map_or(w.size, |r| r.size()); - w.pane = pane; - } - app.windows.current = 0; - let main_active = app.windows.main_active.take(); - show_own(app, main_active); + leave(app, e, outer, inner); } diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index e3c54ee0a..5d92881af 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -1,10 +1,13 @@ //! Several windows: a window holds its own tabs; closing it closes its files, asking first when //! one has unsaved changes, and the main window takes over another window instead of quitting. +use std::sync::mpsc::{Receiver, channel}; + use egui::{RawInput, ViewportEvent, ViewportId, ViewportInfo}; use figcraft_engine::Session; -use serde_json::json; +use serde_json::{Value, json}; +use crate::control::{ControlReply, ControlRequest}; use crate::{FigApp, NoServices}; /// One main-window frame, as the desktop app runs it; `close` asks the window to close. @@ -21,6 +24,23 @@ fn frame(ctx: &egui::Context, app: &mut FigApp, close: bool) { .clear(); } +/// A control request queued for the UI, and where its reply arrives. +fn request(app: &mut FigApp, method: &str, params: Value) -> Receiver { + let (reply, rx) = channel(); + app.control_queue.push(ControlRequest { id: json!(1), method: method.into(), params, reply, cancelled: Default::default() }); + rx +} + +/// The desktop app's logic as eframe runs it for the main window, with each window's visibility +/// (`hidden`: minimised or covered). While the main window is hidden too this is all that runs. +fn logic(ctx: &egui::Context, app: &mut FigApp, hidden: &[ViewportId]) { + let mut input = RawInput::default(); + for id in std::iter::once(ViewportId::ROOT).chain(app.windows.list.iter().map(|w| crate::windows::viewport(w.id))) { + input.viewports.insert(id, ViewportInfo { minimized: Some(false), occluded: Some(hidden.contains(&id)), ..Default::default() }); + } + let _ = ctx.run_logic(&input, |ctx| crate::control::drain_hidden(app, ctx)); +} + fn names(app: &FigApp) -> Vec { app.session.docs.iter().map(|d| d.doc.name.clone()).collect() } @@ -64,3 +84,43 @@ fn windows_hold_tabs_and_close_with_their_files() { assert!(!app.ui.quit, "the app keeps running"); assert_eq!(app.session.ds().unwrap().doc.name, "A"); } + +#[test] +fn another_hidden_window_answers_requests_that_need_no_frame_in_its_own_file() { + let mut s = Session::empty(); + for n in ["A", "B"] { + s.execute("file.new", &json!({"name": n})).unwrap(); + } + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + let mut app = FigApp::new(s, Box::new(NoServices)); + frame(&ctx, &mut app, false); + let w = crate::control::handle(&mut app, &ctx, "window.new", &json!({"index": 0})).unwrap()["window"].as_u64().unwrap(); + frame(&ctx, &mut app, false); + let other = crate::windows::viewport(w); + let rect = |size: u32| json!({"window": w, "command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": size, "height": size}}); + let dirty = |app: &FigApp, name: &str| app.session.docs.iter().find(|d| d.doc.name == name).unwrap().is_dirty(); + + // The main window is on screen, the other one covered: the request to it is answered in its + // file (A), and the main window keeps showing its own (B). + let edit = request(&mut app, "engine.execute", rect(10)); + logic(&ctx, &mut app, &[other]); + assert!(edit.try_recv().expect("answered without the other window's frame").result.is_ok()); + assert!(dirty(&app, "A") && !dirty(&app, "B"), "in the other window's file"); + assert_eq!(app.session.ds().unwrap().doc.name, "B"); + assert_eq!(app.windows.current, 0); + + // Both hidden: still answered, and inspection sees the other window's file. + let inspect = request(&mut app, "document.inspect", json!({"window": w, "depth": 0})); + logic(&ctx, &mut app, &[ViewportId::ROOT, other]); + assert_eq!(inspect.try_recv().expect("answered").result.unwrap()["name"], json!("A")); + assert_eq!(app.session.ds().unwrap().doc.name, "B"); + + // What needs a frame keeps waiting for the other window; an unknown window is an error at once. + let ui = request(&mut app, "ui.inspect", json!({"window": w})); + let unknown = request(&mut app, "window.list", json!({"window": 99})); + logic(&ctx, &mut app, &[ViewportId::ROOT, other]); + assert!(ui.try_recv().is_err(), "waits for the window to show"); + assert!(unknown.try_recv().expect("answered").result.unwrap_err().contains("no window 99")); + assert_eq!(app.control_queue.len(), 1); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f2c049ce4..9cb5f6f50 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -35,9 +35,10 @@ instead of dispatching the new request. These limits apply to the opt-in loopbac requests still queued after the UI timeout are discarded, including requests deferred to a hidden window. A command already running is not interrupted. -A minimised or covered main window draws no frames, but still answers `engine.execute`, -`engine.commands`, `document.inspect`, `node.get`, `window.list` and `ui.render`, so an attached -agent can edit, inspect and save while the app is out of sight. Screenshots, input (`ui.input`, +A minimised or covered window (the main one or another, see Windows below) draws no frames, but +still answers `engine.execute`, `engine.commands`, `document.inspect`, `node.get`, `window.list` +and `ui.render`, acting on the file that window shows, so an attached agent can edit, inspect and +save while the app is out of sight. Screenshots, input (`ui.input`, `ui.pointer`, `ui.key`, `ui.type`) and the interface's own state wait for the window to show, and time out after 60 s if it doesn't. From ac8a0a112c856f2ea88a3699d29f5c515c9ea23e Mon Sep 17 00:00:00 2001 From: Barzabel Date: Wed, 14 Oct 2026 22:10:49 +0300 Subject: [PATCH 057/120] M1: keep a recovery copy until this run has its own (#187) --- apps/figcraft/src/recovery.rs | 122 +++++++++++++------- apps/figcraft/src/recovery_online_tests.rs | 26 ++++- apps/figcraft/src/recovery_tests.rs | 127 ++++++++++++++++++++- apps/figcraft/src/services.rs | 2 + 4 files changed, 234 insertions(+), 43 deletions(-) diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index 93335394f..bf6f8df69 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -4,7 +4,10 @@ //! in the config folder, in the background, at most every [`INTERVAL`] seconds, and the copy goes //! once the document is saved or closed. The app holds a lock on its folder. At startup, the folders //! whose lock is free belong to runs that ended without closing: their copies open again as -//! unsaved documents (`file.recover`) and the folder goes. Closing the app normally removes its own. +//! unsaved documents (`file.recover`). Each such copy **stays until this run has its own copy of +//! the document** (or it is saved or closed): until then it is the only copy on disk, and a run +//! that can't keep copies of its own leaves it for the next start. Then it goes, and its folder +//! with its last copy. Closing the app normally removes its own. //! //! A document that is one of your online files has a note beside its copy (`.online`, written //! after the copy it describes): the server, the file's id, and who the app was in the file with @@ -49,12 +52,21 @@ pub struct Recovery { waiting: Vec, } -/// A copy of one of your online files whose file is being joined again as session `session`. +/// A copy from an earlier run, kept until what it became is safe. pub struct Waiting { - session: u64, + until: Until, copy: PathBuf, - /// The lock of the folder it is in: no other start takes the copy meanwhile. - _lock: Arc, + /// The lock of the folder it is in: no other start takes the copy meanwhile (none for a copy + /// left directly in the root by older versions). + _lock: Option>, +} + +/// What a kept copy waits for. +enum Until { + /// One of your online files, joined again as this session: its outcome. + Session(u64), + /// Opened as document `uid`: this run's own copy of it (or its save or close). + Copied(u64), } /// What [`recover_all`] brought back. @@ -62,21 +74,26 @@ pub struct Waiting { pub struct Recovered { /// Documents opened from their copies. pub copies: usize, - /// Copies of online files waiting for their file ([`Recovery::adopt`]). + /// The copies kept until what they became is safe ([`Recovery::adopt`]). pub waiting: Vec, } impl Recovery { /// Take this run's folder under `root` (`None` when it can't be created or locked: no copies). + /// A crashed run with the same process id may still hold its folder (its copies kept): then + /// another name is taken. pub fn start(root: &Path) -> Option { - let dir = root.join(std::process::id().to_string()); - std::fs::create_dir_all(&dir).ok()?; - let lock = File::create(dir.join(LOCK)).ok()?; - lock.try_lock().ok()?; - Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() }) + let pid = std::process::id(); + (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()?; + Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() }) + }) } - /// Keep earlier runs' copies of online files until their outcome is known. + /// Keep earlier runs' copies until what they became is safe. pub fn adopt(&mut self, waiting: Vec) { self.waiting.extend(waiting); } @@ -86,8 +103,10 @@ impl Recovery { /// an idle window can sleep). pub fn tick(&mut self, s: &Session, now: f64) -> Option { self.settle(s); - // A join on its way settles within its timeouts: look again soon. - let settling = (!self.waiting.is_empty()).then_some(0.5); + // A join on its way settles within its timeouts: look again soon. A copy waiting for this + // run's own is looked at again with the next copies. + let joining = self.waiting.iter().any(|w| matches!(w.until, Until::Session(_))); + let settling = if joining { Some(0.5) } else { (!self.waiting.is_empty()).then_some(INTERVAL) }; let pending = s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.0 != d.revision)); if now < self.next { return pending.then_some(self.next - now).or(settling); @@ -97,26 +116,28 @@ impl Recovery { settling } - /// Remove earlier runs' copies whose outcome is known, once what they became is copied here. - fn settle(&mut self, s: &Session) { - if self.waiting.iter().all(|w| s.live.recovering(w.session)) { - return; + /// Remove earlier runs' copies once what they became is safe: copied here (the write + /// succeeded), saved or closed. An online file's copy goes once its outcome is known and every + /// document is safe, since which of them it became isn't known. + pub fn settle(&mut self, s: &Session) { + let joined = |w: &Waiting| matches!(w.until, Until::Session(id) if !s.live.recovering(id)); + if self.waiting.iter().any(joined) { + self.write(s, true); } - self.write(s, true); - let (done, waiting): (Vec, Vec) = - std::mem::take(&mut self.waiting).into_iter().partition(|w| !s.live.recovering(w.session)); + let all_safe = s.docs.iter().all(|d| self.safe(d)); + let (done, waiting): (Vec, Vec) = std::mem::take(&mut self.waiting).into_iter().partition(|w| match w.until { + Until::Session(_) => joined(w) && all_safe, + Until::Copied(uid) => s.docs.iter().find(|d| d.uid == uid).is_none_or(|d| self.safe(d)), + }); self.waiting = waiting; - for w in done { - let _ = std::fs::remove_file(&w.copy); - let _ = std::fs::remove_file(w.copy.with_extension(ONLINE)); - // The folder goes with its last copy (while another waits there its lock is held). - let dir = w.copy.parent().map(Path::to_path_buf).filter(|_| Arc::strong_count(&w._lock) == 1); - drop(w); - if let Some(dir) = dir { - let _ = std::fs::remove_file(dir.join(LOCK)); - let _ = std::fs::remove_dir(dir); - } - } + done.into_iter().for_each(remove_kept); + } + + /// Whether document `d`'s work is on disk: saved (or nothing to keep), or copied here with no + /// write in flight. + fn safe(&self, d: &figcraft_engine::DocState) -> bool { + let written = self.written.get(&d.uid).is_some_and(|w| w.1.as_ref().is_none_or(JoinHandle::is_finished)); + !needs_copy(d) || (written && self.dir.join(format!("{}.figcraft", d.uid)).is_file()) } /// Copy every document with unsaved changes; `wait`: before returning (after a panic). @@ -175,7 +196,9 @@ impl Recovery { /// 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) { - let Self { dir, _lock, written, .. } = self; + let Self { dir, _lock, written, waiting, .. } = self; + // The documents earlier runs' copies became were saved or discarded too. + waiting.into_iter().filter(|w| matches!(w.until, Until::Copied(_))).for_each(remove_kept); // Windows removes a folder only once nothing in it is open: copies being written first. for h in written.into_values().filter_map(|w| w.1) { let _ = h.join(); @@ -185,6 +208,20 @@ impl Recovery { } } +/// Remove a kept copy and its note, and its folder with its last copy (while another is kept +/// there, its lock is held). +fn remove_kept(w: Waiting) { + let _ = std::fs::remove_file(&w.copy); + let _ = std::fs::remove_file(w.copy.with_extension(ONLINE)); + let last = w._lock.as_ref().is_some_and(|l| Arc::strong_count(l) == 1); + let dir = w.copy.parent().map(Path::to_path_buf).filter(|_| last); + drop(w); + if let Some(dir) = dir { + let _ = std::fs::remove_file(dir.join(LOCK)); + let _ = std::fs::remove_dir(dir); + } +} + /// The extension of the note beside the copy of a document that is one of your online files. const ONLINE: &str = "online"; @@ -222,9 +259,9 @@ pub fn recover(root: &Path, s: &mut Session) -> usize { } /// Open the copies left by runs that ended without closing (folders under `root` whose lock is -/// free, and copies directly in `root` from older versions) as unsaved documents, then remove them. -/// A copy that can't be read stays where it is. A copy of one of your online files is kept -/// instead while its file is joined again ([`Recovered::waiting`]). +/// free, and copies directly in `root` from older versions) as unsaved documents. Each is kept +/// until this run has its own copy of the document ([`Recovered::waiting`]); a copy of one of your +/// online files, while its file is joined again. A copy that can't be read stays where it is. pub fn recover_all(root: &Path, s: &mut Session) -> Recovered { let mut out = Recovered::default(); let Ok(entries) = std::fs::read_dir(root) else { return out }; @@ -234,10 +271,15 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered { } // One of your online files comes back from its server, its copy kept until then. if let Some((session, lock)) = lock.and_then(|l| Some((rejoin_online(file, s)?, l))) { - out.waiting.push(Waiting { session, copy: file.to_path_buf(), _lock: lock.clone() }); - } else if s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() { + out.waiting.push(Waiting { until: Until::Session(session), copy: file.to_path_buf(), _lock: Some(lock.clone()) }); + return; + } + let before: Vec = s.docs.iter().map(|d| d.uid).collect(); + if s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() { out.copies += 1; - let _ = std::fs::remove_file(file); + if let Some(uid) = s.docs.iter().map(|d| d.uid).find(|u| !before.contains(u)) { + out.waiting.push(Waiting { until: Until::Copied(uid), copy: file.to_path_buf(), _lock: lock.cloned() }); + } } }; for e in entries.flatten() { @@ -265,7 +307,7 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered { for f in files.iter().filter(|f| f.extension().is_some_and(|e| e == ONLINE) && !f.with_extension("figcraft").exists()) { let _ = std::fs::remove_file(f); } - // Empty now unless a copy couldn't be read, or waits for its file (kept, its lock held). + // Empty now unless a copy couldn't be read, or is kept (its lock held). if Arc::strong_count(&lock) == 1 { drop(lock); let _ = std::fs::remove_file(path.join(LOCK)); diff --git a/apps/figcraft/src/recovery_online_tests.rs b/apps/figcraft/src/recovery_online_tests.rs index ead3749af..c9036773d 100644 --- a/apps/figcraft/src/recovery_online_tests.rs +++ b/apps/figcraft/src/recovery_online_tests.rs @@ -223,6 +223,30 @@ fn a_copy_ahead_of_the_server_has_its_edits_applied_and_sent() { let _ = std::fs::remove_dir_all(&root); } +#[test] +fn an_online_copy_stays_while_what_it_became_cant_be_copied() { + let root = root("ahead-blocked"); + let (mut s, script, mut server, _) = shared(); + s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap(); + script.lock().unwrap().sent.clear(); + let left = crash(&s, &root); + let (mut next, script, mut r, _) = restart(&s, &root); + arrive(&mut next, &script, &mut server); + assert!(next.docs[1].is_dirty(), "the copy's edit is on top, not saved anywhere yet"); + // Something is in the way of this run's copy: writing it fails. + let mine = root.join(std::process::id().to_string()).join(format!("{}.figcraft", next.docs[1].uid)); + std::fs::create_dir_all(&mine).unwrap(); + r.tick(&next, 1.0); + assert_eq!((copies(&left, "figcraft"), copies(&left, "online")), (1, 1), "kept: the work is nowhere else on disk"); + // Once it can be written, the old copy goes. + std::fs::remove_dir(&mine).unwrap(); + next.execute("shape.rectangle", &json!({"x": 50, "y": 0, "width": 4, "height": 4})).unwrap(); + r.tick(&next, 2.0); + assert!(!left.exists()); + drop(r); + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn a_copy_the_server_already_has_is_discarded() { let root = root("equal"); @@ -332,7 +356,7 @@ fn without_its_record_the_copy_opens_as_any_other() { assert_eq!(recover(&root, &mut next), 1); assert!(script.lock().unwrap().joined.is_empty()); assert!(next.docs.first().is_some_and(|d| d.is_dirty() && d.path.is_none())); - assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "the folder is gone"); + assert!(std::fs::read_dir(&root).unwrap().next().is_some(), "the copy stays until a run has its own"); let _ = std::fs::remove_dir_all(&root); } diff --git a/apps/figcraft/src/recovery_tests.rs b/apps/figcraft/src/recovery_tests.rs index 95560319c..2efed5383 100644 --- a/apps/figcraft/src/recovery_tests.rs +++ b/apps/figcraft/src/recovery_tests.rs @@ -4,7 +4,7 @@ use figcraft_engine::Session; use serde_json::json; -use crate::recovery::{Recovery, recover}; +use crate::recovery::{Recovery, recover, recover_all}; fn root(name: &str) -> std::path::PathBuf { let r = std::env::temp_dir().join(format!("figcraft-recovery-{name}-{}", std::process::id())); @@ -34,7 +34,7 @@ fn a_crashed_runs_unsaved_documents_come_back() { let ds = next.docs.first().unwrap(); assert!(ds.is_dirty() && ds.path.is_none()); assert_eq!(next.doc().unwrap().node(next.doc().unwrap().pages[0]).map(|p| p.children.len()), Some(1)); - assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "the folder is gone"); + assert!(std::fs::read_dir(&root).unwrap().next().is_some(), "the copy stays until a run has its own"); let _ = std::fs::remove_dir_all(&root); } @@ -62,3 +62,126 @@ fn copies_follow_saves_and_a_normal_quit_leaves_nothing() { assert!(!dir.exists(), "a normal quit removes the copies"); let _ = std::fs::remove_dir_all(&root); } + +/// A run that ended without closing, leaving a copy of `s`'s unsaved document: its folder (as +/// another process's). +fn crashed(s: &Session, root: &std::path::Path) -> std::path::PathBuf { + let mut r = Recovery::start(root).unwrap(); + r.write(s, true); + drop(r); + let left = root.join("crashed"); + std::fs::rename(root.join(std::process::id().to_string()), &left).unwrap(); + left +} + +fn copies_in(d: &std::path::Path) -> usize { + std::fs::read_dir(d).map_or(0, |d| d.flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count()) +} + +/// The next start: the copies come back, and this run's recovery keeps them until it covers them. +fn restart(root: &std::path::Path) -> (Session, Recovery) { + let mut next = Session::empty(); + let recovered = recover_all(root, &mut next); + assert_eq!(recovered.copies, 1); + let mut r = Recovery::start(root).unwrap(); + r.adopt(recovered.waiting); + (next, r) +} + +#[test] +fn a_recovered_copy_stays_until_this_run_has_its_own_copy() { + let root = root("handoff"); + let left = crashed(&edited(), &root); + let (next, mut r) = restart(&root); + assert_eq!(copies_in(&left), 1, "the only copy on disk until this run writes one"); + r.write(&next, true); + r.tick(&next, 100.0); + assert!(!left.exists(), "covered by this run's copy, the old one goes"); + assert_eq!(copies_in(&root.join(std::process::id().to_string())), 1); + r.finish(); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_recovered_copy_stays_while_its_replacement_cant_be_written() { + let root = root("blocked"); + let left = crashed(&edited(), &root); + let (next, mut r) = restart(&root); + // Something is in the way of this run's copy: writing it fails. + let mine = root.join(std::process::id().to_string()).join(format!("{}.figcraft", next.docs[0].uid)); + std::fs::create_dir_all(&mine).unwrap(); + r.write(&next, true); + r.tick(&next, 100.0); + assert_eq!(copies_in(&left), 1, "kept: the work is nowhere else on disk"); + // Once it can be written, the old copy goes. + std::fs::remove_dir(&mine).unwrap(); + let mut next = next; + next.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 2, "height": 2})).unwrap(); + r.write(&next, true); + r.tick(&next, 200.0); + assert!(!left.exists()); + r.finish(); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn without_a_recovery_of_its_own_the_copy_stays_for_the_next_start() { + let root = root("nocopies"); + crashed(&edited(), &root); + let mut next = Session::empty(); + let recovered = recover_all(&root, &mut next); + assert_eq!(recovered.copies, 1); + // This run couldn't take a folder of its own: it ends (or crashes) with nothing copied. + drop(recovered); + let mut after = Session::empty(); + assert_eq!(recover(&root, &mut after), 1, "the work comes back again"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_recovered_copy_goes_once_its_document_is_saved_or_closed() { + for close in [false, true] { + let root = root(if close { "closed" } else { "saved" }); + let left = crashed(&edited(), &root); + let (mut next, mut r) = restart(&root); + if close { + next.docs.clear(); + } else { + next.ds_mut().unwrap().mark_saved(); + } + r.tick(&next, 0.0); + assert!(!left.exists(), "close {close}"); + r.finish(); + let _ = std::fs::remove_dir_all(&root); + } +} + +#[test] +fn a_normal_quit_removes_the_recovered_copies_it_took_over() { + let root = root("quitafter"); + let left = crashed(&edited(), &root); + let (_, r) = restart(&root); + // Quitting asked to save or discard every document. + r.finish(); + assert!(!left.exists()); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_run_whose_folder_name_is_still_held_takes_another() { + let root = root("pidreuse"); + // A crashed run with this process's id whose folder the start still holds (its copy waits). + let held = root.join(std::process::id().to_string()); + std::fs::create_dir_all(&held).unwrap(); + let lock = std::fs::File::create(held.join("lock")).unwrap(); + lock.try_lock().unwrap(); + let mut r = Recovery::start(&root).expect("a folder of its own"); + let s = edited(); + r.write(&s, true); + assert_eq!(copies_in(&held), 0); + let dirs = std::fs::read_dir(&root).unwrap().flatten().filter(|e| e.path() != held).count(); + assert_eq!(dirs, 1); + r.finish(); + drop(lock); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index f4aeb63d7..6fd7463da 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -242,6 +242,8 @@ impl Services for Desktop { fn emergency_save(&mut self, session: &Session) { if let Some(r) = crate::recovery::lock(&self.recovery).as_mut() { r.write(session, true); + // Earlier runs' copies now covered go, so the next start doesn't open them twice. + r.settle(session); } } fn reduce_motion(&self) -> bool { From 6bd87bccc60d8d7e033b9c5b476bc52f7337f63e Mon Sep 17 00:00:00 2001 From: Filip Strajnar Date: Wed, 14 Oct 2026 21:12:39 +0200 Subject: [PATCH 058/120] M7: the Small caps sample in Type settings draws without a box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Small caps button in Type settings labelled itself "Aɢ", but the small-capital G (U+0262) is missing from egui's default fonts, which the UI uses whenever Inter isn't built in, so the button showed "A" and a box. The sample is now a capital followed by smaller capitals laid out from ordinary letters every UI font has. A test checks the UI fonts draw every letter of the sample. --- crates/ui-egui/src/typography.rs | 56 +++++++++++++++++++++++++++++++- docs/roadmap.md | 1 + docs/ui-parity.md | 3 +- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 873ac0f0b..56854f27e 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -825,7 +825,11 @@ fn text_seg(ui: &mut Ui, salt: &str, items: &[(&str, &str)], active: Option Arc { + let mut job = egui::text::LayoutJob::default(); + let mut chars = label.chars(); + let first: String = chars.next().into_iter().collect(); + let rest: String = chars.map(small_capital_to_capital).collect::().to_uppercase(); + job.append(&first, 0.0, egui::TextFormat { font_id: regular(11.5), color: col, valign: egui::Align::BOTTOM, ..Default::default() }); + job.append(&rest, 0.0, egui::TextFormat { font_id: regular(8.5), color: col, valign: egui::Align::BOTTOM, ..Default::default() }); + ui.painter().layout_job(job) +} + +/// The capital a small-capital letter stands for (the ones a sample uses), else the letter. +fn small_capital_to_capital(c: char) -> char { + match c { + 'ɢ' => 'G', + 'ʜ' => 'H', + 'ɪ' => 'I', + 'ʟ' => 'L', + 'ɴ' => 'N', + 'ʀ' => 'R', + 'ʏ' => 'Y', + c => c, + } +} + /// One OpenType feature toggle; `on` None = mixed. Returns true when clicked. fn feature_row(ui: &mut Ui, tag: &str, label: &str, on: Option, supported: bool) -> bool { let t = Tokens::get(ui.ctx()); @@ -1136,6 +1167,29 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) mod tests { use super::*; + #[test] + fn small_caps_sample_draws_only_letters_the_ui_font_has() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, false, 0); + let mut shown = None; + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 300.0))), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + shown = Some(small_caps_sample(ui, "Aɢ", Color32::WHITE)); + }); + out.textures_delta.clear(); + let g = shown.expect("drawn"); + assert_eq!(g.job.text, "AG"); + let [a, rest] = g.job.sections.as_slice() else { panic!("two sections: {:?}", g.job.sections) }; + assert!(rest.format.font_id.size < a.format.font_id.size, "the other letters are smaller capitals"); + ctx.fonts_mut(|f| { + for sec in &g.job.sections { + assert!(f.has_glyphs(&sec.format.font_id, &g.job.text), "UI font draws {:?}", g.job.text); + } + }); + assert_eq!(small_capital_to_capital('ʀ'), 'R'); + assert_eq!(small_capital_to_capital('x'), 'x'); + } + #[test] fn parses_line_height_and_letter_spacing() { assert_eq!(parse_line_height("Auto", None), Some(json!("auto"))); diff --git a/docs/roadmap.md b/docs/roadmap.md index 8e2780d68..12f2a5fbf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged | | 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6de538e7a..84269b4b9 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -164,6 +164,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Type settings: the Small caps button draws its sample as a capital and a smaller capital, no longer as a box where the UI font lacks the small-capital letter (egui's default fonts) | | 2026-10-14 | minor | Home screen recent files as cards with thumbnails on their page colour, a sortable table view, selection, keyboard and a context menu | | 2026-10-13 | minor | Vector edit mode regions: with the Move tool the filled area under the pointer is hatched with "/" lines in the selection colour (about 1.4 px), clipped by the area's own fill rule so holes stay clear, and the hatch goes when the pointer is 3.5 px from a point or 2 px from a segment (screen pixels at any zoom); the spacing follows octave folding, fitted to measurements at several zoom levels (8 px at every power-of-two zoom, the gap growing with the zoom and stepping back at √2 · 2ⁿ), anchored to the vector so it doesn't slide when panning, one continuous pattern across selected areas; a click selects an area with its points (they move with it but keep the unselected look), Shift adds an area or takes a selected one out, a press on a selected area drags everything selected, drags show no hover hatch, a click on an empty spot (such as a hole) clears the areas and stays in edit mode, the last area listed wins where areas overlap, only filled vectors have areas; `vector.edit.select {regions}` and `vector.edit.state` report them (`regions_tests`, `vector_regions_tests`) | | 2026-10-13 | minor | Home screen: a Templates tab (eight starter templates as cards with thumbnails, search and a kind filter; click or Enter opens a new document) replaces the Community placeholder, the Drafts placeholder is removed, Recents offers four templates while there are no recent files and shows a Getting started card (closable; Help ▸ Getting started brings it back), File ▸ New from template…, and a first run opens on the home screen | From 30478894fd4b82919ec7878848476405b17248ea Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Wed, 14 Oct 2026 21:17:42 +0200 Subject: [PATCH 059/120] M0.9: a window on screen draws requests for it while the main window is hidden (#269) Requests reach the UI through the main window's logic. A request for another window is answered in that window's frame, and the main window's frame (`drain`) asks that window to draw. While the main window is hidden, only its logic runs and nothing asked: an idle window on screen draws no frames, so the request waited until something else repainted the window, or timed out after 60 s. `drain_hidden` now asks a window on screen that a request waits for to draw. A hidden window isn't asked: it can't draw, and the main window's logic already answers what it can. --- crates/ui-egui/src/control.rs | 8 +++++++- crates/ui-egui/src/windows_tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e25100ce3..99e8999f8 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -149,7 +149,8 @@ fn hidden(ctx: &Context, id: u64) -> bool { /// `logic` still runs, for the main window even while every window is hidden: it answers the /// requests for any hidden window that need no frame, in that window's file, so an attached agent /// can edit, inspect and save. The rest (screenshots, input, the interface's own state) wait for the -/// window to show. A request for a window that doesn't exist is an error at once. +/// window to show; another window on screen is asked to draw, which the main window's frame would +/// do but doesn't while it is hidden. A request for a window that doesn't exist is an error at once. pub fn drain_hidden(app: &mut FigApp, ctx: &Context) { for r in std::mem::take(&mut app.control_queue) { if r.is_cancelled() { @@ -161,6 +162,11 @@ pub fn drain_hidden(app: &mut FigApp, ctx: &Context) { continue; } if !hidden(ctx, target) || !NO_FRAME.contains(&r.method.as_str()) { + // Another window on screen draws only when asked: while the main window is hidden no + // frame of it does that (`drain`), so ask here. + if target != app.windows.current && !hidden(ctx, target) { + ctx.request_repaint_of(crate::windows::viewport(target)); + } app.control_queue.push(r); continue; } diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index 5d92881af..1a6e91bbc 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -124,3 +124,29 @@ fn another_hidden_window_answers_requests_that_need_no_frame_in_its_own_file() { assert!(unknown.try_recv().expect("answered").result.unwrap_err().contains("no window 99")); assert_eq!(app.control_queue.len(), 1); } + +#[test] +fn a_request_for_a_window_on_screen_wakes_it_while_the_main_window_is_hidden() { + let mut s = Session::empty(); + for n in ["A", "B"] { + s.execute("file.new", &json!({"name": n})).unwrap(); + } + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + let mut app = FigApp::new(s, Box::new(NoServices)); + frame(&ctx, &mut app, false); + let w = crate::control::handle(&mut app, &ctx, "window.new", &json!({"index": 0})).unwrap()["window"].as_u64().unwrap(); + frame(&ctx, &mut app, false); + let other = crate::windows::viewport(w); + // Which windows are asked to draw from now on (an idle window draws no frames until asked). + let woken = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let log = woken.clone(); + ctx.set_request_repaint_callback(move |info| log.lock().unwrap().push(info.viewport_id)); + + // Only the main window's logic runs while it is hidden: the request for the other window, + // which is on screen, waits for that window's frame, and that window is asked to draw it. + let ui = request(&mut app, "ui.inspect", json!({"window": w})); + logic(&ctx, &mut app, &[ViewportId::ROOT]); + assert!(ui.try_recv().is_err(), "answered in the other window's frame"); + assert!(woken.lock().unwrap().contains(&other), "the other window is asked to draw: {:?}", woken.lock().unwrap()); +} From b8043cc2adf9e317ce862c047e82690f4aac845a Mon Sep 17 00:00:00 2001 From: Barzabel Date: Wed, 14 Oct 2026 22:23:51 +0300 Subject: [PATCH 060/120] M3: renaming a style folder no longer closes the app --- crates/ui-egui/src/lib.rs | 2 + .../ui-egui/src/style_folder_rename_tests.rs | 56 +++++++++++++++++++ crates/ui-egui/src/style_picker.rs | 7 ++- 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/style_folder_rename_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f98..8f564ead6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -144,6 +144,8 @@ mod shortcut_keys_tests; #[cfg(test)] mod shortcuts_tests; #[cfg(test)] +mod style_folder_rename_tests; +#[cfg(test)] mod text_enter_tests; #[cfg(test)] mod titlebar_tests; diff --git a/crates/ui-egui/src/style_folder_rename_tests.rs b/crates/ui-egui/src/style_folder_rename_tests.rs new file mode 100644 index 000000000..624edfda6 --- /dev/null +++ b/crates/ui-egui/src/style_folder_rename_tests.rs @@ -0,0 +1,56 @@ +//! Renaming a folder of local styles, drawn headless with the screen reader tree on: Rename folder +//! in the folder's menu opens its name for editing. On every frame the focused node must be in the +//! AccessKit tree; the platform adapter (UI Automation on Windows) aborts the app when it isn't +//! (the same fault as #224). + +use egui::{Event, Key, Modifiers, PointerButton, Pos2}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, text_center}; + +/// One frame with `events`, checking the focused node is in the screen reader tree. +fn checked_frame(u: &mut Ui, events: Vec, k: usize) -> Vec { + let out = u.frame_output(events); + let tree = out.platform_output.accesskit_update.expect("an AccessKit update"); + assert!(tree.nodes.iter().any(|(i, _)| *i == tree.focus), "frame {k}: the focused node {:?} isn't in the tree", tree.focus); + flat(&out.shapes) +} + +/// A click with `button` at `p` and the frames that follow, each checked. +fn click(u: &mut Ui, p: Pos2, button: PointerButton) -> Vec { + for _ in 0..5 { + u.frame(vec![Event::PointerMoved(p)]); + } + let mut shapes = Vec::new(); + for (k, pressed) in [Some(true), Some(false)].into_iter().chain(std::iter::repeat_n(None, 20)).enumerate() { + let events = pressed.map(|pressed| Event::PointerButton { pos: p, button, pressed, modifiers: Modifiers::NONE }); + shapes = checked_frame(u, events.into_iter().collect(), k); + } + shapes +} + +#[test] +fn renaming_a_style_folder_keeps_the_focus_in_the_screen_reader_tree() { + let mut s = Session::new(); + for name in ["Brand/Primary", "Brand/Accent"] { + s.execute("style.create", &json!({"type": "paint", "name": name, "value": [{"type": "solid", "color": "#0D99FF"}]})).unwrap(); + } + let mut u = Ui::new(s); + u.ctx.enable_accesskit(); + let shapes = checked_frame(&mut u, vec![], 0); + // The Design panel lists the local styles with nothing selected; the folder's menu renames it. + let menu = click(&mut u, text_center(&shapes, "Brand"), PointerButton::Secondary); + click(&mut u, text_center(&menu, "Rename folder"), PointerButton::Primary); + checked_frame(&mut u, vec![Event::Key { key: Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }], 0); + checked_frame(&mut u, vec![Event::Text("Theme".into())], 1); + for pressed in [true, false] { + checked_frame(&mut u, vec![Event::Key { key: Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }], 2); + } + for k in 0..5 { + checked_frame(&mut u, vec![], k + 3); + } + let names: Vec = u.app.session.doc().unwrap().styles.iter().map(|s| s.name.clone()).collect(); + assert_eq!(names, ["Theme/Primary", "Theme/Accent"]); +} diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 3a4e7b11d..6f0cb2cda 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -1042,7 +1042,10 @@ pub fn local_styles(app: &mut FigApp, ui: &mut Ui, doc: &Document) { let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click()); let text_r = Rect::from_min_max(pos2(r.min.x + widgets::PAD, r.min.y + 2.0), pos2(r.max.x - widgets::PAD, r.max.y - 2.0)); if renaming.as_deref() == Some(*f) { - let (out, open) = widgets::rename_field(ui, folder_rename_id().with(*f), f, text_r, regular(11.0), false); + // Focused as it is first drawn: a focus asked for before its field is drawn + // isn't in the screen reader tree (UI Automation aborts the app, #224). + let start = ui.data_mut(|d| d.remove_temp::(folder_rename_id().with("start"))).unwrap_or(false); + let (out, open) = widgets::rename_field(ui, folder_rename_id().with(*f), f, text_r, regular(11.0), start); if let Some(name) = out { run = Some(("style.renameFolder", json!({"folder": f, "name": name}))); } @@ -1055,7 +1058,7 @@ pub fn local_styles(app: &mut FigApp, ui: &mut Ui, doc: &Document) { crate::right::context_popup(&resp).show(|ui| { if crate::right::menu_item(ui, tr("Rename folder"), "") { ui.data_mut(|d| d.insert_temp(folder_rename_id(), f.to_string())); - ui.memory_mut(|m| m.request_focus(folder_rename_id().with(*f).with("te"))); + ui.data_mut(|d| d.insert_temp(folder_rename_id().with("start"), true)); ui.close(); } }); From fa0365470e6029b03335eadff7985f968960a0ed Mon Sep 17 00:00:00 2001 From: HaSh3003 <64496882+HaSh3003@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:31:58 +0300 Subject: [PATCH 061/120] M9: remove someone from a live session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-click a person's avatar ▸ "Remove from session", or `collab.removePerson {client}`, when you host the session or own the file on a server. The hub closes their connection as the end of the session for them (close 1001: they keep their copy and don't reconnect) and bars their address from that file while it runs, so the code or link they hold no longer gets them in; everyone else stays connected. Protocol addition within version 2: `Admin::RemovePeer {file, client}` on an open hosting connection with the file's owner token (or the server key). Older servers say they can't. Tests: loopback (removed, can't rejoin, others stay), engine (host can, guest can't, bad ids refused), codec round trip; the collab junk sweep covers the new command. --- apps/figcraft-server/src/store.rs | 4 + crates/collab-net/src/host.rs | 11 +++ crates/collab-net/src/hub.rs | 101 +++++++++++++++++++++++- crates/collab-net/src/link.rs | 14 ++++ crates/collab-net/src/loopback_tests.rs | 35 +++++++- crates/collab/src/codec_tests.rs | 3 + crates/collab/src/message.rs | 6 ++ crates/engine/src/live.rs | 71 +++++++++++++++++ crates/engine/src/live_tests.rs | 49 ++++++++++++ crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/src/live.rs | 23 +++++- docs/collab-protocol.md | 12 ++- docs/collaboration-parity.md | 5 +- docs/mcp.md | 7 ++ 14 files changed, 335 insertions(+), 7 deletions(-) diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index e5e56b8fb..352e3086b 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -1438,6 +1438,10 @@ impl Backend for Store { Admin::Blocked => AdminReply::Blocked(self.blocked()).into(), Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()), Admin::Owned { files } => self.owned(&files).into(), + // start feat: remove-from-session + // The hub removes people itself (it knows who is connected); it never asks the store. + Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()), + // end feat: remove-from-session Admin::Remove { file } => { if !self.files.contains_key(&file) { return failed("this file isn't on the server (it may have been removed)".into()); diff --git a/crates/collab-net/src/host.rs b/crates/collab-net/src/host.rs index 0d51735e0..a198ab5cd 100644 --- a/crates/collab-net/src/host.rs +++ b/crates/collab-net/src/host.rs @@ -14,6 +14,9 @@ use figcraft_engine::live::{Event, PEOPLE}; use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name, to_local}; use crate::inbox::Inbox; pub use crate::join::ENDED; +// start feat: remove-from-session +pub use crate::hub::REMOVED; +// end feat: remove-from-session use crate::net::NetLimits; /// What a hosted session needs. @@ -129,6 +132,14 @@ impl Host { self.hosting.send(msg); } + // start feat: remove-from-session + /// Remove client `id` from the session: they are disconnected and can't join again from + /// their address while it runs. The host can't remove themselves. + pub fn remove(&self, id: u32) { + self.hosting.remove(id); + } + // end feat: remove-from-session + /// Cut every connection as a network failure would (no close frame). #[cfg(test)] pub(crate) fn cut_connections(&self) { diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 24474211f..699107541 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -21,7 +21,7 @@ //! backend may refuse addresses or limit what they do. A plain `GET /healthz` on the same port is //! answered `200 ok` for uptime checks. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::io::{self, Read, Write}; use std::net::{IpAddr, Ipv6Addr, SocketAddr, TcpListener, TcpStream}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; @@ -413,6 +413,10 @@ enum HubIn { }, Local(Presence), LocalMsg(ClientMsg), + // start feat: remove-from-session + /// The person hosting removes client `id` from their file (app sessions). + Remove(u32), + // end feat: remove-from-session /// Cut every connection without a word (tests: a network failure). #[cfg(test)] Cut, @@ -461,7 +465,15 @@ impl Hosting { pub(crate) fn start(listener: TcpListener, backend: B, limits: NetLimits, local: Option) -> io::Result { let addr = listener.local_addr()?; let in_flight = InFlight(Arc::default()); - let mut hub = Hub { backend, rooms: HashMap::new(), conns: HashMap::new(), local: None, outbox: Vec::new(), in_flight: in_flight.clone() }; + let mut hub = Hub { + backend, + rooms: HashMap::new(), + conns: HashMap::new(), + local: None, + outbox: Vec::new(), + in_flight: in_flight.clone(), + barred: HashMap::new(), + }; if let Some(l) = local { let mut room = Room::new(l.server); room.local = Some((l.id, l.inbox)); @@ -551,6 +563,14 @@ impl Hosting { let _ = self.hub.send(HubIn::LocalMsg(msg)); } + // start feat: remove-from-session + /// The hosting person removes client `id` from their file (app sessions; see + /// [`HubIn::Remove`]). + pub(crate) fn remove(&self, id: u32) { + let _ = self.hub.send(HubIn::Remove(id)); + } + // end feat: remove-from-session + /// Cut every connection as a network failure would (no close frame). #[cfg(test)] pub(crate) fn cut_connections(&self) { @@ -1108,6 +1128,18 @@ fn unsupported(protocol: u32) -> String { /// sharing): the session's end for them. pub const STOPPED: &str = "Sharing was stopped: the link you joined with no longer works. Your copy stays open"; +// start feat: remove-from-session +/// What someone removed from a file is told: the session's end for them (they keep their copy; +/// nothing retries). +pub const REMOVED: &str = "You were removed from this session. Your copy stays open"; + +/// Why a removed person's address can't join the file again. +const BARRED: &str = "you were removed from this session"; + +/// The most addresses one file keeps barred. +const MAX_BARRED: usize = 256; +// end feat: remove-from-session + /// What everyone in a file is told when its changes couldn't be saved (they reconnect). const UNSAVED: &str = "The server couldn't save the latest changes; reconnecting"; @@ -1121,6 +1153,10 @@ struct Hub { local: Option, /// What the servers said, sent once the backend made what changed durable. outbox: Vec<(String, Outgoing)>, + // start feat: remove-from-session + /// Addresses ([`ip_key`]) removed from each file: they can't join it again while the hub runs. + barred: HashMap>, + // end feat: remove-from-session } impl Hub { @@ -1208,6 +1244,15 @@ impl Hub { self.client_message(&file, id, msg, None); } } + // start feat: remove-from-session + HubIn::Remove(id) => { + if let Some(file) = self.local.clone() + && let Err(why) = self.remove_peer(&file, id) + { + log::info!("live session: couldn't remove client {id}: {why}"); + } + } + // end feat: remove-from-session #[cfg(test)] HubIn::Cut => { for c in self.conns.values() { @@ -1232,6 +1277,16 @@ impl Hub { self.client_message(&file, id, msg, Some(bytes)); } Some(Kind::Hosting { owns }) => match msg { + // start feat: remove-from-session + ClientMsg::Admin(Admin::RemovePeer { file, client }) => { + let reply = if owns.as_deref() == Some(file.as_str()) { + self.removed_reply(&file, client) + } else { + AdminReply::Failed { reason: "an owner token manages only its own file".into() } + }; + self.answer(conn, &ServerMsg::Admin(Box::new(reply))); + } + // end feat: remove-from-session ClientMsg::Admin(req) => { let owns = owns.clone(); let rooms = &self.rooms; @@ -1243,6 +1298,12 @@ impl Hub { _ => self.kick(&[conn], "only publishing and managing your own files are spoken on this connection"), }, Some(Kind::Managing) => match msg { + // start feat: remove-from-session + ClientMsg::Admin(Admin::RemovePeer { file, client }) => { + let reply = self.removed_reply(&file, client); + self.answer(conn, &ServerMsg::Admin(Box::new(reply))); + } + // end feat: remove-from-session ClientMsg::Admin(req) => self.admin(conn, req), ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong), _ => self.kick(&[conn], "only requests to manage files are spoken on this connection"), @@ -1305,6 +1366,12 @@ impl Hub { Ok(a) => a, Err(why) => return refuse(&c.out, &why), }; + // start feat: remove-from-session + if self.barred.get(&hello.file).is_some_and(|b| b.contains(&c.ip)) { + log::info!("event=refused ip={} reason=removed", c.ip); + return refuse(&c.out, BARRED); + } + // end feat: remove-from-session let hosting = reads_hosting(&hello.client_version); // The backend checked the secret: the sessions don't keep it. hello.token.clear(); @@ -1362,6 +1429,36 @@ impl Hub { } } + // start feat: remove-from-session + /// Remove client `id` from `file`: they are told the session is over for them and + /// disconnected, and their address can't join the file again while the hub runs. The person + /// hosting can't be removed. + fn remove_peer(&mut self, file: &str, id: u32) -> Result<(), String> { + let room = self.rooms.get(file).ok_or("nobody is in this file")?; + if room.local.as_ref().is_some_and(|(local, _)| *local == id) { + return Err("the person hosting can't be removed".into()); + } + let conn = *room.ids.get(&id).ok_or_else(|| format!("nobody with client id {id} is in this file"))?; + let c = self.conns.get(&conn).ok_or_else(|| format!("nobody with client id {id} is in this file"))?; + let barred = self.barred.entry(file.to_string()).or_default(); + if barred.len() < MAX_BARRED { + barred.insert(c.ip); + } + log::info!("event=removed file={file} client={id} ip={}", c.ip); + let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into())); + self.gone(conn); + Ok(()) + } + + /// [`remove_peer`](Self::remove_peer) as a managing connection's answer. + fn removed_reply(&mut self, file: &str, id: u32) -> AdminReply { + match self.remove_peer(file, id) { + Ok(()) => AdminReply::Done, + Err(reason) => AdminReply::Failed { reason }, + } + } + // end feat: remove-from-session + /// Take in connection `c` as `kind` and send it `first`. fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) { self.conns.insert(conn, Conn { out: c.out, sock: c.sock, kind, ip: c.ip }); diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index 754f15298..49e085ed3 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -243,6 +243,20 @@ impl LiveLink for NetLink { } } + // start feat: remove-from-session + fn remove(&mut self, session: SessionId, client: u32) -> Result<(), String> { + match self.sessions.get(session) { + Some(Active::Host(h)) if h.me().client_id == client => Err("you can't remove yourself: stop the session instead".into()), + Some(Active::Host(h)) => { + h.remove(client); + Ok(()) + } + Some(Active::Guest(_)) => Err("only the person hosting can remove people from this session".into()), + None => Err("this session is over".into()), + } + } + // end feat: remove-from-session + fn leave(&mut self, session: SessionId) { if let Some(active) = self.sessions.remove(session) { active.end(); diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index 814d00044..65cbef36c 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -15,7 +15,7 @@ use tungstenite::protocol::WebSocket; use tungstenite::{Bytes, Message}; use crate::guest::{Guest, GuestConfig}; -use crate::host::{ENDED, Host, HostConfig}; +use crate::host::{ENDED, Host, HostConfig, REMOVED}; use crate::inbox::Inbox; use crate::invite::Invite; use crate::net::{Bucket, NetLimits}; @@ -611,3 +611,36 @@ fn until_presence_or_pong(r: &mut Raw) -> Option { } } } + +// start feat: remove-from-session +#[test] +fn the_host_removes_someone_and_they_cant_come_back() { + let (mut host, mut hp, invite) = start(); + let (_a, mut ap) = join(&invite, "Ada"); + let (b, mut bp) = join(&invite, "Bo"); + ap.wait("welcome", welcomed); + bp.wait("welcome", welcomed); + let (ada, bo) = (ap.me(), bp.me()); + connections_settle(&host, 2); + // The host can't remove themselves, nor someone who isn't there: nothing happens. + host.remove(host.me().client_id); + host.remove(9999); + host.remove(ada); + // Ada is told the session is over for her (no reconnecting); the others see her leave. + ap.wait("Ada removed", |e| e.iter().any(|e| matches!(e, Event::Ended { reason } if reason == REMOVED))); + hp.wait("the host sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada))); + bp.wait("Bo sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada))); + connections_settle(&host, 1); + assert!(!ap.events.iter().any(|e| matches!(e, Event::Status(Status::Reconnecting { .. }))), "a removed guest doesn't reconnect"); + assert!(!hp.left().contains(&bo), "only Ada was removed"); + // Her address can't join again with the same invite (every guest here comes from 127.0.0.1). + let (_again, mut againp) = join(&invite, "Ada"); + againp.wait("a refusal", |e| e.iter().any(|e| matches!(e, Event::Status(Status::Failed { reason }) if reason.contains("removed")))); + assert!(!welcomed(&againp.events)); + // Bo, already in, stays and can still talk to the host. + b.presence(presence(3.0, 4.0)); + hp.wait("Bo's cursor", |e| e.iter().any(|e| matches!(e, Event::Presence(id, _) if *id == bo))); + host.stop(); + assert_eq!(host.connections(), 0); +} +// end feat: remove-from-session diff --git a/crates/collab/src/codec_tests.rs b/crates/collab/src/codec_tests.rs index 849319047..08f15d226 100644 --- a/crates/collab/src/codec_tests.rs +++ b/crates/collab/src/codec_tests.rs @@ -75,6 +75,9 @@ fn samples() -> (Vec, Vec) { ClientMsg::Admin(Admin::Block { ip: "203.0.113.9".into() }), ClientMsg::Admin(Admin::Unblock { ip: "2001:db8::".into() }), ClientMsg::Admin(Admin::Blocked), + // start feat: remove-from-session + ClientMsg::Admin(Admin::RemovePeer { file: "f".into(), client: 7 }), + // end feat: remove-from-session ClientMsg::Report { reason: "Spam ☕".into() }, ]; let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap(); diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 0eb0bd8c7..1dc22bece 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -274,6 +274,12 @@ pub enum Admin { /// these `(file id, owner token)` pairs own, at most [`MAX_OWNED`] of them: /// [`AdminReply::Owned`]. Servers older than this request can't read it. Owned { files: Vec<(String, String)> }, + // start feat: remove-from-session + /// Remove client `client` from `file`'s session: they are disconnected and their address + /// can't join the file again while the server runs. With the server key, or on an open + /// hosting connection with `file`'s owner token. Servers older than this request can't read it. + RemovePeer { file: String, client: u32 }, + // end feat: remove-from-session } /// The most files one [`Admin::Owned`] asks about. diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index 09c5831d1..e621fd365 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -517,6 +517,13 @@ pub trait LiveLink: Send { fn background(&mut self, _job: u64, _req: NetRequest) -> bool { false } + // start feat: remove-from-session + /// Remove client `client` from session `session`, which this link hosts: they are + /// disconnected and can't join again from their address while it runs. Returns at once. + fn remove(&mut self, _session: SessionId, _client: u32) -> std::result::Result<(), String> { + Err("removing people needs the desktop app or the MCP server".into()) + } + // end feat: remove-from-session /// End session `session` (stop hosting or leave); the others go on. Nothing for a session /// that is over already. fn leave(&mut self, session: SessionId); @@ -1889,6 +1896,61 @@ fn follow(s: &mut Session, p: &Value) -> Result { Ok(json!({"follow": s.live().follow})) } +// start feat: remove-from-session +/// Remove someone from the open file's session: in a session this app hosts, the hub drops them; +/// in a file of yours on a server, the server does (with the file's owner token). They are told +/// the session is over for them, keep their copy, and can't join again from their address while +/// the session (or the server) runs. +fn remove_person(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "collab.removePerson"; + let client = + get(p, "client").and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).ok_or_else(|| bad(CMD, "`client` must be a client id"))?; + let live = s.live(); + if live.status != Status::Live { + return Err(bad(CMD, "the open file isn't in a live session")); + } + let name = + live.peers.get(&client).map(|p| p.name.clone()).ok_or_else(|| bad(CMD, format!("nobody with client id {client} is in the session")))?; + let (id, role, server_file) = (live.id, live.role(), live.server_file()); + match (role, server_file) { + (Some(Role::Host), _) => { + let link = s.live.link.as_deref_mut().ok_or_else(|| unavailable(CMD))?; + link.remove(id, client).map_err(EngineError::Other)?; + } + (Some(Role::Guest), Some((server, file))) => { + let owner = + s.live().published.as_ref().and_then(|p| p.owner.clone()).or_else(|| s.online_owner(&server, &file)).filter(|o| !o.is_empty()); + let owner = owner.ok_or_else(|| bad(CMD, "only the file's owner can remove people from it"))?; + let req = Some(Admin::RemovePeer { file: file.clone(), client }); + match hosting_request(s, CMD, &server, &file, &owner, req) { + Ok((_, Some(AdminReply::Done))) => {} + Ok(_) => return Err(EngineError::Other(format!("couldn't remove {name}: the server answered something else"))), + // A server older than this request can't read it. + Err(e) => return Err(EngineError::Other(format!("couldn't remove {name}: {e} (the server may be older than removing people)"))), + } + } + _ => return Err(bad(CMD, "only the person hosting the session, or the file's owner, can remove people")), + } + s.message = Some(format!("{name} was removed from the session")); + Ok(json!({"removed": client, "name": name})) +} + +/// You may remove others from the open file's session: you host it, or it is your file on a +/// server. +pub fn can_remove_people(s: &Session) -> bool { + let live = s.live(); + live.status == Status::Live + && match live.role() { + Some(Role::Host) => true, + Some(Role::Guest) => live.server_file().is_some_and(|(server, file)| { + live.published.as_ref().and_then(|p| p.owner.as_deref()).is_some_and(|o| !o.is_empty()) + || s.online_owner(&server, &file).is_some_and(|o| !o.is_empty()) + }), + None => false, + } +} +// end feat: remove-from-session + fn cursor(s: &mut Session, p: &Value) -> Result { let at = match (num(p, "x"), num(p, "y")) { (Some(x), Some(y)) => Some((x, y)), @@ -1997,6 +2059,15 @@ pub fn specs() -> Vec { |s, _| Ok(s.live_summary()) ), spec!("collab.follow", "Follow", "{client? (a peer's id; omit to stop following)} → {follow}", false, follow), + // start feat: remove-from-session + spec!( + "collab.removePerson", + "Remove from session", + "{client (a peer's id)} → {removed, name}: they are disconnected (they keep their copy) and can't join again from their address while the session runs; only the person hosting, or the owner of a file on a server, can do it", + false, + remove_person + ), + // end feat: remove-from-session spec!( "collab.cursor", "Show cursor at", diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs index 6b05199b3..f2d8cfb8c 100644 --- a/crates/engine/src/live_tests.rs +++ b/crates/engine/src/live_tests.rs @@ -28,6 +28,10 @@ pub(crate) struct Script { /// The session each presence in `sent` and each message in `msgs` was for. pub(crate) sent_to: Vec, pub(crate) msgs_to: Vec, + // start feat: remove-from-session + /// The people removed: `(session, client)`. + pub(crate) removed: Vec<(u64, u32)>, + // end feat: remove-from-session } pub(crate) struct Mock(pub(crate) Arc>); @@ -62,6 +66,12 @@ impl LiveLink for Mock { s.published.push(opts); Ok(()) } + // start feat: remove-from-session + fn remove(&mut self, session: u64, client: u32) -> Result<(), String> { + self.0.lock().unwrap().removed.push((session, client)); + Ok(()) + } + // end feat: remove-from-session fn leave(&mut self, session: u64) { let mut s = self.0.lock().unwrap(); s.left += 1; @@ -314,6 +324,45 @@ fn the_host_ending_resets_everything() { assert_eq!(s.message.as_deref(), Some("The host ended the session")); } +// start feat: remove-from-session +#[test] +fn the_host_removes_people_and_guests_cant() { + let (mut s, script) = session(); + let remove = |s: &mut Session, client: serde_json::Value| s.execute("collab.removePerson", &json!({ "client": client })); + // Not in a session: nobody to remove. + assert!(matches!(remove(&mut s, json!(2)), Err(EngineError::BadParams { .. }))); + assert!(!crate::live::can_remove_people(&s)); + s.execute("collab.host", &json!({})).unwrap(); + let id = s.live().id; + push(&script, vec![Event::PeerJoined(peer(2, "Ada")), Event::PeerJoined(peer(3, "Bo"))]); + s.live_poll(); + assert!(crate::live::can_remove_people(&s)); + // Only someone who is here, by a client id. + for bad in [json!(9), json!(1), json!(-2), json!("2"), json!(4_294_967_296u64)] { + assert!(matches!(remove(&mut s, bad.clone()), Err(EngineError::BadParams { .. })), "{bad}"); + } + let r = remove(&mut s, json!(2)).unwrap(); + assert_eq!((r["removed"].as_u64(), r["name"].as_str()), (Some(2), Some("Ada"))); + assert_eq!(script.lock().unwrap().removed, [(id, 2)]); + assert_eq!(s.message.as_deref(), Some("Ada was removed from the session")); + // The host's own session tells them she left. + push(&script, vec![Event::PeerLeft(2)]); + s.live_poll(); + assert_eq!(s.live().peers.keys().copied().collect::>(), [3]); + s.execute("collab.stop", &json!({})).unwrap(); + + // A guest in someone else's session can't remove anyone. + let (mut s, script) = session(); + s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap(); + push(&script, vec![Event::Welcome { info: info(Role::Guest, 2), peers: vec![peer(1, "Hana"), peer(3, "Bo")] }]); + s.live_poll(); + assert!(!crate::live::can_remove_people(&s)); + let e = remove(&mut s, json!(3)).unwrap_err().to_string(); + assert!(e.contains("only the person hosting"), "{e}"); + assert!(script.lock().unwrap().removed.is_empty()); +} +// end feat: remove-from-session + #[test] fn collab_commands_survive_junk() { let junk = [ diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index c35b16776..56a7acd7f 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1045,6 +1045,7 @@ name = "English" "Remove the else and its actions" = "Remove the else and its actions" "Remove this condition and its actions" = "Remove this condition and its actions" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." +"Remove {name} from session" = "Remove {name} from session" "Remove {name} from {server}?" = "Remove {name} from {server}?" "Removed" = "Removed" "Removed from the server" = "Removed from the server" diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 46687a685..03bfd3ce2 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -444,7 +444,8 @@ fn follow_frame(painter: &egui::Painter, canvas: Rect, name: &str, fill: Color32 } /// You and the others at the top of the right panel, within `area`: click someone to follow -/// them (again to stop); the chevron lists everyone. +/// them (again to stop); right-click them to remove them from the session (when you host it or +/// own the file); the chevron lists everyone. pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { let t = Tokens::get(ui.ctx()); const R: f32 = 12.0; @@ -459,6 +460,10 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { let peers: Vec<(u32, String, Color32)> = live.peers.values().map(|p| (p.client_id, p.name.clone(), color(&p.color))).collect(); let follow = live.follow; let (in_session, spotlighting) = (live.status == Status::Live, live.spotlighting); + // start feat: remove-from-session + let can_remove = figcraft_engine::live::can_remove_people(&app.session); + let mut remove = None; + // end feat: remove-from-session // Room for the avatars after yours and the chevron. let fit = (((area.width() - 2.0 * R - 18.0) / STEP).floor() as usize).max(1); let (show, more) = if peers.len() > fit { (fit - 1, peers.len() - (fit - 1)) } else { (peers.len(), 0) }; @@ -496,6 +501,16 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { if resp.clicked() { clicked = Some(*id); } + // start feat: remove-from-session + if can_remove { + crate::right::context_popup(&resp).show(|ui| { + if menu_item(ui, &trf("Remove {name} from session", &[("name", name)]), "") { + remove = Some(*id); + ui.close(); + } + }); + } + // end feat: remove-from-session } if more > 0 { x += STEP; @@ -539,6 +554,12 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { if spotlight { app.run("collab.spotlight", json!({"on": !spotlighting})); } + // start feat: remove-from-session + // A server is asked without the window waiting for it. + if let Some(id) = remove { + app.run_background(&ui.ctx().clone(), "collab.removePerson", json!({"client": id}), Box::new(|_, _, _| {})); + } + // end feat: remove-from-session } /// The Share button's popover: the live session (start, invite link, stop or leave) and export. diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 7888241d6..7f99ddfbf 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -386,6 +386,16 @@ resuming with its offline edits, and leaving the session going back to snapshot outlines in the person's colour, and avatars (initials) at the top of the right panel beside Share; click an avatar to **follow** that person's viewport (`collab.follow {client}`), click again, press Escape or pan/zoom yourself to stop. The chevron lists everyone. + +- **Removing someone.** Right-click their avatar ▸ "Remove from session" + (`collab.removePerson {client}`), when you host the session or own the file on a server. The + hub closes their connection with "You were removed from this session" (close code 1001: the + session is over for them, no reconnecting) and bars their address (its `ip_key`) from that file + while it runs, so the invite they hold no longer gets them in; everyone else stays connected. On + a server the owner asks on an open hosting connection with the file's owner token + (`Admin::RemovePeer {file, client}`; the operator may too, with the server key). Addresses are + the only identity a session has: someone behind the same address (one NAT) is barred too. + - **Text carets.** While someone types in a text layer of the shared document on your page, their caret and selected characters show on it in their colour (the text layout's own caret and selection geometry, so they sit at the right glyphs of your copy), with their name in a tag above @@ -463,7 +473,7 @@ resuming with its offline edits, and leaving the session going back to snapshot `collab.share {via?: "online" | "local", access?, …}`, `collab.host {port?, bind?: "local" | "lan" | }`, `collab.stop`, `collab.join {code | link}`, `collab.leave`, `collab.peers`, `collab.follow {client?}`, `collab.cursor {x?, y?}` (an agent's - pointer), `collab.cursorChat {text?}`, `collab.spotlight {on?}`. + pointer), `collab.cursorChat {text?}`, `collab.spotlight {on?}`, `collab.removePerson {client}`. - **Sessions are per document.** Every open file has its own session or none: hosting, joined, being published, reconnecting or failed. Several run at once (host one file, join another, publish a third), each with its own people, links, undo history, cursor chat, spotlight and diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index e7eb468f2..95084d740 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -15,7 +15,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Left: server archives beyond Linux | 25–55 | | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | -| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The list is per computer: no accounts, teams or projects | 8–17 | +| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | | Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | | Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — | | Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 | @@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Removing someone from a live session (M9, from the owner's use: whoever joined with the invite couldn't be put out short of ending the session for everyone): right-click a person's avatar ▸ "Remove from session", or `collab.removePerson {client}`, when you host the session or own the file on a server (owner token). The hub closes their connection as the session's end for them (they keep their copy and don't reconnect) and bars their address from that file while the session or server runs, so the code or link they hold no longer gets them in; everyone else stays. Protocol addition within version 2 (`Admin removePeer`; older servers say they can't). Addresses are the only identity without accounts: people behind one address are barred together. Percentages and hours unchanged | | 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. | | 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. Files are known per computer, not per person (no accounts): percentages and hours unchanged | | 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Usability and hosting rather than a new capability: percentages and hours unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 2b66657c2..0f20ed6b4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1090,6 +1090,13 @@ only: a host stops hosting it, a guest leaves it. pointer. Your selection, page and view go out by themselves when they change. - `collab.follow {client}` follows that person's viewport; `collab.follow {}` stops (so does any pan or zoom of your own, or Escape). + +- `collab.removePerson {client}` removes that person from the session: they are told the session + is over for them (they keep their copy and don't reconnect), and their address can't join the + file again while the session (or the server) runs. Only the person hosting, or the owner of a + file on a server (with its owner token), can; a server older than this request says it can't. + Returns `{removed, name}`. + - `collab.cursorChat {text}` shows `text` next to your cursor for everyone in the session (put the cursor somewhere first with `collab.cursor`): at most 100 characters, control characters become spaces, `""` closes it. Others see it fade 5 seconds after its last change, so send it again to From 5e14632207c26301c1a1389edc6276e1ada094ab Mon Sep 17 00:00:00 2001 From: Barzabel Date: Wed, 14 Oct 2026 22:39:17 +0300 Subject: [PATCH 062/120] M1: cursor and double-click tests scan the system fonts themselves (#333) --- crates/engine/src/cursor_tests.rs | 3 +++ crates/engine/src/double_click_tests.rs | 3 +++ 2 files changed, 6 insertions(+) diff --git a/crates/engine/src/cursor_tests.rs b/crates/engine/src/cursor_tests.rs index 39a18ce4a..0975f3fae 100644 --- a/crates/engine/src/cursor_tests.rs +++ b/crates/engine/src/cursor_tests.rs @@ -58,6 +58,9 @@ fn close(a: f64, b: f64) -> bool { /// A square (100,100)–(200,200), selected, and the text "Hello" at (400, 100), not selected. fn scene() -> (Session, NodeId, NodeId) { + // The text is measured with a font: without craft-fonts that is a system one, scanned here + // rather than by whichever test happens to run first (#333). + figcraft_text::scan_system_fonts(); let mut s = Session::new(); let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100}))); let t = id(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 400, "y": 100, "props": {"fontSize": 40}}))); diff --git a/crates/engine/src/double_click_tests.rs b/crates/engine/src/double_click_tests.rs index e51cb7da8..2ffa00833 100644 --- a/crates/engine/src/double_click_tests.rs +++ b/crates/engine/src/double_click_tests.rs @@ -15,6 +15,9 @@ fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { /// A frame holding a group of a rectangle and a text layer at (60, 60); returns (group, text). fn scene(s: &mut Session) -> (NodeId, NodeId) { + // The text is measured with a font: without craft-fonts that is a system one, scanned here + // rather than by whichever test happens to run first (#333). + figcraft_text::scan_system_fonts(); let f = id_of(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300}))["id"]).unwrap(); let r = id_of(&ex(s, "shape.rectangle", json!({"x": 40, "y": 40, "width": 200, "height": 60, "parent": f}))["id"]).unwrap(); let t = id_of(&ex(s, "text.create", json!({"text": "Hi there", "x": 60, "y": 60, "parent": f, "props": {"fontSize": 16}}))["id"]).unwrap(); From ca2f5dbceed0606a7f92317a3168ddb808d0815b Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:47:51 +0300 Subject: [PATCH 063/120] collab: refuse sequence numbers a host cannot advance The welcome's seq and processed.last_batch are the host's word and were used unchecked: accepted computed self.seq + 1 and queue bumped next_batch, so a host sending u64::MAX overflowed the client (panic in debug, wrap in release). Both counters now step with checked_add/saturating_add, and a sequence that cannot advance is refused as a protocol error (the established rejoin path). Test: hostile_sequence_numbers_do_not_overflow_the_client. --- crates/collab/src/client.rs | 9 +++++-- crates/collab/src/client_tests.rs | 43 +++++++++++++++++++++++++++++++ crates/collab/src/lib.rs | 2 ++ 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 crates/collab/src/client_tests.rs diff --git a/crates/collab/src/client.rs b/crates/collab/src/client.rs index a2c6e7449..8f2a98419 100644 --- a/crates/collab/src/client.rs +++ b/crates/collab/src/client.rs @@ -307,7 +307,8 @@ impl Client { /// Queue `changes` as a new batch (already shown in the view). fn queue(&mut self, changes: Vec, kind: BatchKind, step: Option) { let batch_id = self.next_batch; - self.next_batch += 1; + // `next_batch` starts from the welcome's `last_batch`, the host's word: saturate. + self.next_batch = self.next_batch.saturating_add(1); self.pending.push_back(Pending { batch_id, base_seq: self.seq, changes, kind, step, rebased: false, sent: false }); } @@ -484,7 +485,11 @@ impl Client { /// A batch the server accepted (in order). Follow-ups it causes are queued. fn accepted(&mut self, acc: Accepted, recompute: &dyn Recompute) -> Result<(), Error> { - if acc.seq != self.seq + 1 { + // The host's sequence number is untrusted: at `u64::MAX` it can't step past it. + let Some(next) = self.seq.checked_add(1) else { + return Err(Error::Protocol(format!("sequence {} after {}", acc.seq, self.seq))); + }; + if acc.seq != next { return Err(Error::Protocol(format!("sequence {} after {}", acc.seq, self.seq))); } if acc.author != self.id { diff --git a/crates/collab/src/client_tests.rs b/crates/collab/src/client_tests.rs new file mode 100644 index 000000000..67e1e7a1d --- /dev/null +++ b/crates/collab/src/client_tests.rs @@ -0,0 +1,43 @@ +//! A client's handling of the host's messages: sequence numbers and batch ids come from the host, +//! so they are treated as hostile input (they must never overflow). + +use figcraft_doc::NodeKind; + +use crate::message::{Accepted, ClientMsg, Hello, ServerMsg}; +use crate::sim_tests::base; +use crate::{Client, Limits, NoRecompute, Outgoing, Server}; + +fn hello() -> Hello { + let ClientMsg::Hello(h) = Client::hello("f", "tok", "Ada", "#FF8800") else { panic!("not a hello") }; + h +} + +/// A welcome whose sequence numbers are what a lying host would send. +fn hostile_welcome(seq: u64, last_batch: u64) -> ServerMsg { + let mut s = Server::new(base(), "tok", Limits::default()); + let (_, out) = s.join(hello()).unwrap(); + let mut welcome = out + .into_iter() + .find_map(|o| match o { + Outgoing::To(_, m) => Some(m), + _ => None, + }) + .unwrap(); + let ServerMsg::Welcome { seq: seq0, processed, .. } = &mut welcome else { panic!("no welcome") }; + *seq0 = seq; + processed.last_batch = last_batch; + welcome +} + +#[test] +fn hostile_sequence_numbers_do_not_overflow_the_client() { + let mut c = Client::join(hostile_welcome(u64::MAX, u64::MAX)).unwrap(); + // This client's own first batch must not overflow its batch counter. + let mut d = c.document().clone(); + let page = d.first_page().unwrap(); + d.create(page, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap(); + assert!(c.edit(&d).unwrap().is_some(), "the edit is batched"); + // A batch the host claims follows `u64::MAX` can't exist: refuse it, don't overflow. + let acc = Accepted { seq: 0, author: c.id().wrapping_add(1), batch_id: 0, changes: Vec::new() }; + assert!(c.receive(ServerMsg::Changes(acc), &NoRecompute).is_err()); +} diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 502d8a3e4..2c693bc41 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -123,6 +123,8 @@ impl Recompute for NoRecompute { fn recompute(&self, _: &mut Document, _: &Document, _: &BTreeSet) {} } +#[cfg(test)] +mod client_tests; #[cfg(test)] mod codec_tests; #[cfg(test)] From 245837ff27ca0b5b3e5a7c012849918cba40851c Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:52:15 +0300 Subject: [PATCH 064/120] doc: name counters stop at u64::MAX instead of overflowing Document::next_name summed max + 1 over numbers parsed from existing layer names, and bump_name did the same for the duplicate-rename path: a document (or peer, or node.set) holding a layer named "Frame 18446744073709551615" overflowed both (panic in debug, wrap to a wrong name in release). next_name now collects the numbers it sees and, when the counter is spent, picks the lowest free number (mirrors new_style_id); bump_name only bumps when the number can step, else falls to its existing " 2" branch. Tests: next_name_never_overflows (doc) and duplicating_a_layer_named_past_the_counter_does_not_overflow (engine panel behaviour). --- crates/doc/src/lib.rs | 9 ++++++++- crates/doc/src/tests.rs | 10 ++++++++++ crates/engine/src/cmd/edit.rs | 3 ++- crates/engine/src/panels_behaviour_tests.rs | 11 +++++++++++ 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3ae8bbe8a..513800e37 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -903,15 +903,22 @@ impl Document { /// A unique name like "Frame 3" for a new node of a kind. pub fn next_name(&self, base: &str) -> String { let mut max = 0u64; + let mut taken = std::collections::BTreeSet::new(); for n in self.nodes() { if let Some(rest) = n.name.strip_prefix(base) && let Some(num) = rest.strip_prefix(' ') && let Ok(k) = num.parse::() { max = max.max(k); + taken.insert(k); } } - format!("{base} {}", max + 1) + let next = match max.checked_add(1) { + Some(next) => next, + // The counter is spent: the lowest free number instead of wrapping onto a name. + None => (1u64..).find(|k| !taken.contains(k)).unwrap_or(1), + }; + format!("{base} {next}") } pub fn style(&self, id: &str) -> Option<&Style> { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index ef2f37b93..d54bb29b9 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -335,6 +335,16 @@ fn next_name_increments() { assert_eq!(d.next_name("Ellipse"), "Ellipse 1"); } +#[test] +fn next_name_never_overflows() { + let mut d = Document::new("x"); + let p = page(&d); + d.create(p, NodeKind::Frame, "Frame 18446744073709551615", 0.0, 0.0, 1.0, 1.0).unwrap(); + assert_eq!(d.next_name("Frame"), "Frame 1", "the counter can't step past u64::MAX"); + d.create(p, NodeKind::Frame, "Frame 1", 0.0, 0.0, 1.0, 1.0).unwrap(); + assert_eq!(d.next_name("Frame"), "Frame 2"); +} + #[test] fn custom_easings() { use crate::proto::Easing; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 57ba9fb00..4f5e91eb4 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -446,8 +446,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { fn bump_name(n: &str) -> String { if let Some((base, num)) = n.rsplit_once(' ') && let Ok(k) = num.parse::() + && let Some(next) = k.checked_add(1) { - return format!("{base} {}", k + 1); + return format!("{base} {next}"); } format!("{n} 2") } diff --git a/crates/engine/src/panels_behaviour_tests.rs b/crates/engine/src/panels_behaviour_tests.rs index f28024615..b10973989 100644 --- a/crates/engine/src/panels_behaviour_tests.rs +++ b/crates/engine/src/panels_behaviour_tests.rs @@ -386,3 +386,14 @@ fn assets_panel_lists_files_and_drops_by_the_documented_rules() { assert_eq!(kids(&s, row).first(), Some(&into), "an asset dropped in an auto layout frame joins the flow where it is dropped"); assert_eq!(s.doc().unwrap().page_of(into), Some(page)); } + +#[test] +fn duplicating_a_layer_named_past_the_counter_does_not_overflow() { + let mut s = Session::new(); + ex(&mut s, "pref.renameDuplicates", json!({"on": true})); + let r = rect(&mut s, "r", 30.0, 40.0, None); + ex(&mut s, "node.set", json!({"id": r, "props": {"name": "Layer 18446744073709551615"}})); + let dup = id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [r]}))["ids"][0]).unwrap(); + assert_eq!(name(&s, dup), "Layer 18446744073709551615 2"); + assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out"); +} From 90a4606c65e94e9039d969a208d533bffddb9c80 Mon Sep 17 00:00:00 2001 From: Barzabel Date: Wed, 14 Oct 2026 22:52:43 +0300 Subject: [PATCH 065/120] M9: a compaction cut short closes the file instead of confirming lost edits (#183) --- apps/figcraft-server/src/store.rs | 47 +++++++++++++++++++------ apps/figcraft-server/src/store_tests.rs | 44 +++++++++++++++++++++++ 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index e5e56b8fb..f6cf50c26 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -265,6 +265,9 @@ pub struct Store { next_sweep: u64, /// Compactions so far (tests count them). compactions: u64, + /// Open files closed by a compaction cut short (their snapshot replaced, their log not): + /// reported at the next flush, so nothing appended after it is confirmed. + cut_short: Vec, /// The free space last seen on the data directory's disk, and when. free: Option<(Instant, Option)>, _lock: File, @@ -360,6 +363,7 @@ impl Store { gone_codes: VecDeque::new(), next_sweep: 0, compactions: 0, + cut_short: Vec::new(), free: None, _lock: lock, }; @@ -1124,7 +1128,7 @@ impl Store { Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 } } // A new snapshot and log of the next generation, so appends start clean. - _ => self.rewrite(file, generation + 1, &server)?, + _ => self.rewrite(file, generation + 1, &server).map_err(|e| e.why)?, }; self.entry(file)?.log = Some(log); Ok(server) @@ -1137,21 +1141,24 @@ impl Store { } /// Write `server`'s state as snapshot `generation` and start an empty log of it. - fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result { + fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result { let dir = self.dir(file); - let state = server.state().map_err(|e| e.to_string())?; - let bytes = disk::encode_snapshot(generation, &state)?; + let kept = |why: String| RewriteError { why, snapshot_replaced: false }; + let state = server.state().map_err(|e| kept(e.to_string()))?; + let bytes = disk::encode_snapshot(generation, &state).map_err(kept)?; // Never replace a readable snapshot with one that can't be opened again: the old snapshot // and log still hold everything. if bytes.len() as u64 > self.snapshot_cap() { - return Err(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20)); + return Err(kept(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20))); } if bytes.len() as u64 > self.cfg.max_file_bytes { log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20); } - disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| format!("couldn't write the snapshot: {e}"))?; - disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| format!("couldn't start the log: {e}"))?; - let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| format!("couldn't open the log: {e}"))?; + disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| kept(format!("couldn't write the snapshot: {e}")))?; + // From here the old log no longer goes with the snapshot on disk. + let replaced = |why: String| RewriteError { why, snapshot_replaced: true }; + disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| replaced(format!("couldn't start the log: {e}")))?; + let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| replaced(format!("couldn't open the log: {e}")))?; if let Ok(e) = self.entry(file) { e.bytes = (bytes.len() + LOG_HEADER) as u64; e.meta.seq = server.seq(); @@ -1204,8 +1211,19 @@ impl Store { } log::debug!("compacted file {file}"); } + // The new snapshot holds everything, but a restart would drop whatever is appended to + // the old log now (its generation no longer matches): the file closes, the hub hears + // it at the next flush, and it opens again from the new snapshot. + Err(RewriteError { why, snapshot_replaced: true }) => { + self.note(format!("file {file}: compaction was cut short ({why}); it was closed")); + log::error!("file {file}: compaction was cut short: {why}"); + if let Some(e) = self.files.get_mut(file) { + e.log = None; + } + self.cut_short.push(file.to_string()); + } // The old snapshot and log still hold everything: carry on appending. - Err(e) => log::error!("file {file}: compaction failed: {e}"), + Err(RewriteError { why, .. }) => log::error!("file {file}: compaction failed: {why}"), } } @@ -1213,7 +1231,7 @@ impl Store { /// Those are closed here (their pending records dropped: nobody was told of them) and open /// again from what is on disk. fn flush_all(&mut self) -> Vec { - let mut failed = Vec::new(); + let mut failed = std::mem::take(&mut self.cut_short); for (id, e) in &mut self.files { if let Some(log) = e.log.as_mut() { match flush_log(id, log) { @@ -1229,6 +1247,13 @@ impl Store { } } +/// Why a snapshot and log couldn't be rewritten, and whether the snapshot on disk was replaced +/// before it failed (then the old log no longer goes with it). +struct RewriteError { + why: String, + snapshot_replaced: bool, +} + /// Write and sync a log's pending records: the bytes written, or `Err` when that failed (the log /// can't be trusted to hold them). fn flush_log(id: &str, log: &mut Log) -> Result { @@ -1316,6 +1341,8 @@ impl Backend for Store { if dirty { self.compact(file, server); } + // It closes anyway, everything in its snapshot: a compaction cut short needs no report. + self.cut_short.retain(|f| f != file); if let Some(e) = self.files.get_mut(file) { e.log = None; } diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index fda51d4f5..f380568c8 100644 --- a/apps/figcraft-server/src/store_tests.rs +++ b/apps/figcraft-server/src/store_tests.rs @@ -269,3 +269,47 @@ fn random_bytes_never_panic_the_readers() { let _ = disk::read_log(&bytes); } } + +/// The first page's name in `server`'s document. +fn page_name(server: &Server) -> String { + let d = server.replica().document(); + d.node(d.first_page().unwrap()).unwrap().name.clone() +} + +#[test] +fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() { + let dir = TempDir::new("cutshort"); + let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) }; + let folder = dir.path().join(FILES); + let mut store = Store::open(dir.path(), c.clone()).unwrap(); + let (id, _, _) = publish(&mut store, "Busy"); + let mut server = store.open(&id).unwrap(); + let mut a = join(&mut server, Access::Edit); + store.changed(&id, &mut server); + // The next compaction writes its snapshot, then can't start its log. + let blocker = disk::temp_of(&folder.join(&id).join(LOG)); + fs::create_dir(&blocker).unwrap(); + // What the hub would confirm: every change whose flush didn't report the file. + let mut confirmed = String::new(); + let mut failed = false; + let names = ["P0", "P1", "P2", "After 1", "After 2"]; + for name in names { + rename(&mut server, &mut a, name); + store.changed(&id, &mut server); + if store.flush().contains(&id) { + failed = true; + break; + } + confirmed = name.to_string(); + } + assert!(failed, "the hub hears the file can't be kept; confirmed {confirmed:?}"); + // A crash, then the disk recovers. + drop(store); + fs::remove_dir(&blocker).unwrap(); + let mut store = Store::open(dir.path(), c).unwrap(); + let restored = store.open(&id).unwrap(); + // Every confirmed change is there (the snapshot may also hold the one that was cut short). + let at = |n: &str| names.iter().position(|x| *x == n); + let name = page_name(&restored); + assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}"); +} From f387d3f464dfcec5e76c1aabba83d50a69854ca3 Mon Sep 17 00:00:00 2001 From: HaSh3003 <64496882+HaSh3003@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:00:42 +0300 Subject: [PATCH 066/120] M9: translate Remove from session The menu item and the collab.removePerson label in es, de, fr, ja, ko, pt-BR and zh-Hans (es must be complete). --- crates/ui-egui/locales/de.toml | 2 ++ crates/ui-egui/locales/es.toml | 2 ++ crates/ui-egui/locales/fr.toml | 2 ++ crates/ui-egui/locales/ja.toml | 2 ++ crates/ui-egui/locales/ko.toml | 2 ++ crates/ui-egui/locales/pt-BR.toml | 2 ++ crates/ui-egui/locales/zh-Hans.toml | 2 ++ 7 files changed, 14 insertions(+) diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 7552cca36..83aa6df96 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -997,6 +997,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "„Sonst“ und seine Aktionen entfernen" "Remove this condition and its actions" = "Diese Bedingung und ihre Aktionen entfernen" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Diese Datei vom Server entfernen? Sie kann von dort nicht mehr geteilt oder geöffnet werden. Ihre Kopie bleibt hier geöffnet." +"Remove {name} from session" = "{name} aus der Sitzung entfernen" "Remove {name} from {server}?" = "{name} von {server} entfernen?" "Removed" = "Entfernt" "Removed from the server" = "Vom Server entfernt" @@ -1568,6 +1569,7 @@ status = "draft — needs native review" "collab.publish" = "Auf Server veröffentlichen" "collab.recoverFile" = "Online-Datei wiederherstellen" "collab.registerLinks" = "Einladungslinks in FigCraft öffnen" +"collab.removePerson" = "Aus der Sitzung entfernen" "collab.report" = "Datei dem Serverbetreiber melden" "collab.serverInfo" = "Serverinformationen" "collab.share" = "Mit Beitrittscode teilen" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 6514ac7be..06f38c15b 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1046,6 +1046,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "Quitar el «si no» y sus acciones" "Remove this condition and its actions" = "Quitar esta condición y sus acciones" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "¿Quitar este archivo del servidor? No se podrá volver a compartir ni abrir desde allí. Tu copia sigue abierta aquí." +"Remove {name} from session" = "Quitar a {name} de la sesión" "Remove {name} from {server}?" = "¿Quitar {name} de {server}?" "Removed" = "Eliminado" "Removed from the server" = "Quitado del servidor" @@ -1643,6 +1644,7 @@ status = "draft — needs native review" "collab.publish" = "Publicar en el servidor" "collab.recoverFile" = "Recuperar archivo en línea" "collab.registerLinks" = "Abrir enlaces de invitación en FigCraft" +"collab.removePerson" = "Quitar de la sesión" "collab.report" = "Denunciar archivo al operador del servidor" "collab.serverInfo" = "Información del servidor" "collab.share" = "Compartir con un código para unirse" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index c86659345..2f904c606 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -997,6 +997,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "Retirer le « sinon » et ses actions" "Remove this condition and its actions" = "Retirer cette condition et ses actions" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Retirer ce fichier du serveur ? Il ne pourra plus être partagé ni ouvert depuis celui-ci. Votre copie reste ouverte ici." +"Remove {name} from session" = "Retirer {name} de la session" "Remove {name} from {server}?" = "Retirer {name} de {server} ?" "Removed" = "Supprimé" "Removed from the server" = "Retiré du serveur" @@ -1568,6 +1569,7 @@ status = "draft — needs native review" "collab.publish" = "Publier sur un serveur" "collab.recoverFile" = "Récupérer un fichier en ligne" "collab.registerLinks" = "Ouvrir les liens d’invitation dans FigCraft" +"collab.removePerson" = "Retirer de la session" "collab.report" = "Signaler le fichier à l’opérateur du serveur" "collab.serverInfo" = "Informations sur le serveur" "collab.share" = "Partager avec un code d’accès" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index f7b9cf9fd..1801f0c4d 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -999,6 +999,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "「それ以外」とそのアクションを削除" "Remove this condition and its actions" = "この条件とそのアクションを削除" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "このファイルをサーバーから削除しますか? サーバーからの共有や再オープンはできなくなる。手元のコピーは開いたまま。" +"Remove {name} from session" = "{name} をセッションから削除" "Remove {name} from {server}?" = "{name} を {server} から削除しますか?" "Removed" = "削除" "Removed from the server" = "サーバーから削除済み" @@ -1570,6 +1571,7 @@ status = "draft — needs native review" "collab.publish" = "サーバーに公開" "collab.recoverFile" = "オンラインファイルを復元" "collab.registerLinks" = "招待リンクを FigCraft で開く" +"collab.removePerson" = "セッションから削除" "collab.report" = "ファイルをサーバーの運営者に報告" "collab.serverInfo" = "サーバー情報" "collab.share" = "参加コードで共有" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 59bb23868..657b68175 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -998,6 +998,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "\"그 외\"와 해당 액션 제거" "Remove this condition and its actions" = "이 조건과 해당 액션 제거" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "이 파일을 서버에서 제거할까요? 서버에서 다시 공유하거나 열 수 없게 됨. 내 사본은 여기 열린 채로 유지됨." +"Remove {name} from session" = "{name} 님을 세션에서 제거" "Remove {name} from {server}?" = "{name}을(를) {server}에서 제거할까요?" "Removed" = "제거됨" "Removed from the server" = "서버에서 제거됨" @@ -1569,6 +1570,7 @@ status = "draft — needs native review" "collab.publish" = "서버에 게시" "collab.recoverFile" = "온라인 파일 복구" "collab.registerLinks" = "초대 링크를 FigCraft에서 열기" +"collab.removePerson" = "세션에서 제거" "collab.report" = "서버 운영자에게 파일 신고" "collab.serverInfo" = "서버 정보" "collab.share" = "참여 코드로 공유" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index a692a8cee..40336098e 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -996,6 +996,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "Remover o \"senão\" e suas ações" "Remove this condition and its actions" = "Remover esta condição e suas ações" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Remover este arquivo do servidor? Ele não poderá mais ser compartilhado nem aberto a partir de lá. Sua cópia continua aberta aqui." +"Remove {name} from session" = "Remover {name} da sessão" "Remove {name} from {server}?" = "Remover {name} de {server}?" "Removed" = "Removido" "Removed from the server" = "Removido do servidor" @@ -1567,6 +1568,7 @@ status = "draft — needs native review" "collab.publish" = "Publicar no servidor" "collab.recoverFile" = "Recuperar arquivo online" "collab.registerLinks" = "Abrir links de convite no FigCraft" +"collab.removePerson" = "Remover da sessão" "collab.report" = "Denunciar arquivo ao operador do servidor" "collab.serverInfo" = "Informações do servidor" "collab.share" = "Compartilhar com um código de acesso" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index d2431df09..87edf016f 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -999,6 +999,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "移除“否则”及其操作" "Remove this condition and its actions" = "移除此条件及其操作" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "要从服务器移除此文件吗?之后将无法再从服务器分享或打开它。你的副本仍在此处打开。" +"Remove {name} from session" = "将 {name} 移出会话" "Remove {name} from {server}?" = "要从 {server} 移除 {name} 吗?" "Removed" = "已删除" "Removed from the server" = "已从服务器移除" @@ -1570,6 +1571,7 @@ status = "draft — needs native review" "collab.publish" = "发布到服务器" "collab.recoverFile" = "恢复在线文件" "collab.registerLinks" = "在 FigCraft 中打开邀请链接" +"collab.removePerson" = "移出会话" "collab.report" = "向服务器运营者举报文件" "collab.serverInfo" = "服务器信息" "collab.share" = "用加入代码分享" From 212438a6e3b5d5d38ae73c1925b8a4484ea4b1c5 Mon Sep 17 00:00:00 2001 From: IanHollow <72767437+IanHollow@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:12:38 -0700 Subject: [PATCH 067/120] Set the native window identity to match the desktop entry Assisted-by: OpenAI Codex (GPT-6) Signed-off-by: IanHollow <72767437+IanHollow@users.noreply.github.com> --- apps/figcraft/src/main.rs | 1 + packaging/linux/ai.storyteller.figcraft.desktop | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 0332f7d83..e13cc85d6 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -170,6 +170,7 @@ fn main() -> eframe::Result { let icon = services::window_icon(); let mut viewport = egui::ViewportBuilder::default() .with_title("FigCraft") + .with_app_id("ai.storyteller.figcraft") .with_inner_size([1440.0, 900.0]) .with_min_inner_size([800.0, 500.0]) .with_drag_and_drop(true); diff --git a/packaging/linux/ai.storyteller.figcraft.desktop b/packaging/linux/ai.storyteller.figcraft.desktop index 767dd6e54..9c0adcf9f 100644 --- a/packaging/linux/ai.storyteller.figcraft.desktop +++ b/packaging/linux/ai.storyteller.figcraft.desktop @@ -8,7 +8,7 @@ TryExec=figcraft Icon=ai.storyteller.figcraft Terminal=false StartupNotify=true -StartupWMClass=figcraft +StartupWMClass=ai.storyteller.figcraft Categories=Graphics;2DGraphics;VectorGraphics;X-Design; Keywords=design;interface;ui;ux;prototype;wireframe;mockup;vector;layout;components; MimeType=application/x-figcraft;image/svg+xml;image/png;image/jpeg;x-scheme-handler/figcraft; From 65e52aa7b99ad20ebe1bbeae84aeb654fc28bcd6 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:22:41 +0300 Subject: [PATCH 068/120] engine: long digit strings no longer overflow object.opacityKeys opacity_from_digits multiplied and added on every character of its input string before the one-or-two-digit check, so "9999999999" overflowed u32 on the way (panic in debug builds, wrap in release). The scan now steps n with checked_mul/checked_add and stops (None) at the first character the number cannot take, so a long digit string is refused like any other bad input. Test: opacity_digits (engine keymap) covers the ten-digit string and the command's BadParams refusal. --- crates/engine/src/cmd/edit.rs | 3 ++- crates/engine/src/keymap_tests.rs | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 57ba9fb00..3068ffb9c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -803,7 +803,8 @@ pub fn opacity_from_digits(digits: &str) -> Option { let mut n = 0u32; let mut count = 0; for c in digits.chars() { - n = n * 10 + c.to_digit(10)?; + // Input-derived: a number that outgrows `u32` stops the scan (None); it never wraps. + n = n.checked_mul(10)?.checked_add(c.to_digit(10)?)?; count += 1; } match count { diff --git a/crates/engine/src/keymap_tests.rs b/crates/engine/src/keymap_tests.rs index 8f6f28c18..38775d3b4 100644 --- a/crates/engine/src/keymap_tests.rs +++ b/crates/engine/src/keymap_tests.rs @@ -4,11 +4,11 @@ use serde_json::{Value, json}; -use crate::Session; use crate::cmd::edit::opacity_from_digits; use crate::keymap::{Context, GESTURES, Group, KEYS, Mods, Platform, display, lookup, matches, normalize, parse, primary}; use crate::params::id_of; use crate::tools::ToolId; +use crate::{EngineError, Session}; use figcraft_doc::NodeId; const PLATFORMS: [Platform; 2] = [Platform::Mac, Platform::Other]; @@ -186,6 +186,7 @@ fn opacity_digits() { assert_eq!(opacity_from_digits("00"), Some(0.0)); assert_eq!(opacity_from_digits(""), None); assert_eq!(opacity_from_digits("123"), None); + assert_eq!(opacity_from_digits("9999999999"), None, "ten digits must not overflow u32"); assert_eq!(opacity_from_digits("4x"), None); assert_eq!(opacity_from_digits("٣"), None, "ASCII digits only"); @@ -201,6 +202,10 @@ fn opacity_digits() { ex(&mut s, "edit.undo", json!({})); assert_eq!(op(&s, a), 0.3, "one undo step"); assert!(s.execute("object.opacityKeys", &json!({"digits": "123"})).is_err()); + assert!( + matches!(s.execute("object.opacityKeys", &json!({"digits": "9999999999"})), Err(EngineError::BadParams { .. })), + "an overflowing digit string is a parameter error, not a panic" + ); assert!(s.execute("object.opacityKeys", &json!({})).is_err()); ex(&mut s, "select.none", json!({})); assert!(s.execute("object.opacityKeys", &json!({"digits": "1"})).is_err(), "nothing selected"); From 8efb66ba194c2a8d183d98f9823f79fed8cf84df Mon Sep 17 00:00:00 2001 From: vxMacDev <77440838+vxMacDev@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:24:40 -0500 Subject: [PATCH 069/120] fix(ui): step through the font picker with Up and Down (#345) Up and Down now move a highlight through the family list, starting from the current family and scrolling to keep it in view. The text panel previews the highlighted family on the canvas, as it does for a hovered font style, and Enter applies it. Escape leaves the text as it was. --- crates/ui-egui/src/style_picker.rs | 2 +- crates/ui-egui/src/typography.rs | 128 ++++++++++++++++++++++++++--- 2 files changed, 118 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 3a4e7b11d..2c5e539d4 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -746,7 +746,7 @@ fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) { (family_resp, style_resp) }); crate::right::menu_popup(&family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()) { + if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()).picked { set(out, &|d| { d.font_family = f.clone(); let styles = figcraft_text::family_styles(&f); diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 873ac0f0b..783b541b4 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -181,10 +181,14 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle] let w = ui.available_width(); let resp = widgets::dropdown(ui, family.as_deref().unwrap_or(tr(MIXED)), w, None); menu_popup(&resp).width(w.max(240.0)).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - if let Some(f) = font_picker(ui, family.as_deref(), resp.clicked()) { + let pick = font_picker(ui, family.as_deref(), resp.clicked()); + if let Some(f) = pick.picked { remember_family(ui.ctx(), &f); apply_style(app, "fontFamily", json!(f)); ui.close(); + } else if let Some(f) = pick.highlighted { + // The canvas shows the family the arrow keys are on. + app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, json!({"fontFamily": f}))); } }); }); @@ -1049,14 +1053,31 @@ enum PickRow { Family(String), } -/// The searchable family list. Returns the family picked. -pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> Option { +/// What the family list did this frame. +#[derive(Default)] +pub(crate) struct FontPick { + /// The family clicked, or the one Enter picked. + pub picked: Option, + /// The family Up and Down are on (Enter picks it). + pub highlighted: Option, +} + +/// The searchable family list: click a family, or step through it with Up and Down and press Enter. +pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> FontPick { let t = Tokens::get(ui.ctx()); let q_id = Id::new("font_query"); + // The row Up and Down are on, and the search it is a row of. + let hi_id = Id::new("font_highlight"); let mut q: String = ui.data(|d| d.get_temp(q_id)).unwrap_or_default(); if just_opened { q.clear(); + ui.data_mut(|d| d.remove::<(String, usize)>(hi_id)); } + // Taken before the search field sees them (it would move its cursor). + let step = ui.input_mut(|i| { + i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) as i64 + - i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) as i64 + }); let search = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tr("Search fonts")).desired_width(ui.available_width()).font(regular(11.5))); if just_opened { search.request_focus(); @@ -1065,9 +1086,6 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) let ql = q.trim().to_lowercase(); let all = figcraft_text::family_names(); let matches: Vec = all.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)).cloned().collect(); - if search.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - return matches.into_iter().next(); - } let mut rows: Vec = Vec::new(); if ql.is_empty() { let recent: Vec = recent_families(ui.ctx()).into_iter().filter(|f| all.iter().any(|a| a.eq_ignore_ascii_case(f))).collect(); @@ -1079,13 +1097,64 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) } if matches.is_empty() { widgets::label(ui, tr("No fonts match"), t.text_tertiary); - return None; + return FontPick::default(); } rows.extend(matches.into_iter().map(PickRow::Family)); + let family = |i: usize| match rows.get(i) { + Some(PickRow::Family(f)) => Some(f.clone()), + _ => None, + }; + // A new search starts again; the first step starts from the current family. + let mut hi = ui.data(|d| d.get_temp::<(String, usize)>(hi_id)).filter(|(hq, _)| *hq == q).map(|(_, i)| i); + let stride = ROW + ui.spacing().item_spacing.y; + let mut offset = None; + if step != 0 { + let next = |i: usize| if step > 0 { i.checked_add(1) } else { i.checked_sub(1) }; + let mut from = + hi.or_else(|| rows.iter().rposition(|r| matches!(r, PickRow::Family(f) if current.is_some_and(|c| c.eq_ignore_ascii_case(f))))); + for _ in 0..step.unsigned_abs() { + let mut at = match from { + Some(i) => next(i), + None if step > 0 => Some(0), + None => rows.len().checked_sub(1), + }; + // Past the headers; at either end it stays where it was. + while let Some(i) = at.filter(|i| *i < rows.len() && family(*i).is_none()) { + at = next(i); + } + if let Some(i) = at.filter(|i| family(*i).is_some()) { + from = Some(i); + hi = Some(i); + } + } + if let Some(i) = hi { + // Scrolled just enough to show it. + let seen: f32 = ui.data(|d| d.get_temp(hi_id.with("offset"))).unwrap_or(0.0); + let top = i as f32 * stride; + offset = Some(seen.max(top + stride - 380.0).min(top)); + } + } + match hi { + Some(i) => ui.data_mut(|d| { + d.insert_temp(hi_id, (q.clone(), i)); + }), + None => ui.data_mut(|d| d.remove::<(String, usize)>(hi_id)), + } + if search.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + let first = rows.iter().find_map(|r| match r { + PickRow::Family(f) => Some(f.clone()), + PickRow::Header(_) => None, + }); + return FontPick { picked: hi.and_then(family).or(first), highlighted: None }; + } let mut budget = 4; let mut picked = None; - egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show_rows(ui, ROW, rows.len(), |ui, range| { - for r in rows.get(range).unwrap_or(&[]) { + let mut area = egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]); + if let Some(y) = offset { + area = area.vertical_scroll_offset(y); + } + let shown = area.show_rows(ui, ROW, rows.len(), |ui, range| { + for (i, r) in rows.iter().enumerate().skip(range.start).take(range.len()) { let w = ui.available_width(); let (rect, resp) = ui.allocate_exact_size(vec2(w, ROW), if matches!(r, PickRow::Family(_)) { Sense::click() } else { Sense::hover() }); match r { @@ -1093,7 +1162,7 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, *h, semibold(10.5), t.text_secondary); } PickRow::Family(f) => { - let hovered = resp.hovered(); + let hovered = resp.hovered() || hi == Some(i); if hovered { ui.painter().rect_filled(rect, CornerRadius::same(5), t.accent); } @@ -1129,7 +1198,8 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) } } }); - picked + ui.data_mut(|d| d.insert_temp(hi_id.with("offset"), shown.state.offset.y)); + FontPick { highlighted: hi.and_then(family), picked } } #[cfg(test)] @@ -1153,6 +1223,42 @@ mod tests { assert_eq!(parse_letter_spacing("x", Some(LetterSpacing::Percent(0.0))), None); } + /// One frame of the font picker with `events`. + fn pick(ctx: &egui::Context, current: &str, opened: bool, events: Vec) -> FontPick { + let mut out = FontPick::default(); + let mut shown = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| out = font_picker(ui, Some(current), opened)); + shown.textures_delta.clear(); + out + } + + fn key(key: egui::Key) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE } + } + + #[test] + fn arrow_keys_step_through_the_families_and_enter_picks() { + // #345 + figcraft_text::scan_system_fonts(); + let all = figcraft_text::family_names(); + if all.len() < 3 { + eprintln!("fewer than three font families; skipping"); + return; + } + let ctx = egui::Context::default(); + let current = all[1].as_str(); + assert!(pick(&ctx, current, true, vec![]).highlighted.is_none()); + // The first step starts from the current family; each press is one step. + let down = pick(&ctx, current, false, vec![key(egui::Key::ArrowDown)]); + assert_eq!(down.highlighted.as_deref(), Some(all[2].as_str())); + let up = pick(&ctx, current, false, vec![key(egui::Key::ArrowUp), key(egui::Key::ArrowUp)]); + assert_eq!(up.highlighted.as_deref(), Some(all[0].as_str())); + // At the top it stays; Enter picks the family it is on, not the first. + assert_eq!(pick(&ctx, current, false, vec![key(egui::Key::ArrowUp)]).highlighted.as_deref(), Some(all[0].as_str())); + pick(&ctx, current, false, vec![key(egui::Key::ArrowDown)]); + let enter = pick(&ctx, current, false, vec![key(egui::Key::Enter)]); + assert_eq!(enter.picked.as_deref(), Some(all[1].as_str())); + } + #[test] fn axis_values_round_by_axis_width() { let axis = |min: f64, max: f64| figcraft_text::Axis { tag: "test".into(), name: "Test".into(), min, default: min, max, hidden: false }; From 3c03f370f9b482ed08fd5328a909b8c6a7c5e34c Mon Sep 17 00:00:00 2001 From: Rob Coenen <753704+rcoenen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:07:05 -0400 Subject: [PATCH 070/120] M1: Cmd+R rename test sends Ctrl+R only where Ctrl is the command key On macOS egui never sends `ctrl` together with `command`; Ctrl comes alone there and Ctrl+R isn't a shortcut (rename is Cmd+R or F2), so case 1 failed on every macOS run. The keymap is right; the test now presses Ctrl+R only off macOS. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/layers_rename_tests.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/layers_rename_tests.rs b/crates/ui-egui/src/layers_rename_tests.rs index 652522d59..4f5c6af80 100644 --- a/crates/ui-egui/src/layers_rename_tests.rs +++ b/crates/ui-egui/src/layers_rename_tests.rs @@ -100,9 +100,12 @@ fn tab_on_the_last_row_commits_and_ends() { #[test] fn cmd_r_renames_the_selection_and_opens_its_parents() { - // Cmd+R on macOS; Ctrl+R elsewhere, where egui sets both `ctrl` and `command`. + // Cmd+R on macOS; Ctrl+R elsewhere, where egui sets both `ctrl` and `command`. On macOS Ctrl + // comes without `command` and Ctrl+R isn't a shortcut. + let cmd = Modifiers::MAC_CMD | Modifiers::COMMAND; let ctrl = Modifiers { ctrl: true, command: true, ..Default::default() }; - for (k, held) in [Modifiers::MAC_CMD | Modifiers::COMMAND, ctrl].into_iter().enumerate() { + let keys: &[Modifiers] = if cfg!(target_os = "macos") { &[cmd] } else { &[cmd, ctrl] }; + for (k, &held) in keys.iter().enumerate() { let (mut u, _, frame, child, _) = setup(); u.app.session.execute("select.set", &json!({"ids": [child]})).unwrap(); u.frame(vec![]); From c02e864873e5a0783947b7512a83f92e30bf1d61 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:22:29 +0000 Subject: [PATCH 071/120] Replace every occurrence in edit.findReplace when replacement contains find Found while testing crates/engine/src/cmd/edit.rs:975-981 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/edit.rs | 15 +++++++++++---- crates/engine/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 57ba9fb00..696f8dfe3 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -970,13 +970,20 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { } if let NodeKind::Text(t) = &mut n.kind { // Replace occurrence by occurrence so styles shift correctly. + // The search resumes after each inserted replacement, so a replacement that + // contains `find` neither loops forever nor stops the other occurrences. let mut guard = 0; - while let Some(pos) = t.characters.find(find.as_str()) { - let start = t.characters.get(..pos).map(|x| x.chars().count()).unwrap_or(0); - let len = find.chars().count(); + let mut cursor = 0; + let len = find.chars().count(); + let r_len = r.chars().count(); + loop { + let from = t.characters.char_indices().nth(cursor).map_or(t.characters.len(), |(b, _)| b); + let Some(pos) = t.characters.get(from..).and_then(|tail| tail.find(find.as_str())) else { break }; + let start = cursor + t.characters.get(from..from + pos).map_or(0, |x| x.chars().count()); t.replace_range(start, start + len, r); + cursor = start + r_len; guard += 1; - if guard > 10_000 || r.contains(find.as_str()) { + if guard > 10_000 { break; } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 40da2f1df..04e97a731 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -161,6 +161,28 @@ fn text_create_autosize_and_edit() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "Hello"); } +#[test] +fn find_replace_replaces_every_occurrence_when_replacement_contains_find() { + for (text, find, replace, want) in [ + ("cat and cat", "cat", "cats", "cats and cats"), + ("cat and cat", "cat", "category", "category and category"), + ("banana", "a", "aa", "baanaanaa"), + ] { + let mut s = sess(); + let t = id(&s.execute("text.create", &json!({"text": text, "x": 0, "y": 0})).unwrap()); + s.execute("edit.findReplace", &json!({"find": find, "replace": replace})).unwrap(); + assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, want, "{text}: {find} -> {replace}"); + } +} + +#[test] +fn find_replace_rejects_empty_find() { + let mut s = sess(); + let t = id(&s.execute("text.create", &json!({"text": "cat", "x": 0, "y": 0})).unwrap()); + assert!(s.execute("edit.findReplace", &json!({"find": "", "replace": "x"})).is_err()); + assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "cat"); +} + #[test] fn components_and_instances_sync() { let mut s = sess(); From 2aa3ca3c45f1fc0805cf58213c6515d45dd3d5cb Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:26:37 +0000 Subject: [PATCH 072/120] Make edit.findReplace replace case-insensitive matches Found while testing crates/engine/src/cmd/edit.rs:962-977 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/edit.rs | 36 ++++++++++++++++++++++++++--------- crates/engine/src/tests.rs | 14 ++++++++++++++ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 57ba9fb00..9eac6fcae 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -944,6 +944,16 @@ fn batch_rename(s: &mut Session, p: &Value) -> Result { Ok(json!({"names": names.iter().map(|(i, n)| json!({"id": i, "name": n})).collect::>()})) } +/// Char index of the first case-insensitive occurrence of `needle` in `hay` at or after `from`. +/// Works on char indices (what `replace_range` takes), so lowercasing never shifts offsets. +fn find_ci(hay: &[char], needle: &[char], from: usize) -> Option { + if needle.is_empty() { + return None; + } + (from..hay.len()) + .find(|&i| hay.get(i..i + needle.len()).is_some_and(|w| w.iter().zip(needle).all(|(a, b)| a == b || a.to_lowercase().eq(b.to_lowercase())))) +} + fn find_replace(s: &mut Session, p: &Value) -> Result { let find = req_string("edit.findReplace", p, "find")?; if find.is_empty() { @@ -954,11 +964,11 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { let page = s.page()?; let doc = s.doc_mut()?; let mut hits = Vec::new(); - let lower = find.to_lowercase(); + let needle: Vec = find.chars().collect(); for i in doc.subtree(page) { let Some(n) = doc.node(i) else { continue }; - let text_hit = matches!(&n.kind, NodeKind::Text(t) if t.characters.to_lowercase().contains(&lower)); - let name_hit = names && n.name.to_lowercase().contains(&lower); + let text_hit = matches!(&n.kind, NodeKind::Text(t) if find_ci(&t.characters.chars().collect::>(), &needle, 0).is_some()); + let name_hit = names && find_ci(&n.name.chars().collect::>(), &needle, 0).is_some(); if !(text_hit || name_hit) || n.is_page() { continue; } @@ -966,17 +976,25 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { if let Some(r) = &replace { let n = doc.get_mut(i)?; if names && name_hit { - n.name = n.name.replace(find.as_str(), r); + let chars: Vec = n.name.chars().collect(); + let mut out = String::new(); + let mut at = 0; + while let Some(pos) = find_ci(&chars, &needle, at) { + out.extend(chars.get(at..pos).unwrap_or_default()); + out.push_str(r); + at = pos + needle.len(); + } + out.extend(chars.get(at..).unwrap_or_default()); + n.name = out; } if let NodeKind::Text(t) = &mut n.kind { // Replace occurrence by occurrence so styles shift correctly. + let r_chars: Vec = r.chars().collect(); let mut guard = 0; - while let Some(pos) = t.characters.find(find.as_str()) { - let start = t.characters.get(..pos).map(|x| x.chars().count()).unwrap_or(0); - let len = find.chars().count(); - t.replace_range(start, start + len, r); + while let Some(start) = find_ci(&t.characters.chars().collect::>(), &needle, 0) { + t.replace_range(start, start + needle.len(), r); guard += 1; - if guard > 10_000 || r.contains(find.as_str()) { + if guard > 10_000 || find_ci(&r_chars, &needle, 0).is_some() { break; } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 40da2f1df..ff1ad7238 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -532,3 +532,17 @@ fn guides_add_move_remove() { s.execute("guide.clear", &json!({})).unwrap(); assert!(guides(&s).is_empty()); } + +#[test] +fn find_replace_matches_and_replaces_case_insensitively() { + let mut s = sess(); + let t = id(&s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0})).unwrap()); + let u = id(&s.execute("text.create", &json!({"text": "İ HELLO hello", "x": 0, "y": 40})).unwrap()); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 80, "width": 50, "height": 50, "name": "HELLO box"})).unwrap()); + let chars = |s: &Session, i| s.doc().unwrap().node(i).unwrap().kind.text().unwrap().characters.clone(); + let r = s.execute("edit.findReplace", &json!({"find": "hello", "replace": "bye", "layerNames": true})).unwrap(); + assert_eq!(r["matches"], json!([t, u, f])); + assert_eq!(chars(&s, t), "bye"); + assert_eq!(chars(&s, u), "İ bye bye"); + assert_eq!(s.doc().unwrap().node(f).unwrap().name, "bye box"); +} From b87d2e476f13c08abad1cae57447c477c07f2bf5 Mon Sep 17 00:00:00 2001 From: vxMacDev <77440838+vxMacDev@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:29:09 -0500 Subject: [PATCH 073/120] fix(engine): drag with the Text tool to make a fixed-width text box (#360) The Text tool made its text on the press and ignored the drag, so a drag gave auto-width text like a click. The new text now stays in a TextBox gesture while pressed: past the drag slop it becomes a box as wide as the drag, its top-left at the drag's, with a fixed width and a height that follows its text. A click, or a drag within the slop, still makes auto-width text. --- crates/engine/src/text_behaviour_tests.rs | 35 ++++++++++++++++++++++ crates/engine/src/tools.rs | 36 ++++++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index 51550f7e4..48d7582f0 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -192,3 +192,38 @@ fn paragraph_indent_only_moves_left_aligned_text() { let (x, w2) = first_x(&s); assert!((w2 - (w - 30.0)).abs() < 1e-6 && x.abs() < 1e-6, "centered auto-width text: no indent, the box shrinks by it"); } + +/// A new text made with the Text tool by a press at `from`, a drag to `to` and a release, holding +/// `chars`: its box and resize mode. +fn text_tool(from: (f64, f64), to: (f64, f64), chars: &str) -> (figcraft_geom::Rect, figcraft_doc::TextAutoResize) { + use crate::tools::{PointerEvent, PointerKind, ToolId}; + let mut s = Session::new(); + s.set_tool(ToolId::Text); + s.pointer(&PointerEvent::at(PointerKind::Down, from.0, from.1, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, to.0, to.1, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, to.0, to.1, 1.0)).unwrap(); + crate::textedit::insert(&mut s, chars).unwrap(); + let t = s.ds().unwrap().text_edit.as_ref().unwrap().id; + let doc = s.doc().unwrap(); + let NodeKind::Text(td) = &doc.node(t).unwrap().kind else { panic!("not a text") }; + (doc.world_bounds(t).unwrap(), td.auto_resize) +} + +#[test] +fn a_drag_with_the_text_tool_makes_a_fixed_width_box() { + // #360: as wide as the drag, its top-left where the drag started, and its text wraps in it; a + // click, or a drag within a few pixels, still makes auto-width text. + use figcraft_doc::TextAutoResize; + figcraft_text::scan_system_fonts(); + let long = "A sentence much longer than eighty pixels"; + let (line, auto) = text_tool((100.0, 100.0), (100.0, 100.0), long); + assert_eq!(auto, TextAutoResize::WidthAndHeight); + assert_eq!(text_tool((100.0, 100.0), (101.0, 101.0), long).1, TextAutoResize::WidthAndHeight, "a slip of the hand is a click"); + let (b, auto) = text_tool((100.0, 100.0), (180.0, 160.0), long); + assert_eq!(auto, TextAutoResize::Height); + assert_eq!((b.x0, b.y0, b.width()), (100.0, 100.0, 80.0)); + assert!(b.height() > line.height(), "the text wraps: {} vs one line {}", b.height(), line.height()); + // Dragged up and to the left, the box is the same. + let (b2, _) = text_tool((180.0, 160.0), (100.0, 100.0), long); + assert_eq!((b2.x0, b2.y0, b2.width()), (100.0, 100.0, 80.0)); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index cfb9176d0..958805875 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -370,6 +370,11 @@ pub(crate) enum Gesture { TextSelect { id: NodeId, }, + /// A new text from the Text tool, still pressed: a drag makes it a box as wide as the drag. + TextBox { + id: NodeId, + start: Point, + }, PenDrag { anchor: Point, }, @@ -1347,6 +1352,11 @@ impl Session { self.tool.gesture = g; return self.draw_drag(ev, start, id, parent, &targets); } + Gesture::TextBox { id, start } => { + let (id, start) = (*id, *start); + self.tool.gesture = g; + return self.text_box_drag(ev, id, start); + } Gesture::Arc { id, handle, arc } => { let (id, handle, arc) = (*id, *handle, *arc); self.tool.gesture = g; @@ -1760,7 +1770,7 @@ impl Session { self.tool.overlay.pen.clear(); self.pencil_finish(&points, &pressure)?; } - Gesture::TextSelect { .. } | Gesture::Marquee { .. } | Gesture::None => {} + Gesture::TextSelect { .. } | Gesture::TextBox { .. } | Gesture::Marquee { .. } | Gesture::None => {} Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } | Gesture::Origin => {} Gesture::Vector(_) => self.commit_interaction()?, } @@ -1888,12 +1898,36 @@ impl Session { d.insert(n, parent, None)?; ds.selection = vec![id]; textedit::begin(self, id, Some(0), true)?; + self.tool.gesture = Gesture::TextBox { id, start: ev.p() }; if !self.prefs.keep_tool { self.tool.tool = ToolId::Move; } Ok(()) } + /// Drag out a new text (Figma's text box): past the drag slop it is as wide as the drag, its + /// top-left at the drag's, and its height follows its text. + fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point) -> Result<()> { + if within_slop(ev, start) { + return Ok(()); + } + let r = Rect::from_points(start, ev.p()); + let d = self.ds_mut()?.doc_mut(); + let pw = d.node(id).and_then(|n| n.parent).map(|p| d.world_transform(p)).unwrap_or_default(); + let n = d.get_mut(id)?; + n.transform = pw.inverse() * Affine::translate((r.x0.round(), r.y0.round())); + n.width = r.width().round().max(1.0); + n.child_layout.sizing_h = Sizing::Fixed; + if let NodeKind::Text(t) = &mut n.kind { + t.auto_resize = TextAutoResize::Height; + } + if let Some((_, h)) = figcraft_text::auto_size(n) { + n.height = h; + } + self.ds_mut()?.revision += 1; + Ok(()) + } + fn eyedrop(&mut self, ev: &PointerEvent) -> Result<()> { let ds = self.ds()?; let doc = ds.doc.clone(); From 01a0f4f332092c4fdb16961c68e4f92295600a5b Mon Sep 17 00:00:00 2001 From: Mateus Villain Date: Wed, 14 Oct 2026 20:01:53 -0300 Subject: [PATCH 074/120] M3: variables export as a download in the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web build has no save dialogs, so Export… ▸ Variables JSON / CSS custom properties returned silently. Services gains downloads() and download(name, bytes); the browser hands the file over through a Blob link, and the variables panel uses it where the platform saves by downloads. --- Cargo.lock | 1 + apps/figcraft-web/Cargo.toml | 3 +- apps/figcraft-web/src/web.rs | 36 ++++++- crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/src/lib.rs | 11 +++ .../ui-egui/src/variables_export_ui_tests.rs | 93 +++++++++++++++++++ crates/ui-egui/src/variables_panel.rs | 15 ++- docs/components-parity.md | 5 +- 12 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/variables_export_ui_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 5e9c6f684..02c221f08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1718,6 +1718,7 @@ dependencies = [ "figcraft-collab-net", "figcraft-engine", "figcraft-ui-egui", + "js-sys", "log", "wasm-bindgen", "wasm-bindgen-futures", diff --git a/apps/figcraft-web/Cargo.toml b/apps/figcraft-web/Cargo.toml index 93f9eb41c..2f1a8190f 100644 --- a/apps/figcraft-web/Cargo.toml +++ b/apps/figcraft-web/Cargo.toml @@ -24,5 +24,6 @@ log = { workspace = true } # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" +js-sys = "0.3" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] } diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 636890385..bb563422a 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -69,14 +69,27 @@ impl eframe::App for WebShell { } } -/// Browser services. File dialogs return nothing for now (the trait's defaults); the clipboard -/// goes through egui's own web clipboard handling. +/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved +/// as downloads; the clipboard goes through egui's own web clipboard handling. struct WebServices { /// `prefers-reduced-motion: reduce` (it follows the setting while the page is open). reduce_motion: Option, } impl Services for WebServices { + fn downloads(&self) -> bool { + true + } + /// A Blob behind a temporary `` link, clicked and removed. + fn download(&mut self, name: &str, bytes: &[u8]) -> bool { + match download(name, bytes) { + Ok(()) => true, + Err(e) => { + log::error!("couldn't download {name}: {e:?}"); + false + } + } + } fn open_url(&mut self, url: &str) { let Some(window) = web_sys::window() else { return }; if let Err(e) = window.open_with_url_and_target(url, "_blank") { @@ -108,3 +121,22 @@ impl Services for WebServices { held } } + +fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> { + let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; + let body = document.body().ok_or("no body")?; + let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &{ + let o = web_sys::BlobPropertyBag::new(); + o.set_type("application/octet-stream"); + o + })?; + let url = web_sys::Url::create_object_url_with_blob(&blob)?; + let a: web_sys::HtmlAnchorElement = document.create_element("a")?.dyn_into()?; + a.set_href(&url); + a.set_download(name); + body.append_child(&a)?; + a.click(); + a.remove(); + web_sys::Url::revoke_object_url(&url) +} diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 7552cca36..4f38f7cba 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -300,6 +300,7 @@ status = "draft — needs native review" "Copy {value}" = "{value} kopieren" "Corner radius" = "Eckenradius" "Corner smoothing" = "Eckenglättung" +"Couldn't download the variables" = "Die Variablen konnten nicht heruntergeladen werden" "Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat." "Count" = "Anzahl" "Create" = "Erstellen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index c35b16776..61fd1528f 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -309,6 +309,7 @@ name = "English" "Copy {value}" = "Copy {value}" "Corner radius" = "Corner radius" "Corner smoothing" = "Corner smoothing" +"Couldn't download the variables" = "Couldn't download the variables" "Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers." "Count" = "Count" "Create" = "Create" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 6514ac7be..a725bca5e 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,6 +8,7 @@ name = "Español" status = "draft — needs native review" [ui] +"Couldn't download the variables" = "No se pudieron descargar las variables" "% of all commits" = "% del total de commits" "(no longer published)" = "(ya no publicado)" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index c86659345..d5110c1be 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -300,6 +300,7 @@ status = "draft — needs native review" "Copy {value}" = "Copier {value}" "Corner radius" = "Rayon d’angle" "Corner smoothing" = "Lissage des angles" +"Couldn't download the variables" = "Impossible de télécharger les variables" "Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient." "Count" = "Nombre" "Create" = "Créer" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index a692a8cee..4f6760e76 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -299,6 +299,7 @@ status = "draft — needs native review" "Copy {value}" = "Copiar {value}" "Corner radius" = "Raio do canto" "Corner smoothing" = "Suavização de cantos" +"Couldn't download the variables" = "Não foi possível baixar as variáveis" "Couldn't reach {server}: showing what this computer remembers." = "Não foi possível alcançar {server}: mostrando o que este computador lembra." "Count" = "Quantidade" "Create" = "Criar" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f98..b68279deb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -148,6 +148,8 @@ mod text_enter_tests; #[cfg(test)] mod titlebar_tests; #[cfg(test)] +mod variables_export_ui_tests; +#[cfg(test)] mod vector_tail_ui_tests; #[cfg(test)] mod windows_qa_tests; @@ -171,6 +173,15 @@ pub trait Services { fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> Option { None } + /// The platform saves files by handing them over as downloads (the browser), not through + /// save dialogs. + fn downloads(&self) -> bool { + false + } + /// Hand `bytes` over as a download named `name`; false when nothing was delivered. + fn download(&mut self, _name: &str, _bytes: &[u8]) -> bool { + false + } /// A folder to write several files into. fn pick_folder(&mut self, _title: &str) -> Option { None diff --git a/crates/ui-egui/src/variables_export_ui_tests.rs b/crates/ui-egui/src/variables_export_ui_tests.rs new file mode 100644 index 000000000..40a843931 --- /dev/null +++ b/crates/ui-egui/src/variables_export_ui_tests.rs @@ -0,0 +1,93 @@ +//! Variables ▸ Export… where the platform saves by downloads (the browser, which has no save +//! dialogs): the export arrives as a download instead of doing nothing. + +use std::sync::{Arc, Mutex}; + +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::variables_panel::export_dialog; +use crate::{FigApp, NoServices, Services}; + +type Downloads = Arc)>>>; + +/// Services that save by downloads and keep them (`deliver` false: the browser refused). +struct Browser { + got: Downloads, + deliver: bool, +} + +impl Services for Browser { + fn downloads(&self) -> bool { + true + } + fn download(&mut self, name: &str, bytes: &[u8]) -> bool { + self.got.lock().unwrap().push((name.to_string(), bytes.to_vec())); + self.deliver + } +} + +fn session() -> Session { + let mut s = Session::new(); + for (cmd, p) in [ + ("variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]})), + ( + "variable.create", + json!({"collection": "Theme", "name": "color/primary", "type": "color", "values": {"Light": "#0000ff", "Dark": "#ffffff"}}), + ), + ("variable.create", json!({"collection": "Theme", "name": "radius", "type": "number", "value": 4})), + ] { + s.execute(cmd, &p).unwrap(); + } + s +} + +fn browser(deliver: bool) -> (FigApp, Downloads) { + let got = Downloads::default(); + (FigApp::new(session(), Box::new(Browser { got: got.clone(), deliver })), got) +} + +fn toast(app: &FigApp) -> String { + app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default() +} + +#[test] +fn json_export_downloads_what_variable_export_writes() { + let (mut app, got) = browser(true); + let expected = app.session.execute("variable.export", &json!({"format": "json"})).unwrap()["text"].as_str().unwrap().to_string(); + export_dialog(&mut app, "json"); + let got = got.lock().unwrap(); + let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) }; + assert!(name.ends_with(".variables.json"), "{name}"); + assert_eq!(String::from_utf8(bytes.clone()).unwrap(), expected); + let parsed: Value = serde_json::from_slice(bytes).unwrap(); + assert!(parsed.is_object()); + assert!(toast(&app).contains(name.as_str()), "{}", toast(&app)); +} + +#[test] +fn css_export_downloads_custom_properties() { + let (mut app, got) = browser(true); + export_dialog(&mut app, "css"); + let got = got.lock().unwrap(); + let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) }; + assert!(name.ends_with(".variables.css"), "{name}"); + let css = String::from_utf8(bytes.clone()).unwrap(); + assert!(css.contains(":root") && css.contains("--"), "{css}"); +} + +#[test] +fn a_refused_download_says_so() { + let (mut app, got) = browser(false); + export_dialog(&mut app, "json"); + assert_eq!(got.lock().unwrap().len(), 1); + assert_eq!(toast(&app), "Couldn't download the variables"); +} + +#[test] +fn platforms_with_save_dialogs_keep_them() { + // No dialog answer (cancelled) and no downloads: nothing happens, as before. + let mut app = FigApp::new(session(), Box::new(NoServices)); + export_dialog(&mut app, "json"); + assert!(app.ui.toast.is_none()); +} diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index 240a51a57..7f0dc435f 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -202,9 +202,20 @@ fn header(app: &mut FigApp, ui: &mut Ui, open: &mut bool) { ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border)); } -/// Export this file's collections through a save dialog. -fn export_dialog(app: &mut FigApp, format: &str) { +/// Export this file's collections through a save dialog, or as a download where the platform has +/// no save dialogs (the browser). +pub(crate) fn export_dialog(app: &mut FigApp, format: &str) { let name = app.session.doc().map(|d| format!("{}.variables.{format}", d.name)).unwrap_or_else(|_| format!("variables.{format}")); + if app.services.downloads() { + let Some(r) = app.run("variable.export", json!({"format": format})) else { return }; + let text = r["text"].as_str().unwrap_or_default(); + if app.services.download(&name, text.as_bytes()) { + app.toast(trf("Exported {file}", &[("file", &name)])); + } else { + app.toast(tr("Couldn't download the variables")); + } + return; + } let Some(p) = app.services.pick_save(tr("Export variables"), &name, &[format]) else { return }; if app.run("variable.export", json!({"format": format, "path": p.to_string_lossy()})).is_some() { let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); diff --git a/docs/components-parity.md b/docs/components-parity.md index 46a012fe3..659676fd4 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (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-14 · **Change:** minor (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 | +| 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) | | Expressions in prototype set-variable actions | done | see [prototyping-parity](prototyping-parity.md) | ## Revision history | Date | Change | Summary | |---|---|---| +| 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. | | 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. | | 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. | | 2026-10-12 | minor | Layers and Assets panels behaviour suite: Assets folders by page, frame and slash names; a leading `.` or `_` keeps a component out of the library even after it was published, and removing it brings the component back | From 3edb15c4174fdc2709193519654b015149cdb67a Mon Sep 17 00:00:00 2001 From: Mateus Villain Date: Wed, 14 Oct 2026 20:26:04 -0300 Subject: [PATCH 075/120] M1: copy and paste through the system clipboard in the browser The web build's Services never wrote to the clipboard, so Cmd+V after Cmd+C got the paste event with whatever text the clipboard held before and made a text layer of it instead of pasting the copied layers. WebServices now writes copies (text and PNG) through egui's output, which eframe hands to the browser clipboard during the copy event. --- apps/figcraft-web/src/web.rs | 14 ++- crates/ui-egui/src/lib.rs | 19 +++ crates/ui-egui/src/web_clipboard_tests.rs | 141 ++++++++++++++++++++++ docs/hardware-parity.md | 5 +- 4 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/web_clipboard_tests.rs diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 636890385..5bb209053 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -40,7 +40,7 @@ pub fn start() { // on the socket wakes the page for a frame. let ctx = cc.egui_ctx.clone(); session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint())))); - let fig = FigApp::new(session, Box::new(WebServices { reduce_motion })); + let fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() })); Ok(Box::new(WebShell { fig })) }), ) @@ -69,14 +69,22 @@ impl eframe::App for WebShell { } } -/// Browser services. File dialogs return nothing for now (the trait's defaults); the clipboard -/// goes through egui's own web clipboard handling. +/// Browser services. File dialogs return nothing for now (the trait's defaults). Copies go to the +/// system clipboard through egui's output; pastes arrive as paste events with their text (a page +/// can't read the clipboard when it likes). struct WebServices { /// `prefers-reduced-motion: reduce` (it follows the setting while the page is open). reduce_motion: Option, + ctx: egui::Context, } impl Services for WebServices { + fn clipboard_set_text(&mut self, s: &str) { + figcraft_ui_egui::egui_copy_text(&self.ctx, s); + } + fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) { + figcraft_ui_egui::egui_copy_png(&self.ctx, w, h, rgba); + } fn open_url(&mut self, url: &str) { let Some(window) = web_sys::window() else { return }; if let Err(e) = window.open_with_url_and_target(url, "_blank") { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f98..3fe8beb83 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -150,6 +150,8 @@ mod titlebar_tests; #[cfg(test)] mod vector_tail_ui_tests; #[cfg(test)] +mod web_clipboard_tests; +#[cfg(test)] mod windows_qa_tests; #[cfg(test)] mod windows_tests; @@ -213,6 +215,23 @@ pub trait Services { pub struct NoServices; impl Services for NoServices {} +/// Put `text` on the system clipboard through egui's output, which the browser shell writes while +/// it handles the copy event (the only time a page may write it). For shells without a clipboard +/// of their own. +pub fn egui_copy_text(ctx: &egui::Context, text: &str) { + ctx.copy_text(text.to_string()); +} + +/// Put a straight-RGBA image on the system clipboard through egui's output (see +/// [`egui_copy_text`]); a buffer that doesn't match its size is dropped. +pub fn egui_copy_png(ctx: &egui::Context, w: u32, h: u32, rgba: &[u8]) { + let (w, h) = (w as usize, h as usize); + if w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(rgba.len()) { + return; + } + ctx.copy_image(egui::ColorImage::from_rgba_unmultiplied([w, h], rgba)); +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] pub enum RightTab { Design, diff --git a/crates/ui-egui/src/web_clipboard_tests.rs b/crates/ui-egui/src/web_clipboard_tests.rs new file mode 100644 index 000000000..a77871204 --- /dev/null +++ b/crates/ui-egui/src/web_clipboard_tests.rs @@ -0,0 +1,141 @@ +//! Copy and paste as the browser does them: Cmd+C arrives as a copy event and the copy reaches the +//! system clipboard only through egui's output; Cmd+V arrives as the key and a paste event holding +//! whatever the system clipboard has. A copy that never reached the clipboard pasted the text that +//! was there before as a text layer instead of the copied layers. + +use egui::{Event, Key, Modifiers, OutputCommand, Pos2, RawInput, Rect, vec2}; +use figcraft_doc::NodeKind; +use figcraft_engine::Session; +use serde_json::json; + +use crate::{FigApp, Services, egui_copy_png, egui_copy_text}; + +/// The browser shell's clipboard: writes go out as egui output commands. +struct Browser(egui::Context); + +impl Services for Browser { + fn clipboard_set_text(&mut self, s: &str) { + egui_copy_text(&self.0, s); + } + fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) { + egui_copy_png(&self.0, w, h, rgba); + } +} + +struct Page { + ctx: egui::Context, + app: FigApp, + time: f64, + /// The system clipboard's text. + clipboard: String, +} + +impl Page { + fn new() -> Self { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + s.execute("select.set", &json!({"ids": [r["id"]]})).unwrap(); + let ctx = egui::Context::default(); + let app = FigApp::new(s, Box::new(Browser(ctx.clone()))); + let mut p = Self { ctx, app, time: 0.0, clipboard: "text copied in another app".into() }; + for _ in 0..3 { + p.frame(vec![]); + } + p + } + + /// One frame; copies in its output land on the clipboard, as eframe writes them. + fn frame(&mut self, events: Vec) -> Vec { + self.frame_then(events, |_| {}) + } + + /// One frame that runs `then` after the app's own UI (a menu item clicked during the frame). + fn frame_then(&mut self, events: Vec, then: impl FnOnce(&mut FigApp)) -> Vec { + self.time += 1.0 / 60.0; + let input = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; + let app = &mut self.app; + let mut then = Some(then); + let mut out = self.ctx.run_ui(input, |ui| { + app.ui(ui); + if let Some(f) = then.take() { + f(app); + } + }); + out.textures_delta.clear(); + for c in &out.platform_output.commands { + if let OutputCommand::CopyText(t) = c { + self.clipboard = t.clone(); + } + } + out.platform_output.commands + } + + fn key(key: Key, pressed: bool) -> Event { + Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers: Modifiers::MAC_CMD | Modifiers::COMMAND } + } + + fn copy(&mut self) { + self.frame(vec![Self::key(Key::C, true), Event::Copy]); + self.frame(vec![Self::key(Key::C, false)]); + } + + fn paste(&mut self) { + let text = self.clipboard.clone(); + self.frame(vec![Self::key(Key::V, true), Event::Paste(text)]); + self.frame(vec![Self::key(Key::V, false)]); + } + + fn count(&self, kind: fn(&NodeKind) -> bool) -> usize { + self.app.session.doc().unwrap().nodes().filter(|n| kind(&n.kind)).count() + } +} + +fn rect(k: &NodeKind) -> bool { + matches!(k, NodeKind::Rectangle) +} + +fn text(k: &NodeKind) -> bool { + matches!(k, NodeKind::Text(_)) +} + +#[test] +fn cmd_c_then_cmd_v_pastes_the_copied_layers() { + let mut p = Page::new(); + p.copy(); + assert_ne!(p.clipboard, "text copied in another app", "the copy reached the system clipboard"); + p.paste(); + assert_eq!(p.count(rect), 2, "the rectangle was pasted"); + assert_eq!(p.count(text), 0, "no text layer from the clipboard's old text"); + p.paste(); + assert_eq!(p.count(rect), 3, "and again"); +} + +#[test] +fn text_copied_elsewhere_after_our_copy_still_pastes_as_text() { + let mut p = Page::new(); + p.copy(); + p.clipboard = "hello from another app".into(); + p.paste(); + assert_eq!(p.count(rect), 1); + assert_eq!(p.count(text), 1, "newer text from elsewhere becomes a text layer"); +} + +#[test] +fn copy_as_png_puts_an_image_on_the_clipboard() { + let mut p = Page::new(); + let out = p.frame_then(vec![], |app| crate::menus::invoke(app, "edit.copyAsPng")); + let img = out.iter().find_map(|c| if let OutputCommand::CopyImage(i) = c { Some(i.clone()) } else { None }); + let img = img.expect("an image was copied"); + assert!(img.size[0] > 0 && img.size[1] > 0); +} + +#[test] +fn a_mismatched_image_buffer_is_dropped() { + let ctx = egui::Context::default(); + for (w, h, rgba) in [(4, 4, &[0u8; 10][..]), (u32::MAX, u32::MAX, &[][..])] { + let mut out = ctx.run_ui(RawInput::default(), |ui| egui_copy_png(ui.ctx(), w, h, rgba)); + out.textures_delta.clear(); + assert!(out.platform_output.commands.is_empty(), "{w}x{h}"); + } +} diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 8995db252..a990d75b6 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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-14 · **Change:** minor (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 @@ -43,13 +43,14 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | 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 | | FreeBSD | none | tarball | | -| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser | +| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps) | | iOS / Android | apps: view, comment, mirror prototypes | none (the web build is not touch-first) | 20–40 h for a viewer | ## Revision history | Date | Change | Summary | |---|---|---| +| 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 | | 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | | 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | From 12d4dbfbc963492829be671b3ce33b5c9888ea48 Mon Sep 17 00:00:00 2001 From: vxMacDev <77440838+vxMacDev@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:35:21 -0500 Subject: [PATCH 076/120] fix(engine): new text is readable on the frame it is drawn in (#362) The colour of new text in the app was chosen from the page alone, so on a dark page text drawn in a white frame (a new frame's default) was white and invisible. readable_text_fills now looks at what the text is drawn on: the nearest layer from its parent out whose top visible fill is a mostly opaque solid colour, else the page. The Text tool and text.create / node.create pass the text's parent. The two tests that asserted the page-only rule in a frame now expect black text in a white frame; new cases cover dark, unfilled, nested, faint and stacked fills, and the Text tool. --- crates/engine/src/cmd/create.rs | 2 +- crates/engine/src/dark_page_text_tests.rs | 55 +++++++++++++++++++---- crates/engine/src/theme_pages_tests.rs | 5 ++- crates/engine/src/tools.rs | 18 +++++--- 4 files changed, 62 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 63442b3ca..61b0f8b4c 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -242,7 +242,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result { let doc = ds.doc_mut(); let nid = doc.alloc_id(); let mut node = if base == "Section" { Node::new_section(nid, name, w, h) } else { Node::new(nid, kind, name, w, h) }; - if app_text && let Some(fills) = crate::tools::readable_text_fills(doc, doc.page_of(parent).unwrap_or(page)) { + if app_text && let Some(fills) = crate::tools::readable_text_fills(doc, parent) { node.fills = fills; } node.transform = space.inverse() * figcraft_geom::Affine::translate((x + offset.0, y + offset.1)); diff --git a/crates/engine/src/dark_page_text_tests.rs b/crates/engine/src/dark_page_text_tests.rs index c4348430f..b46bd0c71 100644 --- a/crates/engine/src/dark_page_text_tests.rs +++ b/crates/engine/src/dark_page_text_tests.rs @@ -1,6 +1,6 @@ -//! Text made by commands in the app (the interface reported its theme) is readable on its page -//! like text drawn with the Text tool: white on a dark page unless a fill is named. A session -//! without an interface (CLI, MCP, exports) keeps the model's black everywhere. +//! Text made by commands in the app (the interface reported its theme) is readable where it is +//! drawn, like text drawn with the Text tool: white on a dark page or a dark frame unless a fill is +//! named. A session without an interface (CLI, MCP, exports) keeps the model's black everywhere. use figcraft_color::{Color, Paint}; use figcraft_doc::NodeId; @@ -62,12 +62,51 @@ fn text_made_by_commands_is_readable_on_its_page() { } #[test] -fn text_in_a_frame_follows_the_page_it_is_on() { - // As with the Text tool: the page decides, also inside a frame; made in one undo step. +fn text_in_a_frame_is_readable_on_the_frame() { + // #362: what the text is drawn on decides: the nearest layer around it with a solid fill (a + // new frame's white too), else its page; made in one undo step. + let (white, black) = (vec![Paint::solid(Color::WHITE)], vec![Paint::solid(Color::BLACK)]); let mut s = session(Some(true)); - let frame: NodeId = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100}))["id"]).unwrap(); - let t = ex(&mut s, "text.create", json!({"text": "Hi", "x": 10, "y": 10, "parent": frame})); - assert_eq!(fills(&s, &t), vec![Paint::solid(Color::WHITE)]); + let frame = |s: &mut Session, x: i64, parent: Option, fills: Option| -> NodeId { + let mut p = json!({"x": x, "y": 0, "width": 200, "height": 100}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + let f = id_of(&ex(s, "frame.create", p)["id"]).unwrap(); + if let Some(fills) = fills { + ex(s, "node.set", json!({"id": f, "props": {"fills": fills}})); + } + f + }; + let light = frame(&mut s, 0, None, None); + let dark = frame(&mut s, 300, None, Some(json!([{"type": "solid", "color": "#202020"}]))); + let bare = frame(&mut s, 600, None, Some(json!([]))); + let inner = frame(&mut s, 0, Some(light), Some(json!([]))); + let faint = frame(&mut s, 900, None, Some(json!([{"type": "solid", "color": "#FFFFFF", "opacity": 0.2}]))); + let stacked = frame(&mut s, 1200, None, Some(json!([{"type": "solid", "color": "#202020"}, {"type": "solid", "color": "#FFFFFF"}]))); + let cases = [ + (light, &black, "a white frame on a dark page: black text"), + (dark, &white, "a dark frame: white"), + (bare, &white, "a frame without a fill: the page decides"), + (inner, &black, "a frame without a fill in a white one: the white one"), + (faint, &white, "a faint fill lets the page show through: the page decides"), + (stacked, &black, "the fill on top counts"), + ]; + for (parent, want, why) in cases { + let t = ex(&mut s, "text.create", json!({"text": "Hi", "x": 10, "y": 10, "parent": parent})); + assert_eq!(&fills(&s, &t), want, "{why}"); + } + // The Text tool reads it the same way. + use crate::tools::{PointerEvent, PointerKind, ToolId}; + let at = s.doc().unwrap().world_bounds(light).unwrap().center(); + s.set_tool(ToolId::Text); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::at(kind, at.x, at.y, 1.0)).unwrap(); + } + let typed = s.ds().unwrap().text_edit.as_ref().unwrap().id; + assert_eq!(s.doc().unwrap().node(typed).unwrap().fills, black, "the Text tool in a white frame"); + crate::textedit::end(&mut s).unwrap(); + let t = ex(&mut s, "text.create", json!({"text": "Hi", "x": 10, "y": 10, "parent": light})); ex(&mut s, "edit.undo", json!({})); assert!(s.doc().unwrap().node(id_of(&t["id"]).unwrap()).is_none()); } diff --git a/crates/engine/src/theme_pages_tests.rs b/crates/engine/src/theme_pages_tests.rs index e4c1071ae..e80c8d32f 100644 --- a/crates/engine/src/theme_pages_tests.rs +++ b/crates/engine/src/theme_pages_tests.rs @@ -118,13 +118,14 @@ fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_black() { let t = draw_text(&mut s, 40.0, 40.0); assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(want)], "text drawn on {page}"); } - // In a frame on a dark page too; so is text a command makes in the app (`dark_page_text_tests`). + // In a (white) frame on a dark page, readable on the frame (#362); so is text a command makes + // in the app (`dark_page_text_tests`). let mut s = Session::empty(); s.set_ui_dark(true); ex(&mut s, "file.new", json!({})); ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 200})); let t = draw_text(&mut s, 50.0, 50.0); - assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(Color::WHITE)]); + assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(Color::BLACK)]); let made = id_of(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 300}))["id"]).unwrap(); assert_eq!(s.doc().unwrap().node(made).unwrap().fills, vec![Paint::solid(Color::WHITE)]); } diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index cfb9176d0..ec727c376 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -530,12 +530,16 @@ pub fn anchor_name(a: (u8, u8)) -> &'static str { ANCHORS.get(usize::from(a.1.min(2)) * 3 + usize::from(a.0.min(2))).copied().unwrap_or("tl") } -/// The fill new text takes to be readable where it is drawn: white on a dark `page` (None: the -/// model's default, black). For text made in the app: by the Text tool, or by a command that -/// names no fill while the interface is up. -pub(crate) fn readable_text_fills(doc: &Document, page: NodeId) -> Option> { - matches!(doc.node(page).map(|p| &p.kind), Some(NodeKind::Page { background, .. }) if background.is_dark()) - .then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)]) +/// The fill new text takes to be readable where it is drawn, in `parent`: white on a dark +/// background (None: the model's default, black). The background is the nearest layer from `parent` +/// out whose top visible fill is a mostly opaque solid colour, else the page (#362). For text made +/// in the app: by the Text tool, or by a command that names no fill while the interface is up. +pub(crate) fn readable_text_fills(doc: &Document, parent: NodeId) -> Option> { + let dark = std::iter::once(parent).chain(doc.ancestors(parent)).filter_map(|id| doc.node(id)).find_map(|n| match &n.kind { + NodeKind::Page { background, .. } => Some(background.is_dark()), + _ => n.fills.iter().rev().find(|p| p.visible).and_then(|p| p.solid_color()).filter(|c| c.a >= 0.5).map(|c| c.is_dark()), + }); + dark.unwrap_or(false).then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)]) } const ANCHORS: [&str; 9] = ["tl", "t", "tr", "l", "c", "r", "bl", "b", "br"]; @@ -1878,7 +1882,7 @@ impl Session { let at = Point::new(ev.x.round(), (ev.y - 7.0).round()); n.transform = pw.inverse() * Affine::translate(at.to_vec2()); n.name = String::new(); - if let Some(fills) = readable_text_fills(d, page) { + if let Some(fills) = readable_text_fills(d, parent) { n.fills = fills; } if let Some((w, h)) = figcraft_text::auto_size(&n) { From 87b9a3a9ce79b73b89226a2263beab80fd078d89 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:08:36 +0000 Subject: [PATCH 077/120] Reject negative or fractional index in effect.set, export.update and export.remove Found while testing crates/engine/src/cmd/edit.rs:256 (effect.set), :305 (export.update), :317 (export.remove) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 6 +++--- crates/engine/src/effects_tests.rs | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 57ba9fb00..cb194497c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -253,7 +253,7 @@ pub fn specs() -> Vec { ok() }), spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| { - let i = req_num("effect.set", p, "index")? as usize; + let i = req_index("effect.set", p, "index")?; let e: Effect = parse("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?; for id in req_targets("effect.set", s, p)? { let n = s.doc_mut()?.get_mut(id)?; @@ -302,7 +302,7 @@ pub fn specs() -> Vec { true, |s, p| { let cmd = "export.update"; - let i = req_num(cmd, p, "index")? as usize; + let i = req_index(cmd, p, "index")?; super::file::export_setting(cmd, ExportSetting::default(), p)?; for id in req_targets(cmd, s, p)? { let n = s.doc_mut()?.get_mut(id)?; @@ -314,7 +314,7 @@ pub fn specs() -> Vec { } ), spec!("export.remove", "Remove export setting", "{ids?, index}", true, |s, p| { - let i = req_num("export.remove", p, "index")? as usize; + let i = req_index("export.remove", p, "index")?; for id in req_targets("export.remove", s, p)? { let n = s.doc_mut()?.get_mut(id)?; if i < n.exports.len() { diff --git a/crates/engine/src/effects_tests.rs b/crates/engine/src/effects_tests.rs index 5142c40d5..be69fac20 100644 --- a/crates/engine/src/effects_tests.rs +++ b/crates/engine/src/effects_tests.rs @@ -85,3 +85,24 @@ fn inspect_and_css_show_the_new_effects() { let css = code_for(doc, r, CodeLang::Css); assert!(css.contains("backdrop-filter: blur(2px);"), "{css}"); } + +#[test] +fn negative_or_fractional_index_is_refused_not_item_zero() { + let mut s = Session::new(); + let r = rect(&mut s); + s.execute("effect.add", &json!({"ids": [r], "type": "layerBlur"})).unwrap(); + s.execute("export.add", &json!({"ids": [r]})).unwrap(); + s.execute("export.add", &json!({"ids": [r]})).unwrap(); + let before = s.doc().unwrap().node(r).unwrap().clone(); + assert_eq!(before.exports.len(), 2); + for index in [json!(-1), json!(-0.5), json!(1.5)] { + let set = json!({"ids": [r], "index": index, "effect": {"type": "dropShadow"}}); + assert!(s.execute("effect.set", &set).is_err(), "effect.set index {index}"); + let upd = json!({"ids": [r], "index": index, "format": "svg"}); + assert!(s.execute("export.update", &upd).is_err(), "export.update index {index}"); + assert!(s.execute("export.remove", &json!({"ids": [r], "index": index})).is_err(), "export.remove index {index}"); + let n = s.doc().unwrap().node(r).unwrap(); + assert_eq!(n.effects, before.effects, "index {index}"); + assert_eq!(n.exports, before.exports, "index {index}"); + } +} From 3c00ea64401897ea82ad1b5990a98b394ab0b782 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:41:17 +0200 Subject: [PATCH 078/120] Outline stroke on every selected layer, not just the first Fixes #378 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/structure.rs | 4 +-- crates/engine/src/shapes_behaviour_tests.rs | 28 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 13693d57f..bed70d3df 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -362,9 +362,9 @@ fn flatten(s: &mut Session, p: &Value) -> Result { fn outline_stroke(s: &mut Session, p: &Value) -> Result { let ids = req_targets("vector.outlineStroke", s, p)?; - let doc = s.doc_mut()?; let mut out = Vec::new(); for i in ids { + let doc = s.doc_mut()?; let n = doc.get(i)?.clone(); if !n.has_visible_stroke() { continue; @@ -409,7 +409,7 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result { if let Some(g) = id_of(&r["id"]) { out.push(g); } - return Ok(json!({"ids": out})); + continue; } // Standing in for the layer, the outline keeps how it is drawn and placed. (v.opacity, v.blend, v.effects, v.styles.effect) = (n.opacity, n.blend, n.effects.clone(), n.styles.effect); diff --git a/crates/engine/src/shapes_behaviour_tests.rs b/crates/engine/src/shapes_behaviour_tests.rs index e97cae044..154c5ae19 100644 --- a/crates/engine/src/shapes_behaviour_tests.rs +++ b/crates/engine/src/shapes_behaviour_tests.rs @@ -431,3 +431,31 @@ fn flatten_and_outline_stroke() { let (path, _) = figcraft_render::node_outline(s.doc().unwrap(), &n).unwrap(); assert!((path.bounding_box().height() - 10.0).abs() < 1e-6, "the outline is as thick as the stroke was"); } + +#[test] +fn outline_stroke_outlines_every_selected_layer() { + // Fixed (#378): with several filled, stroked shapes selected, only the first was outlined. + for by_selection in [false, true] { + let mut s = Session::new(); + let a = create(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})); + let b = create(&mut s, "shape.rectangle", json!({"x": 100, "y": 0, "width": 50, "height": 50})); + let line = create(&mut s, "shape.line", json!({"x": 0, "y": 100, "width": 100, "props": {"strokeWeight": 4}})); + ex(&mut s, "node.set", json!({"ids": [a, b], "props": {"strokes": ["#000000"], "strokeWeight": 4}})); + let out = if by_selection { + s.set_selection(vec![a, b, line]); + ex(&mut s, "vector.outlineStroke", json!({})) + } else { + ex(&mut s, "vector.outlineStroke", json!({"ids": [a, b, line]})) + }; + let ids: Vec = out["ids"].as_array().unwrap().iter().filter_map(id_of).collect(); + assert_eq!(ids.len(), 3, "one result per layer: {out}"); + assert!(matches!(node(&s, ids[0]).kind, NodeKind::Group), "filled shape a becomes a group"); + assert!(matches!(node(&s, ids[1]).kind, NodeKind::Group), "filled shape b becomes a group"); + assert!(matches!(node(&s, ids[2]).kind, NodeKind::Vector { .. }), "the line becomes a vector"); + for id in [a, b] { + assert!(node(&s, id).strokes.is_empty(), "{id:?} still has its stroke"); + } + assert!(s.doc().unwrap().node(line).is_none(), "the line was replaced by its outline"); + assert_eq!(s.selection(), ids, "the results are selected"); + } +} From fc6f52e852e4fbc78b517d6ea6cd129e57018461 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:50:00 +0200 Subject: [PATCH 079/120] Keep corner resize cursors diagonal on wide and tall layers Fixes #370 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tools.rs | 13 +++++++++- .../engine/src/transform_behaviour_tests.rs | 24 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index cfb9176d0..9034a47a4 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -685,7 +685,18 @@ pub(crate) fn handle_cursor(c: &[Point; 4], h: Handle) -> Cursor { Handle::Corner(i) => { let a = c.get(i as usize).copied().unwrap_or_default(); let b = c.get((i as usize + 2) % 4).copied().unwrap_or_default(); - Cursor::Resize(angle(a, b)) + // Along the box's own axes at 45°, not the diagonal, so wide or tall boxes still get a + // diagonal cursor. A box with no width or height falls back to the diagonal. + let unit = |v: figcraft_geom::kurbo::Vec2| { + let len = v.hypot(); + (len.is_finite() && len > 1e-9).then(|| v / len) + }; + let next = c.get((i as usize + 1) % 4).copied().unwrap_or_default(); + let prev = c.get((i as usize + 3) % 4).copied().unwrap_or_default(); + match (unit(next - a), unit(prev - a)) { + (Some(u), Some(v)) => Cursor::Resize((u + v).atan2().to_degrees()), + _ => Cursor::Resize(angle(a, b)), + } } Handle::Edge(i) => { let a = c.get(i as usize).copied().unwrap_or_default(); diff --git a/crates/engine/src/transform_behaviour_tests.rs b/crates/engine/src/transform_behaviour_tests.rs index 3fbecb017..69125deb6 100644 --- a/crates/engine/src/transform_behaviour_tests.rs +++ b/crates/engine/src/transform_behaviour_tests.rs @@ -378,6 +378,30 @@ fn smart_guides_snap_to_edges_centres_and_equal_spacing() { assert_eq!(bounds(&s, b).origin(), Point::new(198.0, 2.0)); } +#[test] +fn corner_cursors_stay_diagonal_whatever_the_proportions() { + let mut s = session(); + let mod180 = |a: f64, b: f64| near((a - b).rem_euclid(180.0).min((b - a).rem_euclid(180.0)), 0.0); + for (w, h) in [(65.0, 15.0), (100.0, 100.0), (60.0, 200.0), (200.0, 50.0)] { + let r = rect(&mut s, 0.0, 0.0, w, h, None); + let doc = s.doc().unwrap().clone(); + let q = selection_box(&doc, &[r]).unwrap(); + for (i, want) in [(0u8, 45.0), (1, 135.0), (2, 45.0), (3, 135.0)] { + let Cursor::Resize(a) = handle_cursor(&q, Handle::Corner(i)) else { panic!() }; + assert!(mod180(a, want), "{w}x{h} corner {i}: {a}, want {want}"); + } + // Turned, the corner cursors turn with the layer's own axes. + set(&mut s, &[r], json!({"rotation": 30})); + let doc = s.doc().unwrap().clone(); + let q = selection_box(&doc, &[r]).unwrap(); + let axis = (q[1] - q[0]).atan2().to_degrees(); + for (i, off) in [(0u8, 45.0), (1, 135.0), (2, 45.0), (3, 135.0)] { + let Cursor::Resize(a) = handle_cursor(&q, Handle::Corner(i)) else { panic!() }; + assert!(mod180(a, axis + off), "{w}x{h} turned corner {i}: {a}, want {}", axis + off); + } + } +} + #[test] fn the_transform_box_and_its_handles() { let mut s = session(); From d1430e30544495c5928cc074a9c68f7835d2014a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:56:27 +0200 Subject: [PATCH 080/120] Keep text selection highlight inside the box at the end of the text Fixes #365 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 3 ++- crates/text/src/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 110c75898..b5b617d00 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1213,7 +1213,8 @@ impl TextLayout { } let (x0, _) = self.caret(a.max(line.char_start)); let x1 = if b >= line.char_end { - self.line_end_x(line) + if line.para_end && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 } + // The last line has no paragraph break to show, so it ends at its glyphs. + self.line_end_x(line) + if line.para_end && li + 1 < self.lines.len() && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 } } else { self.caret(b).0 }; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 526ae0b03..e34f0110f 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -416,3 +416,24 @@ fn arrows_move_visually_through_right_to_left_runs() { // Plain left-to-right lines step by char (the caller's default). assert!(layout(&TextData::new("plain"), None, None).visual_step(1, true).is_none()); } + +#[test] +fn selection_through_text_end_stays_inside_auto_width_box() { + if !have_fonts() { + return; + } + for size in [12.0, 24.0, 96.0] { + let mut t = TextData::new("Hello world"); + t.style.font_size = size; + let l = layout(&t, None, None); + let n = "Hello world".chars().count(); + let right = l.selection_rects(0, n).last().map_or(f64::NAN, |r| r.x1); + assert!(right <= l.width.ceil(), "size {size}: right {right} > ceil(width) {}", l.width.ceil()); + } + // A selected paragraph break still shows past the end of its line. + let t = TextData::new("Hello\nworld"); + let l = layout(&t, None, None); + let r = l.selection_rects(0, 6); + let first = r.first().map_or(f64::NAN, |r| r.x1); + assert!(first > l.caret(5).0 + 1.0, "{first}"); +} From 13691c1cc032a1ac43b7820d6196b25076793a9a Mon Sep 17 00:00:00 2001 From: Edivan Teixeira Date: Wed, 14 Oct 2026 23:43:31 -0300 Subject: [PATCH 081/120] Let batch steps reference ids created by earlier steps Inside command.batch, `{"$step": n}` anywhere in a step's params is replaced by the `id` in step n's result before the step runs (`{"$step": n, "field": "..."}` picks another field). A nested tree, such as a frame with children, now takes one batch and one undo step, not one round-trip per level with ids copied between calls by hand. Malformed references (forward or self, negative or fractional index, missing or non-string field, extra keys) fail the batch with BadParams naming the step, and the existing group machinery rolls it back. The params walk is depth-capped. Steps are journalled with resolved params. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/batch_step_ref_tests.rs | 91 +++++++++++++++++++++++ crates/engine/src/lib.rs | 56 +++++++++++++- crates/mcp/src/tools.rs | 2 +- crates/mcp/tests/agent_tasks.rs | 37 +++++++++ docs/control-protocol.md | 8 ++ docs/mcp.md | 28 ++++++- 6 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/batch_step_ref_tests.rs diff --git a/crates/engine/src/batch_step_ref_tests.rs b/crates/engine/src/batch_step_ref_tests.rs new file mode 100644 index 000000000..7474d5465 --- /dev/null +++ b/crates/engine/src/batch_step_ref_tests.rs @@ -0,0 +1,91 @@ +use serde_json::json; + +use crate::{EngineError, Session}; + +fn id(value: &serde_json::Value) -> crate::NodeId { + crate::params::id_of(&value["id"]).unwrap() +} + +#[test] +fn later_steps_target_layers_created_by_earlier_steps() { + let mut s = Session::new(); + let results = s + .execute( + "command.batch", + &json!({ + "label": "Card", + "commands": [ + {"command": "node.create", "params": {"type": "frame", "x": 0, "y": 0, "width": 200, "height": 100}}, + {"command": "node.create", "params": {"type": "rectangle", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}}, + {"command": "node.set", "params": {"ids": [{"$step": 1}], "props": {"name": "Swatch"}}} + ] + }), + ) + .unwrap(); + + let frame = id(&results[0]); + let rect = id(&results[1]); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(rect).unwrap().parent, Some(frame)); + assert_eq!(doc.node(rect).unwrap().name, "Swatch"); + assert_eq!(s.ds().unwrap().history.undo.len(), 1, "still one undo step"); +} + +#[test] +fn field_picks_another_part_of_the_result() { + let mut s = Session::new(); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + {"command": "node.create", "params": {"type": "frame", "width": 50, "height": 50}}, + {"command": "node.create", "params": {"type": "text", "text": "hi", "parent": {"$step": 0, "field": "id"}}} + ]}), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().node(id(&results[1])).unwrap().parent, Some(id(&results[0]))); +} + +#[test] +fn bad_references_fail_and_roll_back() { + let mut s = Session::new(); + let before = s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(); + let frame = json!({"command": "node.create", "params": {"type": "frame", "width": 10, "height": 10}}); + for (bad, needle) in [ + (json!({"$step": 1}), "earlier step"), + (json!({"$step": 5}), "earlier step"), + (json!({"$step": -1}), "step index"), + (json!({"$step": "0"}), "step index"), + (json!({"$step": 0, "field": "nope"}), "no `nope`"), + (json!({"$step": 0, "field": 3}), "`field` must be a string"), + (json!({"$step": 0, "extra": true}), "not `extra`"), + ] { + let r = s.execute("command.batch", &json!({"commands": [frame, {"command": "node.set", "params": {"ids": [bad], "props": {"name": "x"}}}]})); + match r { + Err(EngineError::BadParams { msg, .. }) => assert!(msg.contains(needle), "{msg} should mention {needle}"), + other => panic!("expected BadParams for {bad}, got {other:?}"), + } + assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), before, "rolled back"); + } +} + +#[test] +fn deep_params_are_refused_not_overflowed() { + let mut s = Session::new(); + let mut deep = json!(1); + for _ in 0..200 { + deep = json!([deep]); + } + let r = s.execute("command.batch", &json!({"commands": [{"command": "select.none", "params": {"x": deep}}]})); + assert!(matches!(r, Err(EngineError::BadParams { .. }))); +} + +#[test] +fn text_that_looks_like_a_reference_is_left_alone() { + let mut s = Session::new(); + let results = s + .execute("command.batch", &json!({"commands": [{"command": "node.create", "params": {"type": "text", "text": "$step 0 {\"$step\": 0}"}}]})) + .unwrap(); + let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone(); + assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == "$step 0 {\"$step\": 0}")); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a906..0a0857114 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -107,6 +107,47 @@ pub fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.to_string(), msg: msg.into() } } +/// Deepest params nesting `resolve_step_refs` walks before giving up. +const STEP_REF_MAX_DEPTH: usize = 64; + +/// Replace every `{"$step": n}` / `{"$step": n, "field": "…"}` in `v` with that field (default +/// `id`) of `results[n]`, the result of an earlier batch step. +fn resolve_step_refs(v: &Value, results: &[Value], step: usize, depth: usize) -> Result { + if depth > STEP_REF_MAX_DEPTH { + return Err(bad("command.batch", format!("step {step}: params nest deeper than {STEP_REF_MAX_DEPTH} levels"))); + } + match v { + Value::Object(o) if o.contains_key("$step") => { + if let Some(k) = o.keys().find(|k| *k != "$step" && *k != "field") { + return Err(bad("command.batch", format!("step {step}: a `$step` reference takes only `field`, not `{k}`"))); + } + let n = o + .get("$step") + .and_then(Value::as_u64) + .ok_or_else(|| bad("command.batch", format!("step {step}: `$step` must be a step index (0, 1, …)")))?; + let field = match o.get("field") { + None => "id", + Some(Value::String(f)) => f.as_str(), + Some(_) => return Err(bad("command.batch", format!("step {step}: `field` must be a string"))), + }; + let Some(result) = usize::try_from(n).ok().and_then(|n| results.get(n)) else { + let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) }; + return Err(bad("command.batch", format!("step {step}: `$step` {n} must be an earlier step ({range})"))); + }; + result.get(field).cloned().ok_or_else(|| bad("command.batch", format!("step {step}: step {n}'s result has no `{field}`"))) + } + Value::Object(o) => { + let mut out = serde_json::Map::with_capacity(o.len()); + for (k, x) in o { + out.insert(k.clone(), resolve_step_refs(x, results, step, depth + 1)?); + } + Ok(Value::Object(out)) + } + Value::Array(a) => a.iter().map(|x| resolve_step_refs(x, results, step, depth + 1)).collect::>>().map(Value::Array), + _ => Ok(v.clone()), + } +} + /// One undo step. #[derive(Clone, Debug)] pub struct HistoryEntry { @@ -1071,9 +1112,18 @@ impl Session { self.ui_requests.push(UiRequest { kind: kind.to_string(), data }); } - /// Run several commands as one undo step. + /// Run several commands as one undo step. A param value `{"$step": n}` is replaced by the `id` + /// in the result of the earlier step `n` (`{"$step": n, "field": "…"}` picks another field), + /// so a step can target a layer that an earlier step of the same batch created. pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result> { - self.grouped(label, |s| cmds.iter().map(|(id, p)| s.execute(id, p)).collect()) + self.grouped(label, |s| { + let mut results: Vec = Vec::with_capacity(cmds.len()); + for (step, (id, p)) in cmds.iter().enumerate() { + let p = resolve_step_refs(p, &results, step, 0)?; + results.push(s.execute(id, &p)?); + } + Ok(results) + }) } /// Run `f` as one undo step called `label`: the commands it executes push no steps of their @@ -1393,6 +1443,8 @@ mod autosave_tests; #[cfg(test)] mod batch_rollback_tests; #[cfg(test)] +mod batch_step_ref_tests; +#[cfg(test)] mod binding_lists_tests; #[cfg(test)] mod booleans_tests; diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index b8d3f3ad0..68b78ad82 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -127,7 +127,7 @@ pub fn tool_definitions() -> Vec { tool( "batch", "Run commands as one step", - "Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. Note: later commands cannot reference ids created by earlier ones in the same batch (ids are only known afterwards), so create containers first, then batch the edits.", + "Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. A later step can use a layer an earlier step created: anywhere in its params, {\"$step\": n} stands for the `id` in step n's result (zero-based; {\"$step\": n, \"field\": \"…\"} picks another field). Example: [{\"command\":\"node.create\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$step\":0}}}] builds a frame and a text inside it in one call.", obj( json!({ "commands": {"type": "array", "minItems": 1, "description": "Steps to run", "items": obj(json!({"command": string("Command id"), "params": {"type": "object"}}), &["command"])}, diff --git a/crates/mcp/tests/agent_tasks.rs b/crates/mcp/tests/agent_tasks.rs index cc7974abc..b35f3e7fd 100644 --- a/crates/mcp/tests/agent_tasks.rs +++ b/crates/mcp/tests/agent_tasks.rs @@ -307,3 +307,40 @@ fn task_vector_edit_keys() { assert_eq!(c.page_children().len(), 1); assert!(c.call("run_command", json!({"command": "vector.edit.state", "params": {}}))["isError"] == true, "Enter left edit mode"); } + +/// A whole card in one `batch` call: later steps reach the frames earlier steps created through +/// `{"$step": n}`, so the tree is built in one round-trip and one undo step. +#[test] +fn task_card_in_one_batch() { + let mut c = Client::new(); + let out = c.ok( + "batch", + json!({"label": "Card", "commands": [ + {"command": "node.create", "params": {"type": "frame", "name": "Card", "width": 320, "height": 50, + "props": {"layout": "vertical", "padding": 24, "gap": 12, "sizingV": "hug"}}}, + {"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}, "text": "Ada Lovelace"}}, + {"command": "node.create", "params": {"type": "frame", "parent": {"$step": 0}, "name": "Button", "height": 44, + "props": {"layout": "horizontal", "sizingH": "fill"}}}, + {"command": "node.create", "params": {"type": "text", "parent": {"$step": 2}, "text": "Follow"}}, + {"command": "node.set", "params": {"ids": [{"$step": 2}], "props": {"fill": "#3B5BDB"}}}, + ]}), + ); + let results = ids(&out); + assert_eq!(results.len(), 5); + let (card, button) = (results[0]["id"].clone(), results[2]["id"].clone()); + let n = c.node(&card); + assert_eq!(ids(&n["children"]).len(), 2, "title and button are inside the card"); + let b = c.node(&button); + assert_eq!(ids(&b["children"]).len(), 1, "the label is inside the button"); + assert_eq!(b["fills"][0]["color"].as_str().unwrap().to_ascii_uppercase(), "#3B5BDB"); + assert_eq!(c.doc()["history"]["undo"][0], "Card", "one undo step"); + // A forward reference fails the whole batch and names the step. + let e = c.err( + "batch", + json!({"commands": [ + {"command": "node.create", "params": {"type": "frame", "parent": {"$step": 1}}}, + {"command": "node.create", "params": {"type": "frame"}}, + ]}), + ); + assert!(e.contains("step 0") && e.contains("earlier step"), "{e}"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9fc9937d9..5cb3dc04e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -60,6 +60,14 @@ commands return `{"id": N}`). {"id":3,"method":"engine.execute","params":{"command":"command.batch","params":{"label":"Tidy","commands":[{"command":"align.left","params":{"ids":[2,3]}},{"command":"distribute.vertical","params":{"ids":[2,3]}}]}}} ``` +Inside `command.batch`, `{"$step": n}` in a step's params stands for the `id` in step `n`'s result +(zero-based; `{"$step": n, "field": "…"}` for another field), so one batch can create a frame and +fill it: + +```json +{"id":4,"method":"engine.execute","params":{"command":"command.batch","params":{"commands":[{"command":"node.create","params":{"type":"frame","width":200,"height":100}},{"command":"node.create","params":{"type":"rectangle","parent":{"$step":0},"width":20,"height":20}}]}}} +``` + ### `engine.commands {}` Every command: `[{id, label, params, menu, shortcut, undoable}]`. diff --git a/docs/mcp.md b/docs/mcp.md index 2b66657c2..8068ac01c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -219,7 +219,7 @@ Or in a project's `.mcp.json`: |---|---| | `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. | | `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, SwiftUI or Jetpack Compose for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React and Leptos, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). | -| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. | +| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: `{"$step": n}` anywhere in its params becomes the `id` in step n's result (zero-based), and `{"$step": n, "field": "…"}` another field of it (see Step references below). | | `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. | | `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. | | `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). | @@ -1509,6 +1509,26 @@ Align and distribute as one undo step: {"command":"distribute.horizontal","params":{"ids":[3,4,5]}}]}}} ``` -The end-to-end tests in `crates/mcp/tests/agent_tasks.rs` are worked examples of ten realistic -tasks (auto layout, components and overrides, gradients and shadows, paint styles, Light/Dark -variables, align/distribute/group, boolean subtract + flatten, export, undo/redo, drawing with tools). +### Step references + +A step in a `batch` can use what an earlier step returned. `{"$step": n}` anywhere in a step's +params (a value, an item in `ids`, a nested field) is replaced by the `id` in the result of step `n` +(zero-based) before the step runs; `{"$step": n, "field": "name"}` takes another field of that +result. This builds a nested tree in one call and one undo step, without guessing ids: + +```json +{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"batch","arguments":{"label":"Card","commands":[ + {"command":"node.create","params":{"type":"frame","name":"Card","props":{"layout":"vertical","gap":12}}}, + {"command":"node.create","params":{"type":"text","parent":{"$step":0},"text":"Ada Lovelace"}}, + {"command":"node.create","params":{"type":"frame","parent":{"$step":0},"name":"Button"}}, + {"command":"node.create","params":{"type":"text","parent":{"$step":2},"text":"Follow"}}]}}} +``` + +A reference to the same or a later step, a negative or non-integer index, a missing `field` or any +other key next to `$step` fails the whole batch, which is rolled back. Only an object whose keys are +`$step` (and optionally `field`) is a reference: text such as `"$step 0"` is left as written. + +The end-to-end tests in `crates/mcp/tests/agent_tasks.rs` are worked examples of realistic tasks +(auto layout, a card built in one batch with step references, components and overrides, gradients +and shadows, paint styles, Light/Dark variables, align/distribute/group, boolean subtract + flatten, +export, undo/redo, drawing with tools). From 1199f690adbc18aaafffa0e0af35141677022fe3 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:12:21 +0200 Subject: [PATCH 082/120] Fix Send to back leaving layers above siblings across frames Found while testing crates/engine/src/cmd/arrange.rs:327-330 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/arrange.rs | 8 ++++++-- crates/engine/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 2c088cfa1..ca691bfac 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -325,8 +325,12 @@ fn zorder(s: &mut Session, p: &Value, z: Z) -> Result { } } if matches!(z, Z::Back) { - for (k, i) in ids.iter().enumerate() { - doc.reorder(*i, k)?; + let mut next: std::collections::HashMap = std::collections::HashMap::new(); + for i in &ids { + let Some(pp) = doc.get(*i)?.parent else { continue }; + let k = next.entry(pp).or_insert(0); + doc.reorder(*i, *k)?; + *k += 1; } } ok() diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 40da2f1df..14ec4cfc0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -253,6 +253,28 @@ fn align_distribute_zorder() { assert_eq!(s.doc().unwrap().node(page).unwrap().children[0], c); } +#[test] +fn send_to_back_across_frames_counts_per_parent() { + let mut s = sess(); + let make = |s: &mut Session, p: Value| id(&s.execute("node.create", &p).unwrap()); + let fa = make(&mut s, json!({"type": "frame", "x": 0, "y": 0, "width": 200, "height": 200})); + let fb = make(&mut s, json!({"type": "frame", "x": 300, "y": 0, "width": 200, "height": 200})); + let rect = |parent: NodeId| json!({"type": "rectangle", "parent": parent, "x": 10, "y": 10, "width": 20, "height": 20}); + let a: Vec = (0..2).map(|_| make(&mut s, rect(fa))).collect(); + let b: Vec = (0..3).map(|_| make(&mut s, rect(fb))).collect(); + let idx = |s: &Session, n: NodeId| s.doc().unwrap().index_in_parent(n); + assert_eq!(idx(&s, a[1]), Some(1)); + assert_eq!(idx(&s, b[2]), Some(2)); + s.execute("arrange.back", &json!({"ids": [a[1], b[2]]})).unwrap(); + assert_eq!(idx(&s, a[1]), Some(0), "top of frame A at the back"); + assert_eq!(idx(&s, b[2]), Some(0), "top of frame B at the back"); + // Two from B plus one from A: each parent's selection ends at 0..n in its original order. + s.execute("arrange.back", &json!({"ids": [b[1], a[0], b[0]]})).unwrap(); + let kids = |s: &Session, f: NodeId| s.doc().unwrap().node(f).unwrap().children.clone(); + assert_eq!(kids(&s, fa), vec![a[0], a[1]]); + assert_eq!(kids(&s, fb), vec![b[0], b[1], b[2]]); +} + #[test] fn copy_paste_and_duplicate() { let mut s = sess(); From 996852d15ca3397439304280363231649c12ba1e Mon Sep 17 00:00:00 2001 From: Rob Coenen <753704+rcoenen@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:55:43 -0400 Subject: [PATCH 083/120] M4: hanging punctuation test holds for fonts that kern with x offsets On macOS without craft-fonts the default face is Helvetica Neue, whose kerning comes out of the shaper partly as an x offset (e.g. on the "e" of "here."), so a glyph's x + advance is not its pen end. The layout was right (the full stop's pen sits exactly on the edge); the test assumed no offsets. Each hanging line is now compared with the same line set flush: every glyph must move by exactly the hanging mark's advance, and the flush right line must end on the edge. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/tail_tests.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/text/src/tail_tests.rs b/crates/text/src/tail_tests.rs index 9f771b818..890b254de 100644 --- a/crates/text/src/tail_tests.rs +++ b/crates/text/src/tail_tests.rs @@ -130,14 +130,23 @@ fn hanging_punctuation_outside_the_aligned_edge() { t.auto_resize = TextAutoResize::Height; let Some(plain) = laid(&t) else { return }; let open = plain.glyphs[0].advance; + // Kerning may draw a glyph off its pen position (an x offset, e.g. Helvetica Neue's "re"), so + // each hanging line is checked against the same line set flush: every glyph moves by exactly + // the hanging mark's advance, which puts the letters' pen edge on the box edge. + let moved = |a: &TextLayout, b: &TextLayout, by: f64| { + a.glyphs.len() == b.glyphs.len() && a.glyphs.iter().zip(&b.glyphs).all(|(a, b)| (a.x - b.x - by).abs() < EPS) + }; t.hanging_punctuation = true; let l = laid(&t).unwrap(); - assert!((l.glyphs[0].x + open).abs() < EPS && l.glyphs[1].x.abs() < EPS, "left: the quote hangs, Q starts the line"); + assert!(plain.glyphs[0].x.abs() < EPS && moved(&l, &plain, -open), "left: the quote hangs, Q starts the line"); t.align_h = TextAlignH::Right; + t.hanging_punctuation = false; + let flush = laid(&t).unwrap(); + t.hanging_punctuation = true; let l = laid(&t).unwrap(); - let last = l.glyphs.last().unwrap(); - let before = &l.glyphs[l.glyphs.len() - 2]; - assert!((before.x + before.advance - 400.0).abs() < EPS && last.x >= 400.0 - EPS, "right: the full stop hangs past the edge"); + let stop = l.glyphs.last().unwrap().advance; + let edge = flush.lines[0].x + flush.lines[0].width; + assert!((edge - 400.0).abs() < EPS && moved(&l, &flush, stop), "right: the full stop hangs past the edge"); t.align_h = TextAlignH::Center; let (a, b) = (laid(&t).unwrap(), { t.hanging_punctuation = false; From 49a2c643c68cec7e99eefa82b5f74dc7fae98272 Mon Sep 17 00:00:00 2001 From: Losminek <301412321+Losminek@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:01:27 +0200 Subject: [PATCH 084/120] i18n: add Czech (cs) UI catalog as a draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds crates/ui-egui/locales/cs.toml with all 2165 entries (ui, commands, menu), marked "draft — needs native review" and kept out of COMPLETE. Plurals use one/other only; the other form is worded count-neutral so it reads correctly for 2-4 as well. Lists cs in the pref.language help text, adds a cs_CZ.UTF-8 detection test and updates the localization docs. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 26 +- contributors/people.toml | 3 + crates/engine/src/cmd/view.rs | 28 +- crates/ui-egui/locales/cs.toml | 2176 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n_tests.rs | 11 +- docs/gaps.md | 16 +- docs/localization-parity.md | 53 +- docs/mcp.md | 2 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 5 +- docs/translating.md | 14 +- 11 files changed, 2274 insertions(+), 65 deletions(-) create mode 100644 crates/ui-egui/locales/cs.toml diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29b..688fa18df 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Czech draft catalog; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -50,7 +50,7 @@ against real Figma documents. Ready-for-real-work is ~53%, inside alpha's ~40– ~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h), basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h); -native speakers' reviews of the six language drafts need people. +native speakers' reviews of the language drafts need people. ## By dimension @@ -63,7 +63,7 @@ native speakers' reviews of the six language drafts need people. | Stability | 55% | 20–40 | [gaps](docs/gaps.md) | | Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | | Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | -| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | +| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Czech drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | | Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | | Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) | | AI features | 0% | 30–60 | [gaps](docs/gaps.md) | @@ -99,21 +99,22 @@ native speakers' reviews of the six language drafts need people. | Language | Code | Strings | Status | |---|---|---|---| | English | en | 100% | full | -| Simplified Chinese | zh-Hans | 100% | draft (needs native review) | +| Simplified Chinese | zh-Hans | 95% | draft (needs native review) | | Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) | | Hindi | hi | 0% | none | | Arabic | ar | 0% | none | -| French | fr | 100% | draft (needs native review) | -| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) | +| French | fr | 95% | draft (needs native review) | +| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) | | Indonesian | id | 0% | none | -| Japanese | ja | 100% | draft (needs native review) | -| German | de | 100% | draft (needs native review) | -| Korean | ko | 100% | draft (needs native review) | +| Japanese | ja | 95% | draft (needs native review) | +| German | de | 95% | draft (needs native review) | +| Korean | ko | 95% | draft (needs native review) | | Vietnamese | vi | 0% | none | +| Czech | cs | 100% (2,165 entries) | draft (needs native review) | -No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese +Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), -French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸ +French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Czech drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI text uses an installed CJK font, loaded only when needed; adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, @@ -126,7 +127,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). 1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h. 2. Real-file corpus and measured fidelity: 15–30 h. 3. Tool-by-tool interaction pass against Figma: 40–60 h. -4. Languages: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h). +4. Languages: native reviews of the es, fr, de, pt-BR, ja, ko, zh-Hans and cs drafts and the es-419 overlay (people), then the other key languages. 5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h. ## Progress log @@ -312,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Czech draft catalog: 1,577 UI keys, 557 command labels and 31 menu wordings (2,165 entries), marked as awaiting native review and kept out of `COMPLETE`. The build discovers `cs.toml` automatically; System resolves cs and cs-CZ without font changes. Singular uses `n == 1`; count-neutral `other` forms cover 0, 2–4 and 5+. Localization grades and estimates unchanged. | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/contributors/people.toml b/contributors/people.toml index e61cfd31a..88d5b64ab 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -16,3 +16,6 @@ # # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. + +[people.Losminek] +sync_github_name = true diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 6135e5dfe..77ff03c4b 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -369,18 +369,24 @@ pub fn specs() -> Vec { Ok(json!({"url": s.prefs.publish_server, "default": crate::live::DEFAULT_SERVER})) } ), - spec!("pref.language", "Interface language", "{language: system|en|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}", false, |s, p| { - let lang = req_string("pref.language", p, "language")?; - let lang = lang.trim(); - // A BCP 47-style tag ("es", "pt-BR", "zh_Hans") or "system". - let tag = lang.len() <= 35 - && lang.split(['-', '_']).all(|part| !part.is_empty() && part.len() <= 8 && part.chars().all(|c| c.is_ascii_alphanumeric())); - if !tag { - return Err(bad("pref.language", format!("`{lang}` is not a language code"))); + spec!( + "pref.language", + "Interface language", + "{language: system|en|cs|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}", + false, + |s, p| { + let lang = req_string("pref.language", p, "language")?; + let lang = lang.trim(); + // A BCP 47-style tag ("es", "pt-BR", "zh_Hans") or "system". + let tag = lang.len() <= 35 + && lang.split(['-', '_']).all(|part| !part.is_empty() && part.len() <= 8 && part.chars().all(|c| c.is_ascii_alphanumeric())); + if !tag { + return Err(bad("pref.language", format!("`{lang}` is not a language code"))); + } + s.prefs.language = lang.to_string(); + Ok(json!({"language": s.prefs.language})) } - s.prefs.language = lang.to_string(); - Ok(json!({"language": s.prefs.language})) - }), + ), spec!("app.preferences", "Preferences", "{}", false, |s, _| ui(s, "dialog.preferences")), spec!("app.actions", "Actions", "{}", false, |s, _| ui(s, "palette.actions")), spec!("help.shortcuts", "Keyboard shortcuts", "{}", false, |s, _| ui(s, "panel.shortcuts")), diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml new file mode 100644 index 000000000..f54e8aded --- /dev/null +++ b/crates/ui-egui/locales/cs.toml @@ -0,0 +1,2176 @@ +# Czech interface catalog. Machine-drafted; awaiting native review. +# Plurals use count-neutral other forms for Czech 0, 2–4 and 5+. +[meta] +name = "Čeština" +status = "draft — needs native review" + +[ui] +"% of all commits" = "% všech commitů" +"(no longer published)" = "(již nepublikováno)" +"({n} warnings: some entries were skipped)" = { one = "({n} varování: některé položky byly přeskočeny)", other = "(Varování: {n}; některé položky byly přeskočeny)" } +"+ Create variable" = "+ Vytvořit proměnnou" +".fig (experimental)" = ".fig (experimentální)" +"1…9, 0" = "1…9, 0" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, commity: {commits}, +{added} / −{deleted} řádků (Δ {delta}), +{bin_added} / −{bin_deleted} binárních souborů, {first} – {last}" +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Kolekce tohoto souboru s těmito režimy a proměnnými: přepište jejich hodnoty pro jednotlivé režimy a přidejte vlastní proměnné" +"A responsive product page: hero, features, figures, a quote and a footer" = "Responzivní produktová stránka: úvodní blok, funkce, čísla, citace a zápatí" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "Pružina se pohybuje až do ustálení: délku určuje její hmotnost, tuhost a tlumení" +"A title, an agenda, a big figure, a chart and a closing slide" = "Titulní snímek, program, výrazné číslo, graf a závěrečný snímek" +"A to Z" = "Od A do Z" +"About" = "O aplikaci" +"About FigCraft" = "O aplikaci FigCraft" +"About this server" = "O tomto serveru" +"Action" = "Akce" +"Actions" = "Akce" +"Actions for {name}" = "Akce pro {name}" +"Actions…" = "Akce…" +"Active" = "Aktivní" +"Actual size (100%)" = "Skutečná velikost (100 %)" +"Add a category" = "Přidat kategorii" +"Add a comment" = "Přidat komentář" +"Add a description" = "Přidat popis" +"Add a frame (F) or a shape (R) to get started" = "Začněte přidáním rámce (F) nebo tvaru (R)" +"Add a layer or component to tile first" = "Nejprve přidejte vrstvu nebo komponentu k opakování" +"Add a note for developers" = "Přidat poznámku pro vývojáře" +"Add action" = "Přidat akci" +"Add actions for when no condition holds" = "Přidat akce pro případ, kdy neplatí žádná podmínka" +"Add an annotation for developers" = "Přidat anotaci pro vývojáře" +"Add another condition" = "Přidat další podmínku" +"Add auto layout" = "Přidat automatické rozvržení" +"Add background behind overlay" = "Přidat pozadí za překryv" +"Add content to this slot" = "Přidat obsah do tohoto slotu" +"Add dev resource" = "Přidat zdroj pro vývojáře" +"Add effect" = "Přidat efekt" +"Add export setting" = "Přidat nastavení exportu" +"Add fill" = "Přidat výplň" +"Add interaction" = "Přidat interakci" +"Add layout grid" = "Přidat mřížku rozvržení" +"Add library" = "Přidat knihovnu" +"Add library file…" = "Přidat soubor knihovny…" +"Add max height" = "Přidat maximální výšku" +"Add max width" = "Přidat maximální šířku" +"Add min height" = "Přidat minimální výšku" +"Add min width" = "Přidat minimální šířku" +"Add mode" = "Přidat režim" +"Add new page" = "Přidat novou stránku" +"Add or update collections from a variables JSON" = "Přidat nebo aktualizovat kolekce ze souboru JSON s proměnnými" +"Add properties to make instances configurable" = "Přidejte vlastnosti pro nastavení instancí" +"Add reaction" = "Přidat reakci" +"Add starting point" = "Přidat počáteční bod" +"Add stroke" = "Přidat tah" +"Add variant" = "Přidat variantu" +"Add variant with a new value" = "Přidat variantu s novou hodnotou" +"Added" = "Přidáno" +"Added the library \"{name}\"" = "Přidána knihovna „{name}“" +"Adjust" = "Upravit" +"Adjustment presets" = "Předvolby úprav" +"Advanced" = "Pokročilé" +"Advanced stroke" = "Pokročilé nastavení tahu" +"After delay" = "Po prodlevě" +"After delay {ms}ms" = "Po prodlevě {ms} ms" +"Align" = "Zarovnat" +"Align bottom" = "Zarovnat dolů" +"Align center" = "Zarovnat na střed" +"Align horizontal centers" = "Zarovnat vodorovné středy" +"Align left" = "Zarovnat vlevo" +"Align middle" = "Zarovnat doprostřed" +"Align right" = "Zarovnat vpravo" +"Align text baselines" = "Zarovnat účaří textu" +"Align top" = "Zarovnat nahoru" +"Align vertical centers" = "Zarovnat svislé středy" +"Alignment" = "Zarovnání" +"All" = "Vše" +"All categories" = "Všechny kategorie" +"All comments" = "Všechny komentáře" +"All components" = "Všechny komponenty" +"All fonts" = "Všechna písma" +"All supported properties" = "Všechny podporované vlastnosti" +"All variables" = "Všechny proměnné" +"Alpha" = "Alfa" +"Alphabetical" = "Abecedně" +"Alt+drag" = "Alt+tažení" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Analytický přehled na mřížce: čísla, vektorové grafy a tabulka" +"An invite link that opens FigCraft and joins" = "Odkaz s pozvánkou, který otevře FigCraft a připojí k relaci" +"Anchor" = "Kotva" +"Android" = "Android" +"Angular" = "Úhlový" +"Animation" = "Animace" +"Annotations" = "Anotace" +"Annotations shown on the canvas" = "Anotace zobrazené na plátně" +"Another server, or one that needs its key. The usual server is set in Preferences." = "Jiný server nebo server vyžadující klíč. Výchozí server se nastavuje v předvolbách." +"Any status" = "Libovolný stav" +"Anyone on your network with this code can edit the file, while this app stays open." = "Kdokoli ve vaší síti s tímto kódem může soubor upravovat, dokud tato aplikace zůstane otevřená." +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Kdokoli s odkazem pro úpravy může soubor měnit; odkaz pro prohlížení umožňuje pouze prohlížení. Odkazy obsahují tajné údaje: sdílejte je jako hesla. Příkaz `figcraft-server links` vytváří další odkazy a odvolává je." +"Anyone with this code can edit the file." = "Kdokoli s tímto kódem může soubor upravovat." +"Anyone with this code can look at the file, not change it." = "Kdokoli s tímto kódem může soubor prohlížet, ale nemůže ho měnit." +"Appearance" = "Vzhled" +"Apply a component property" = "Použít vlastnost komponenty" +"Apply variable" = "Použít proměnnou" +"Apply variable to text" = "Použít proměnnou na text" +"Apply variable to visibility" = "Použít proměnnou na viditelnost" +"Apps" = "Aplikace" +"Apr" = "dubna" +"Arc" = "Oblouk" +"Arc ratio" = "Poměr oblouku" +"Arc start" = "Začátek oblouku" +"Arc sweep" = "Rozsah oblouku" +"Archive" = "Archivovat" +"Archived" = "Archivováno" +"Arrange" = "Uspořádat" +"Arrow" = "Šipka" +"As typed" = "Podle zadání" +"Asking the server…" = "Dotazování serveru…" +"Assets" = "Zdroje" +"Aug" = "srpna" +"Auto" = "Automaticky" +"Auto (space around)" = "Automaticky (mezery kolem)" +"Auto (space between)" = "Automaticky (mezery mezi)" +"Auto (space evenly)" = "Automaticky (stejné mezery)" +"Auto ({mode})" = "Automaticky ({mode})" +"Auto columns" = "Automatické sloupce" +"Auto gap" = "Automatický rozestup" +"Auto height" = "Automatická výška" +"Auto layout" = "Automatické rozvržení" +"Auto rows" = "Automatické řádky" +"Auto width" = "Automatická šířka" +"Back to the FigCraft server" = "Zpět na server FigCraft" +"Back to the font's value" = "Zpět na hodnotu písma" +"Back" = "Zpět" +"Back to Ready for dev" = "Zpět do stavu Připraveno pro vývoj" +"Back to Ready for dev: the next change compares with how it is now" = "Zpět do stavu Připraveno pro vývoj: další změna se porovná s aktuálním stavem" +"Back to assets" = "Zpět ke zdrojům" +"Back to current" = "Zpět na aktuální" +"Back to styles" = "Zpět ke stylům" +"Back to the canvas from a focused region" = "Zpět na plátno ze zaměřené oblasti" +"Back to the current version" = "Zpět na aktuální verzi" +"Back to the values the font style implies" = "Zpět na hodnoty dané řezem písma" +"Background" = "Pozadí" +"Background blur" = "Rozostření pozadí" +"Basics" = "Základy" +"Before" = "Před" +"Bend" = "Ohnout" +"Bevel" = "Zkosený" +"Binary assets added" = "Přidané binární soubory" +"Binary assets removed" = "Odstraněné binární soubory" +"Black & white" = "Černobílé" +"Bleed" = "Spadávka" +"Bleed marks" = "Značky spadávky" +"Bleed marks need a bleed" = "Značky spadávky vyžadují spadávku" +"Blend" = "Prolnutí" +"Blend mode" = "Režim prolnutí" +"Blue" = "Modrá" +"Blur" = "Rozostření" +"Boolean" = "Logická hodnota" +"Boolean groups" = "Booleovské skupiny" +"Both directions" = "Oba směry" +"Bottom" = "Dole" +"Bottom left radius" = "Poloměr levého dolního rohu" +"Bottom right radius" = "Poloměr pravého dolního rohu" +"Branch" = "Větev" +"Branch archived" = "Větev archivována" +"Branch merged" = "Větev sloučena" +"Branch name" = "Název větve" +"Branch of {file}" = "Větev souboru {file}" +"Branch of {file} · merged or archived" = "Větev souboru {file} · sloučená nebo archivovaná" +"Branch updated from main" = "Větev aktualizována z hlavního souboru" +"Branch {name}" = "Větev {name}" +"Branches" = "Větve" +"Bound to {name}. Click to detach the variable." = "Navázáno na {name}. Kliknutím odpojíte proměnnou." +"Bring forward" = "Přenést o úroveň výš" +"Bring the main file's changes into the branch" = "Přenést změny hlavního souboru do větve" +"Bring to front" = "Přenést do popředí" +"Bristle" = "Štětiny" +"Browse" = "Procházet" +"Brush" = "Štětec" +"Brush rotation jitter" = "Náhodná odchylka natočení štětce" +"Brush scatter" = "Rozptyl štětce" +"Brush size jitter" = "Náhodná odchylka velikosti štětce" +"Brush spacing" = "Rozestup štětce" +"Bulleted list" = "Odrážkový seznam" +"C or Shift+C" = "C nebo Shift+C" +"C or ⇧C" = "C nebo ⇧C" +"CMYK colour" = "Barva CMYK" +"Can view" = "Může prohlížet" +"Checking the server…" = "Kontrola serveru…" +"Clear from this list" = "Vymazat z tohoto seznamu" +"Code or invite link" = "Kód nebo odkaz s pozvánkou" +"Color profile: {name}" = "Barevný profil: {name}" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Barevné a textové styly, proměnné pro světlý a tmavý režim, tlačítka, vstupní pole a karty" +"CSS custom properties…" = "Vlastní vlastnosti CSS…" +"Can edit" = "Může upravovat" +"Can't read this library: {e}" = "Tuto knihovnu nelze přečíst: {e}" +"Cancel" = "Zrušit" +"Canvas" = "Plátno" +"Canvas stacking" = "Pořadí na plátně" +"Cap height to baseline" = "Od horní hrany verzálek po účaří" +"Case" = "Velikost písmen" +"Case-sensitive forms" = "Tvary podle velikosti písmen" +"Categories" = "Kategorie" +"Center" = "Střed" +"Change alias" = "Změnit alias" +"Change resolved" = "Změna vyřešena" +"Change style" = "Změnit styl" +"Change to" = "Změnit na" +"Change to needs a layer inside a variant" = "Akce Změnit na vyžaduje vrstvu uvnitř varianty" +"Changed" = "Změněno" +"Changed on both sides: keep one" = "Změněno na obou stranách: vyberte jednu" +"Changes on main" = "Změny v hlavním souboru" +"Changes on the branch" = "Změny ve větvi" +"Changes unknown" = "Změny nejsou známy" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Pro propojení této komponenty s jejím kódem vyberte nahoře React, Leptos, SwiftUI nebo Compose." +"Choose another profile to change it." = "Pro změnu vyberte jiný profil." +"Choose collection" = "Vybrat kolekci" +"Choose image" = "Vybrat obrázek" +"Choose mode" = "Vybrat režim" +"Choose variable" = "Vybrat proměnnou" +"Circle" = "Kruh" +"Clear recent" = "Vymazat nedávné" +"Click + to replace mixed content" = "Kliknutím na + nahradíte smíšený obsah" +"Click anywhere on the canvas to leave one." = "Kliknutím kamkoli na plátno přidáte komentář." +"Click or drag to merge · {alt} removes" = "Kliknutím nebo tažením sloučíte · {alt} odečítá" +"Click or drag to remove · {alt} merges" = "Kliknutím nebo tažením odeberete · {alt} slučuje" +"Click to record a key" = "Kliknutím zaznamenáte klávesu" +"Clip content" = "Oříznout obsah" +"Clip to shape" = "Oříznout podle tvaru" +"Close" = "Zavřít" +"Close overlay" = "Zavřít překryv" +"Close preview" = "Zavřít náhled" +"Close tab" = "Zavřít kartu" +"Close version history" = "Zavřít historii verzí" +"Close when clicking outside" = "Zavřít při kliknutí mimo" +"Close without saving" = "Zavřít bez uložení" +"Code" = "Kód" +"Code syntax" = "Syntaxe kódu" +"Collapse" = "Sbalit" +"Collapse panel" = "Sbalit panel" +"Collection" = "Kolekce" +"Collections" = "Kolekce" +"Color" = "Barva" +"Color burn" = "Ztmavit barvy" +"Color dodge" = "Zesvětlit barvy" +"Color format" = "Formát barvy" +"Color profile" = "Barevný profil" +"Color styles" = "Barevné styly" +"Color values in this file are in this color space." = "Hodnoty barev v tomto souboru jsou v tomto barevném prostoru." +"Color variables" = "Barevné proměnné" +"Colour" = "Barva" +"Column" = "Sloupec" +"Column gap" = "Mezera mezi sloupci" +"Column sizes" = "Velikosti sloupců" +"Column span" = "Rozpětí přes sloupce" +"Columns" = "Sloupce" +"Columns {count}" = "Sloupce: {count}" +"Comment" = "Komentář" +"Comments" = "Komentáře" +"Commits" = "Commity" +"Company" = "Společnost" +"Compare changes" = "Porovnat změny" +"Completed" = "Dokončeno" +"Component" = "Komponenta" +"Component name" = "Název komponenty" +"Component property" = "Vlastnost komponenty" +"Component set" = "Sada komponent" +"Component sets" = "Sady komponent" +"Components" = "Komponenty" +"Conditional" = "Podmíněná akce" +"Confetti" = "Konfety" +"Conflicts" = "Konflikty" +"Connect to code" = "Propojit s kódem" +"Connected" = "Připojeno" +"Connected to code: {from}" = "Propojeno s kódem: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "Spojení ztraceno, opětovné připojování (pokus {attempt})…" +"Constrain proportions" = "Zachovat poměr stran" +"Constraints" = "Omezení" +"Content" = "Obsah" +"Contents hidden" = "Obsah skryt" +"Contents only" = "Pouze obsah" +"Contextual alternates" = "Kontextové alternativy" +"Contrast" = "Kontrast" +"Contributors" = "Přispěvatelé" +"Contributors: {people} · Commits: {commits}" = "Přispěvatelé: {people} · Commity: {commits}" +"Controller" = "Ovladač" +"Cool" = "Studené" +"Copied as CSS" = "Zkopírováno jako CSS" +"Copied as PNG" = "Zkopírováno jako PNG" +"Copied as SVG" = "Zkopírováno jako SVG" +"Copied as text" = "Zkopírováno jako text" +"Copied {value}" = "Zkopírováno: {value}" +"Copy" = "Kopírovat" +"Copy as" = "Kopírovat jako" +"Copy as CSS" = "Kopírovat jako CSS" +"Copy as PNG" = "Kopírovat jako PNG" +"Copy as SVG" = "Kopírovat jako SVG" +"Copy code" = "Kopírovat kód" +"Copy link" = "Kopírovat odkaz" +"Copy the {label}" = "Kopírovat: {label}" +"Copy {value}" = "Kopírovat: {value}" +"Corner radius" = "Poloměr rohu" +"Corner smoothing" = "Vyhlazení rohů" +"Couldn't reach {server}: showing what this computer remembers." = "Server {server} není dostupný: zobrazuje se stav uložený v tomto počítači." +"Count" = "Počet" +"Create" = "Vytvořit" +"Create a variable collection first" = "Nejprve vytvořte kolekci proměnných" +"Create a variable first" = "Nejprve vytvořte proměnnou" +"Create alias" = "Vytvořit alias" +"Create branch" = "Vytvořit větev" +"Create collection" = "Vytvořit kolekci" +"Create component" = "Vytvořit komponentu" +"Create component property" = "Vytvořit vlastnost komponenty" +"Create components, styles or variables to publish them as a library." = "Vytvořte komponenty, styly nebo proměnné a publikujte je jako knihovnu." +"Create property…" = "Vytvořit vlastnost…" +"Create style" = "Vytvořit styl" +"Create style from the selected text" = "Vytvořit styl z vybraného textu" +"Created" = "Vytvořeno" +"Crop" = "Oříznout" +"Crop marks" = "Ořezové značky" +"Ctrl+arrows" = "Ctrl+šipky" +"Ctrl+click" = "Ctrl+kliknutí" +"Ctrl+scroll" = "Ctrl+posouvání" +"Current name" = "Aktuální název" +"Current version" = "Aktuální verze" +"Cursor chat" = "Chat u kurzoru" +"Custom" = "Vlastní" +"Cut" = "Vyjmout" +"Dark" = "Tmavý" +"Darken" = "Ztmavit" +"Dash" = "Čárkování" +"Dashboard" = "Přehled" +"Date created" = "Datum vytvoření" +"Dec" = "prosince" +"Decoration style" = "Styl dekorace" +"Decoration" = "Dekorace" +"Deep select" = "Hloubkový výběr" +"Default" = "Výchozí" +"Default content" = "Výchozí obsah" +"Default value (the top-left variant)" = "Výchozí hodnota (varianta vlevo nahoře)" +"Delay" = "Prodleva" +"Delete" = "Smazat" +"Delete category (its annotations stay)" = "Smazat kategorii (její anotace zůstanou)" +"Delete collection" = "Smazat kolekci" +"Delete group" = "Smazat skupinu" +"Delete mode" = "Smazat režim" +"Delete page" = "Smazat stránku" +"Delete preset" = "Smazat předvolbu" +"Delete property" = "Smazat vlastnost" +"Delete the style (layers keep their values)" = "Smazat styl (vrstvy si zachovají hodnoty)" +"Delete thread" = "Smazat vlákno" +"Delete variable" = "Smazat proměnnou" +"Delete “{name}”? This can't be undone." = "Smazat „{name}“? Tuto akci nelze vrátit zpět." +"Density" = "Hustota" +"Depth" = "Hloubka" +"Describe how and when to use this component" = "Popište, jak a kdy tuto komponentu používat" +"Describe what changed" = "Popište změny" +"Description" = "Popis" +"Description (optional)" = "Popis (nepovinný)" +"Design" = "Návrh" +"Design system starter" = "Základ design systému" +"Desktop" = "Počítač" +"Destination" = "Cíl" +"Detach alias" = "Odpojit alias" +"Detach instance" = "Odpojit instanci" +"Detach property" = "Odpojit vlastnost" +"Detach style" = "Odpojit styl" +"Detach styles" = "Odpojit styly" +"Detach variable" = "Odpojit proměnnou" +"Details" = "Podrobnosti" +"Dev Mode" = "Režim pro vývojáře" +"Dev resources" = "Zdroje pro vývojáře" +"Development" = "Vývoj" +"Device" = "Zařízení" +"Device at 100%" = "Zařízení ve 100 %" +"Diamond" = "Kosočtverec" +"Difference" = "Rozdíl" +"Dimensions" = "Rozměry" +"Direction" = "Směr" +"Disconnect from code" = "Odpojit od kódu" +"Dispersion" = "Rozptyl" +"Display name" = "Zobrazované jméno" +"Dissolve" = "Prolnutí snímků" +"Distribute and tidy" = "Rozmístit a uspořádat" +"Distribute horizontal spacing" = "Rozmístit vodorovné rozestupy" +"Distribute vertical spacing" = "Rozmístit svislé rozestupy" +"Divider" = "Oddělovač" +"Don't truncate" = "Nezkracovat" +"Done" = "Hotovo" +"Done editing" = "Dokončit úpravy" +"Dots" = "Tečky" +"Dotted" = "Tečkovaný" +"Down" = "Dolů" +"Drafts" = "Koncepty" +"Drag the stroke to add a width point" = "Tažením tahu přidáte bod šířky" +"Draw" = "Kreslení" +"Draw a frame" = "Nakreslit rámec" +"Drawing tools" = "Kreslicí nástroje" +"Drop shadow" = "Vržený stín" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Dvojí licence MIT OR Apache-2.0. Vytvořil tým ArtCraft a komunita." +"Duo" = "Dvojtónové" +"Duplicate" = "Duplikovat" +"Duplicate by dragging" = "Duplikovat tažením" +"Duplicate mode" = "Duplikovat režim" +"Duplicate page" = "Duplikovat stránku" +"Edit" = "Upravit" +"Edit as an expression" = "Upravit jako výraz" +"Edit colour" = "Upravit barvu" +"Edit comment" = "Upravit komentář" +"Edit dev resource" = "Upravit zdroj pro vývojáře" +"Edit link" = "Odkaz pro úpravy" +"Edit link copied" = "Odkaz pro úpravy zkopírován" +"Edit style" = "Upravit styl" +"Edit variable" = "Upravit proměnnou" +"Edit with the builder" = "Upravit pomocí tvorby tvarů" +"Edited {when}" = "Upraveno {when}" +"Effect settings" = "Nastavení efektu" +"Effect styles" = "Styly efektů" +"Effects" = "Efekty" +"Ellipse" = "Elipsa" +"Ellipses" = "Elipsy" +"Else" = "Jinak" +"Else if" = "Jinak pokud" +"Embedded profile" = "Vložený profil" +"Empty" = "Prázdné" +"End" = "Konec" +"End point" = "Koncový bod" +"Ends the session; everyone keeps their copy" = "Ukončí relaci; každý si ponechá svou kopii" +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Zadejte adresu serveru figcraft-server a stisknutím Procházet zobrazte nabízené pluginy." +"Enter to save · Esc to cancel" = "Enter uloží · Esc zruší" +"Eraser" = "Guma" +"Esc" = "Esc" +"Essential" = "Základní" +"Every conflict has a choice." = "Každý konflikt má vybrané řešení." +"Every top-level frame already starts a flow" = "Každý rámec nejvyšší úrovně již zahajuje tok" +"Exact opacity (4 then 5: 45%)" = "Přesné krytí (4 a pak 5: 45 %)" +"Exclude" = "Vyloučit" +"Excluded" = "Vyloučeno" +"Excluded from layout" = "Vyloučeno z rozvržení" +"Excludes" = "Vyloučení" +"Exclusion" = "Vylučovací prolnutí" +"Exit focus view (Esc)" = "Opustit zaměřený pohled (Esc)" +"Expand" = "Rozbalit" +"Export" = "Export" +"Export all" = "Exportovat vše" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Exportovat rámce a vrstvy jako PNG, JPG, SVG nebo PDF" +"Export page as PNG" = "Exportovat stránku jako PNG" +"Export settings" = "Nastavení exportu" +"Export variables" = "Exportovat proměnné" +"Export {name}" = "Exportovat {name}" +"Export {n} layers" = { one = "Exportovat {n} vrstvu", other = "Exportovat vrstvy: {n}" } +"Exported {file}" = "Exportován soubor {file}" +"Exported {name}" = "Exportováno: {name}" +"Exported {name}; {n} kinds of content were left out" = { one = "Exportováno: {name}; vynechán {n} druh obsahu", other = "Exportováno: {name}; vynechané druhy obsahu: {n}" } +"Exported {n} files" = { one = "Exportován {n} soubor", other = "Exportované soubory: {n}" } +"Export…" = "Exportovat…" +"Expose properties to instances" = "Zpřístupnit vlastnosti instancím" +"Exposure" = "Expozice" +"Expression" = "Výraz" +"Extend collection" = "Rozšířit kolekci" +"Extends \"{name}\": overridden values are marked" = "Rozšiřuje „{name}“: přepsané hodnoty jsou označeny" +"Eye" = "Oko" +"F" = "F" +"Fade" = "Vyblednutí" +"False" = "Nepravda" +"Feb" = "února" +"Fetching from the server…" = "Stahování ze serveru…" +"File" = "Soubor" +"File name" = "Název souboru" +"File not found" = "Soubor nenalezen" +"File published" = "Soubor publikován" +"Files aren't end-to-end encrypted: the server's operator can read them." = "Soubory nemají koncové šifrování: provozovatel serveru je může číst." +"Files nobody opens for {n} days are deleted." = { one = "Soubory, které nikdo neotevře déle než {n} den, se smažou.", other = "Soubory, které nikdo neotevře déle než {n} d, se smažou." } +"Fill" = "Výplň" +"Fill (fr)" = "Vyplnit (fr)" +"Fill container" = "Vyplnit kontejner" +"Fill window" = "Vyplnit okno" +"Fills" = "Výplně" +"Fill|sizing" = "Vyplnit" +"Filter and sort" = "Filtrovat a řadit" +"Find" = "Najít" +"Find and replace" = "Najít a nahradit" +"Find layers" = "Najít vrstvy" +"First" = "První" +"First commit" = "První commit" +"First on top" = "První nahoře" +"Fit" = "Přizpůsobit" +"Fit device to window" = "Přizpůsobit zařízení oknu" +"Fit to contents" = "Přizpůsobit obsahu" +"Fit to window" = "Přizpůsobit oknu" +"Fit width" = "Přizpůsobit šířce" +"Fixed" = "Pevné" +"Fixed gap" = "Pevný rozestup" +"Fixed size" = "Pevná velikost" +"Flatten" = "Sloučit do vektoru" +"Flip" = "Převrátit" +"Flip horizontal" = "Převrátit vodorovně" +"Flip text to the other side" = "Převrátit text na druhou stranu" +"Flip vertical" = "Převrátit svisle" +"Flip width profile" = "Převrátit profil šířky" +"Flow starting point" = "Počáteční bod toku" +"Flow starting points" = "Počáteční body toků" +"Flows" = "Toky" +"Focus view" = "Zaměřený pohled" +"Fold autosaves" = "Sbalit automaticky uložené verze" +"Folder: click to collapse" = "Složka: kliknutím sbalíte" +"Folder: click to expand" = "Složka: kliknutím rozbalíte" +"Follow" = "Sledovat" +"Follow {name}" = "Sledovat: {name}" +"Following" = "Sledování" +"Following {name} · {key} to stop" = "Sledujete: {name} · {key} ukončí sledování" +"Font" = "Písmo" +"Font not installed" = "Písmo není nainstalováno" +"Font size" = "Velikost písma" +"Font style" = "Řez písma" +"Forget on this computer" = "Zapomenout v tomto počítači" +"Four screens of a trail app with components and a prototype flow" = "Čtyři obrazovky turistické aplikace s komponentami a tokem prototypu" +"Fractions" = "Zlomky" +"Frame" = "Rámec" +"Frame selection" = "Vytvořit rámec z výběru" +"Frame tools" = "Nástroje rámců" +"Frames" = "Rámce" +"From the library \"{name}\": read-only" = "Z knihovny „{name}“: jen pro čtení" +"From {name}" = "Z {name}" +"From {server}" = "Ze serveru {server}" +"Frost" = "Námraza" +"Gap" = "Rozestup" +"Gap between columns" = "Mezera mezi sloupci" +"Gap between items" = "Mezera mezi položkami" +"Gap between rows" = "Mezera mezi řádky" +"Getting started" = "Začínáme" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Zadejte adresu veřejného serveru nebo adresu serveru s klíčem (vypíše ho příkaz `figcraft-server key`)." +"Glass" = "Sklo" +"Go to main component" = "Přejít na hlavní komponentu" +"Go to {name}" = "Přejít na {name}" +"Grab bag" = "Směs" +"Gradient" = "Barevný přechod" +"Gray" = "Šedá" +"Green" = "Zelená" +"Greyscale components and two example screens to sketch a flow with" = "Komponenty v odstínech šedi a dvě ukázkové obrazovky pro náčrt toku" +"Grid" = "Mřížka" +"Grid ({size})" = "Mřížka ({size})" +"Grid styles" = "Styly mřížek" +"Grid {size}" = "Mřížka {size}" +"Group" = "Skupina" +"Group selection" = "Seskupit výběr" +"Groups" = "Skupiny" +"Gutter" = "Mezera" +"Hand tool" = "Nástroj ruka" +"Hanging list" = "Předsazený seznam" +"Hanging punctuation" = "Předsazená interpunkce" +"Hard light" = "Tvrdé světlo" +"Height" = "Výška" +"Help" = "Nápověda" +"Help and resources" = "Nápověda a zdroje" +"Help page" = "Stránka nápovědy" +"Here: {names}" = "Zde: {names}" +"Hidden" = "Skryté" +"Hide contents" = "Skrýt obsah" +"Hide from publishing" = "Skrýt při publikování" +"Hide section contents" = "Skrýt obsah sekce" +"Hide these tips (Getting started in the Help menu shows them again)" = "Skrýt tyto tipy (znovu je zobrazíte volbou Začínáme v nabídce Nápověda)" +"Hide when publishing" = "Skrýt při publikování" +"Highlight layers on hover" = "Zvýraznit vrstvy pod ukazatelem" +"Highlights" = "Světla" +"Hold Alt" = "Podržte Alt" +"Hold Alt+Z" = "Podržte Alt+Z" +"Hold Ctrl" = "Podržte Ctrl" +"Hold Z" = "Podržte Z" +"Hold ⌃ or ⌘" = "Podržte ⌃ nebo ⌘" +"Hold ⌥" = "Podržte ⌥" +"Hold ⌥Z" = "Podržte ⌥Z" +"Home" = "Domů" +"Horizontal" = "Vodorovně" +"Horizontal align" = "Vodorovné zarovnání" +"Horizontal hex" = "Vodorovný šestiúhelník" +"Horizontal padding" = "Vodorovné vnitřní odsazení" +"Horizontal spacing" = "Vodorovné rozestupy" +"Hosted by {operator}" = "Provozuje {operator}" +"Hover a layer to see its properties" = "Najeďte na vrstvu a zobrazte její vlastnosti" +"Hover other layers to measure the distance" = "Najeďte na jiné vrstvy a změřte vzdálenost" +"Hue" = "Odstín" +"Hug" = "Podle obsahu" +"Hug contents" = "Podle obsahu" +"Icon components on a 24 px grid with keylines and a size preview" = "Komponenty ikon na mřížce 24 px s pomocnými liniemi a náhledem velikosti" +"Icon set starter" = "Základ sady ikon" +"If" = "Pokud" +"If {condition}" = "Pokud {condition}" +"Ignore auto layout (absolute position)" = "Ignorovat automatické rozvržení (absolutní poloha)" +"Image" = "Obrázek" +"Import" = "Import" +"Import path" = "Cesta importu" +"Import plugin from manifest" = "Importovat plugin z manifestu" +"Import plugin from manifest…" = "Importovat plugin z manifestu…" +"Import variables" = "Importovat proměnné" +"Imported {name}" = "Importováno: {name}" +"Imported {n} new variables, updated {updated}" = { one = "Importována {n} nová proměnná, aktualizováno: {updated}", other = "Nově importované proměnné: {n}, aktualizované: {updated}" } +"Import…" = "Importovat…" +"In an instance, this comes from the main component" = "V instanci se toto přebírá z hlavní komponenty" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "V prohlížeči se můžete připojit k relacím hostovaným desktopovou aplikací a k souborům na serveru FigCraft. Stránka otevřená přes https může přistupovat k jiným počítačům pouze přes odkazy wss:// (server za TLS)." +"Include bounding box" = "Zahrnout ohraničující rámeček" +"Include id attribute" = "Zahrnout atribut id" +"Included" = "Zahrnuto" +"Included in layout" = "Zahrnuto do rozvržení" +"Individual corners" = "Jednotlivé rohy" +"Individual strokes" = "Jednotlivé tahy" +"Ink" = "Inkoust" +"Inner shadow" = "Vnitřní stín" +"Inside" = "Uvnitř" +"Inspect" = "Inspekce" +"Inspect the layer under the pointer: size, colours, typography" = "Prozkoumat vrstvu pod ukazatelem: velikost, barvy, typografii" +"Install plugin" = "Nainstalovat plugin" +"Install" = "Nainstalovat" +"Installed {name}" = "Nainstalováno: {name}" +"Installed" = "Nainstalované" +"Installing…" = "Instalace…" +"Instance" = "Instance" +"Instance properties" = "Vlastnosti instance" +"Instance swap" = "Výměna instance" +"Instance swap (no other component)" = "Výměna instance (žádná jiná komponenta)" +"Instances" = "Instance" +"Instant" = "Okamžitě" +"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." +"Intersect" = "Průnik" +"Intersects" = "Průniky" +"Invite link" = "Odkaz s pozvánkou" +"Invite link copied" = "Odkaz s pozvánkou zkopírován" +"It asks permission to:" = "Žádá o oprávnění:" +"Jan" = "ledna" +"Join" = "Připojit se" +"Join code copied" = "Kód pro připojení zkopírován" +"Join code or invite link" = "Kód pro připojení nebo odkaz s pozvánkou" +"Join live session" = "Připojit se k živé relaci" +"Join us on Discord" = "Připojte se k nám na platformě Discord" +"Join with a code" = "Připojit se pomocí kódu" +"Joined" = "Připojeno" +"Joined {file}" = "Připojeno k souboru {file}" +"Joining…" = "Připojování…" +"Join|stroke" = "Spojení" +"Jul" = "července" +"Jun" = "června" +"Just now" = "Právě teď" +"Justify" = "Do bloku" +"Keep appearance" = "Zachovat vzhled" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Zachovat vzhled převede všechny hodnoty barev do profilu {profile}, aby barvy vypadaly stejně. Zachovat hodnoty ponechá čísla beze změny, takže se barvy posunou." +"Keep tool selected after use" = "Po použití ponechat nástroj vybraný" +"Keep values" = "Zachovat hodnoty" +"Key/Gamepad" = "Klávesa/ovladač" +"Key/Gamepad {key}" = "Klávesa/ovladač {key}" +"Keyboard shortcuts" = "Klávesové zkratky" +"Kits" = "Sady" +"Landing page" = "Vstupní stránka" +"Language" = "Jazyk" +"Lasso" = "Laso" +"Last" = "Poslední" +"Last change: {note}" = "Poslední změna: {note}" +"Last commit" = "Poslední commit" +"Last modified" = "Poslední úprava" +"Last on top" = "Poslední nahoře" +"Layer blur" = "Rozostření vrstvy" +"Layer name" = "Název vrstvy" +"Layers" = "Vrstvy" +"Layers in an instance take their position from the main component" = "Vrstvy v instanci přebírají polohu z hlavní komponenty" +"Layout" = "Rozvržení" +"Layout grid" = "Mřížka rozvržení" +"Layout grid settings" = "Nastavení mřížky rozvržení" +"Layout grids" = "Mřížky rozvržení" +"Leave session" = "Opustit relaci" +"Leaves" = "Listy" +"Left" = "Vlevo" +"Left & right" = "Vlevo a vpravo" +"Left side" = "Levá strana" +"Left side width" = "Šířka levé strany" +"Letter case" = "Velikost písmen" +"Letter spacing" = "Prostrkání znaků" +"Libraries" = "Knihovny" +"Libraries in this file" = "Knihovny v tomto souboru" +"Library updates available" = "Jsou dostupné aktualizace knihoven" +"Library: click to collapse" = "Knihovna: kliknutím sbalíte" +"Library: click to expand" = "Knihovna: kliknutím rozbalíte" +"Library: its variables are read-only here; edit them in the library file and publish it" = "Knihovna: její proměnné jsou zde jen pro čtení; upravte je v souboru knihovny a publikujte ho" +"Light" = "Světlý" +"Lighten" = "Zesvětlit" +"Light|glass" = "Světlo" +"Line" = "Čára" +"Line arrow" = "Šipka z čar" +"Line delta" = "Změna počtu řádků" +"Line height" = "Výška řádku" +"Linear" = "Lineární" +"Lines" = "Čáry" +"Lines +/−" = "Řádky +/−" +"Lines added" = "Přidané řádky" +"Lines deleted" = "Smazané řádky" +"Link" = "Odkaz" +"Link copied" = "Odkaz zkopírován" +"Link docs, code or tickets for developers." = "Připojte odkazy na dokumentaci, kód nebo úkoly pro vývojáře." +"Link frames on the Prototype tab, then present the flow" = "Propojte rámce na kartě Prototyp a pak spusťte prezentaci toku" +"Link this component to its code component: instances then show your real snippet with their values." = "Propojte tuto komponentu s její komponentou v kódu: instance pak zobrazí váš skutečný úryvek kódu se svými hodnotami." +"List spacing" = "Rozestupy seznamu" +"List style" = "Styl seznamu" +"Live session" = "Živá relace" +"Live · {people}" = "Živě · {people}" +"Local components" = "Místní komponenty" +"Local network" = "Místní síť" +"Local styles" = "Místní styly" +"Local variables" = "Místní proměnné" +"Lock/Unlock" = "Zamknout/Odemknout" +"Locked" = "Zamknuto" +"Lowercase" = "Malá písmena" +"Luminance" = "Jas" +"Luminosity" = "Světlost" +"Main" = "Hlavní soubor" +"Main component deleted" = "Hlavní komponenta smazána" +"Main menu" = "Hlavní nabídka" +"Make a component" = "Vytvořit komponentu" +"Make slot" = "Vytvořit slot" +"Mar" = "března" +"Margin" = "Vnější okraj" +"Mark all as read" = "Označit vše jako přečtené" +"Mark as resolved" = "Označit jako vyřešené" +"Mark as unread" = "Označit jako nepřečtené" +"Mark as unresolved" = "Označit jako nevyřešené" +"Mask" = "Maska" +"Mass · Stiffness · Damping" = "Hmotnost · Tuhost · Tlumení" +"Match (empty: the whole name)" = "Hledaný text (prázdné: celý název)" +"Max H" = "Max. V" +"Max W" = "Max. Š" +"Max lines" = "Max. řádků" +"Maximize" = "Maximalizovat" +"May" = "května" +"Measure to the selection" = "Měřit k výběru" +"Merge into main" = "Sloučit do hlavního souboru" +"Merge regions" = "Sloučit oblasti" +"Merge the branch into the main file (one undo step) and archive it" = "Sloučit větev do hlavního souboru (jeden krok zpět) a archivovat ji" +"Merged" = "Sloučeno" +"Merged PRs" = "Sloučené PR" +"Min H" = "Min. V" +"Min W" = "Min. Š" +"Minimize" = "Minimalizovat" +"Mirror angle" = "Zrcadlit úhel" +"Mirror angle and length" = "Zrcadlit úhel a délku" +"Mirroring" = "Zrcadlení" +"Missing" = "Chybí" +"Missing layer" = "Chybějící vrstva" +"Missing style" = "Chybějící styl" +"Missing variable" = "Chybějící proměnná" +"Miter" = "Ostrý" +"Miter angle" = "Úhel ostrého spoje" +"Mixed" = "Smíšené" +"Mixed fonts" = "Smíšená písma" +"Mixed interactions: click + to replace them" = "Smíšené interakce: kliknutím na + je nahradíte" +"Mobile app" = "Mobilní aplikace" +"Mode" = "Režim" +"Model" = "Model" +"Models" = "Modely" +"Mono" = "Jednobarevné" +"More" = "Další" +"More instance actions" = "Další akce instance" +"Mouse down" = "Stisk myši" +"Mouse enter" = "Vstup ukazatele" +"Mouse leave" = "Odchod ukazatele" +"Mouse up" = "Uvolnění myši" +"Move along the toolbar or the layer rows" = "Pohyb po panelu nástrojů nebo řádcích vrstev" +"Move" = "Přesunout" +"Move column left" = "Přesunout sloupec vlevo" +"Move column right" = "Přesunout sloupec vpravo" +"Move down" = "Posunout dolů" +"Move in" = "Přesunout dovnitř" +"Move out" = "Přesunout ven" +"Move tools" = "Nástroje přesunu" +"Move up" = "Posunout nahoru" +"Moved to {name}" = "Přesunuto do {name}" +"Multi" = "Vícebarevné" +"Multiply" = "Násobit" +"Muted" = "Tlumené" +"Name" = "Název" +"Name (A–Z)" = "Název (A–Z)" +"Named instance" = "Pojmenovaná instance" +"Navigate to" = "Přejít na" +"Nested instance" = "Vnořená instance" +"New design file" = "Nový soubor návrhu" +"Newest first" = "Od nejnovějších" +"Next or previous control in the focused region" = "Další nebo předchozí ovládací prvek v zaměřené oblasti" +"Next ready for dev" = "Další připravené pro vývoj" +"Next size option" = "Další možnost velikosti" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "Žádné zdroje: chybí nastavení exportu, ikony i obrázky. Přidejte nastavení exportu na kartě Návrh." +"No boolean variables fit here." = "Žádné vhodné logické proměnné." +"No boolean variables yet. Create them in Local variables." = "Zatím žádné logické proměnné. Vytvořte je v Místních proměnných." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Žádné větve. Větev je kopie souboru pro vyzkoušení změn, které pak zkontrolujete a sloučíte." +"No category" = "Bez kategorie" +"No changes" = "Žádné změny" +"No colour variables fit here." = "Žádné vhodné barevné proměnné." +"No colour variables yet. Create them in Local variables." = "Zatím žádné barevné proměnné. Vytvořte je v Místních proměnných." +"No comments yet" = "Zatím žádné komentáře" +"No components yet. Select layers and press {keys}." = "Zatím žádné komponenty. Vyberte vrstvy a stiskněte {keys}." +"No conflicts." = "Žádné konflikty." +"No contributor data was built into this copy." = "Toto sestavení neobsahuje údaje o přispěvatelích." +"No fonts match" = "Žádná odpovídající písma" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Kód pro připojení není dostupný (server kódy nepodporuje nebo tuto adresu nelze do kódu vložit): sdílejte odkaz." +"No layer changes to show." = "Žádné změny vrstev k zobrazení." +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Zatím žádné knihovny. Přidejte soubor .figcraft publikovaný jako knihovna nebo vložte odkaz na týmovou knihovnu." +"No list" = "Bez seznamu" +"No matching comments" = "Žádné odpovídající komentáře" +"No matching components" = "Žádné odpovídající komponenty" +"No matching layers" = "Žádné odpovídající vrstvy" +"No matching styles" = "Žádné odpovídající styly" +"No matching variables" = "Žádné odpovídající proměnné" +"No mirroring" = "Bez zrcadlení" +"No model credits were built into this copy." = "Toto sestavení neobsahuje údaje o podílu modelů." +"No number variables fit here." = "Žádné vhodné číselné proměnné." +"No number variables yet. Create them in Local variables." = "Zatím žádné číselné proměnné. Vytvořte je v Místních proměnných." +"No paints" = "Žádné výplně" +"No plugins on {server}." = "Na serveru {server} nejsou žádné pluginy." +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Zatím žádné pluginy. Importujte vývojový plugin z jeho souboru figcraft-plugin.json." +"No preferred components" = "Žádné preferované komponenty" +"No preset" = "Bez předvolby" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "Zatím žádné uložené verze. Uložte verzi jako bod práce, ke kterému se můžete vrátit." +"No scrolling" = "Bez posouvání" +"No shortcuts match" = "Žádné odpovídající zkratky" +"No status" = "Bez stavu" +"No string variables fit here." = "Žádné vhodné textové proměnné." +"No string variables yet. Create them in Local variables." = "Zatím žádné textové proměnné. Vytvořte je v Místních proměnných." +"No styles yet." = "Zatím žádné styly." +"No styles yet. Click + to create one from the selection." = "Zatím žádné styly. Kliknutím na + vytvoříte styl z výběru." +"No templates match your search" = "Žádné šablony neodpovídají hledání" +"No text styles yet. Click + to create one from the selected text." = "Zatím žádné textové styly. Kliknutím na + vytvoříte styl z vybraného textu." +"No variables fit here." = "Žádné vhodné proměnné." +"No variables yet. Create them in Local variables." = "Zatím žádné proměnné. Vytvořte je v Místních proměnných." +"Nobody else is here yet" = "Zatím tu není nikdo další" +"Noise" = "Šum" +"None" = "Žádné" +"None." = "Žádné." +"Normal" = "Normální" +"Not bound" = "Nenavázáno" +"Not in a live session" = "Mimo živou relaci" +"Not in this font" = "Není v tomto písmu" +"Not properties, kept as written: {names}" = "Nejde o vlastnosti, ponecháno beze změny: {names}" +"Not read yet" = "Dosud nepřečteno" +"Not shared" = "Nesdíleno" +"Note {n}" = "Poznámka {n}" +"Nothing changed since this was marked ready for dev." = "Od označení jako připravené pro vývoj se nic nezměnilo." +"Nothing here yet. Set a frame's status to list it." = "Zatím nic. Pro zobrazení rámce v seznamu nastavte jeho stav." +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Zatím nic online. Nahrajte soubor online pomocí Sdílet a zobrazí se zde." +"Nothing to export" = "Není co exportovat" +"Nov" = "listopadu" +"Now" = "Nyní" +"Nudge (Shift: big nudge)" = "Posunout (Shift: větší posun)" +"Nudge amount" = "Velikost posunu" +"Number" = "Číslo" +"Number ↑" = "Číslo ↑" +"Number ↓" = "Číslo ↓" +"Numbered list" = "Číslovaný seznam" +"Object" = "Objekt" +"Oct" = "října" +"Off" = "Vypnuto" +"Offered first when filling the slot (any content is allowed)" = "Nabídne se jako první při vyplňování slotu (povolen je libovolný obsah)" +"Offered first when swapping the instance (any component is allowed)" = "Nabídne se jako první při výměně instance (povolena je libovolná komponenta)" +"Offset" = "Posun" +"Oldest first" = "Od nejstarších" +"On click" = "Při kliknutí" +"On drag" = "Při tažení" +"On this page" = "Na této stránce" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Jedno oznámení v pěti běžných velikostech: čtverec, na výšku, příběh, odkaz a banner" +"Online" = "Online" +"Online files" = "Online soubory" +"Online sharing is off: files are shared on the local network only." = "Online sdílení je vypnuté: soubory se sdílejí pouze v místní síti." +"Only the first changed layers are listed." = "Zobrazeny jsou pouze první změněné vrstvy." +"Only the first {n} changed layers are listed." = "Zobrazeno je pouze prvních {n} změněných vrstev." +"Only your threads" = "Pouze vaše vlákna" +"Opacity" = "Krytí" +"Opacity & blend" = "Krytí a prolnutí" +"Opacity 10%…90%, 0: 100%" = "Krytí 10 %…90 %, 0: 100 %" +"Open" = "Otevřít" +"Open .figcraft, .svg or images" = "Otevřít .figcraft, .svg nebo obrázky" +"Open branch" = "Otevřít větev" +"Open file" = "Otevřít soubor" +"Open files" = "Otevřít soubory" +"Open in focus view" = "Otevřít v zaměřeném pohledu" +"Open in new window" = "Otevřít v novém okně" +"Open invite links in FigCraft" = "Otevírat odkazy s pozvánkou v aplikaci FigCraft" +"Open link" = "Otevřít odkaz" +"Open link {url}" = "Otevřít odkaz {url}" +"Open overlay" = "Otevřít překryv" +"Open prototype preview" = "Otevřít náhled prototypu" +"Open variables ({count})" = "Otevřít proměnné ({count})" +"Opened “{name}” in a new tab" = "Soubor „{name}“ otevřen v nové kartě" +"Open|comments" = "Otevřené" +"Options" = "Možnosti" +"Orange" = "Oranžová" +"Order" = "Pořadí" +"Other apps may not open every .fig file exported here." = "Jiné aplikace nemusí otevřít všechny zde exportované soubory .fig." +"Others lose access and keep their copy; the file stays on the server for you" = "Ostatní ztratí přístup a ponechají si svou kopii; soubor pro vás zůstane na serveru" +"Others type this under Share ▸ Join with a code" = "Ostatní tento kód zadají v nabídce Sdílet ▸ Připojit se pomocí kódu" +"Outline stroke" = "Převést tah na obrysy" +"Outline text" = "Převést text na obrysy" +"Outlines" = "Obrysy" +"Outside" = "Vně" +"Overflow" = "Přetečení" +"Overlay" = "Překryv" +"Overlay position" = "Poloha překryvu" +"Overridden here (the collection it extends has another value)" = "Zde přepsáno (rozšiřovaná kolekce má jinou hodnotu)" +"Overrides" = "Přepsání" +"Owner token" = "Token vlastníka" +"Owner token copied" = "Token vlastníka zkopírován" +"PDF print options" = "Možnosti tisku PDF" +"PDF print settings" = "Nastavení tisku PDF" +"Padding" = "Vnitřní odsazení" +"Page" = "Stránka" +"Pages" = "Stránky" +"Pages: {pages}" = "Stránky: {pages}" +"Paint bucket" = "Plechovka barvy" +"Pan the canvas" = "Posunout plátno" +"Pan with nothing selected (Shift: farther)" = "Posun bez výběru (Shift: dále)" +"Panels" = "Panely" +"Paper" = "Papír" +"Paragraph indent" = "Odsazení odstavce" +"Paragraph indent applies to left-aligned text" = "Odsazení odstavce se vztahuje na text zarovnaný vlevo" +"Paragraph spacing" = "Mezery mezi odstavci" +"Pass through" = "Průchozí" +"Paste a link (https://…)" = "Vložte odkaz (https://…)" +"Paste a team library link (a view link)" = "Vložte odkaz na týmovou knihovnu (odkaz pro prohlížení)" +"Paste here" = "Vložit sem" +"Paste to replace" = "Vložit a nahradit" +"Pattern" = "Vzor" +"Pattern alignment" = "Zarovnání vzoru" +"Pattern scale" = "Měřítko vzoru" +"Pattern source" = "Zdroj vzoru" +"Pen" = "Pero" +"Pencil" = "Tužka" +"People in this file" = "Lidé v tomto souboru" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "Lidé se připojí odkudkoli pomocí kódu. Po dobu sdílení je soubor uložen na serveru {server}." +"People on your network join with a code. Nothing goes through a server." = "Lidé ve vaší síti se připojí pomocí kódu. Nic neprochází přes server." +"Phone" = "Telefon" +"Pick color" = "Vybrat barvu" +"Pick color (I)" = "Vybrat barvu (I)" +"Pick one font family first" = "Nejprve vyberte jednu rodinu písma" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Vyberte nástroj Rámec a pak táhněte na plátně nebo zvolte velikost zařízení" +"Pin a property" = "Připnout vlastnost" +"Pinch" = "Sevření" +"Pixel grid" = "Pixelová mřížka" +"Pixel preview 1x" = "Pixelový náhled 1x" +"Pixel preview 2x" = "Pixelový náhled 2x" +"Place a shape or frame (its tool chosen)" = "Umístit tvar nebo rámec (s vybraným nástrojem)" +"Place automatically" = "Umístit automaticky" +"Place image" = "Umístit obrázek" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Pluginy běží v izolovaném prostředí aplikace FigCraft bez přístupu k souborům, síti nebo hodinám. Instalujte pouze pluginy, kterým důvěřujete." +"Plugins" = "Pluginy" +"Plus darker" = "Sčítání se ztmavením" +"Plus lighter" = "Sčítání se zesvětlením" +"Point" = "Bod" +"Points" = "Body" +"Polygon" = "Mnohoúhelník" +"Polygons" = "Mnohoúhelníky" +"Port" = "Port" +"Position" = "Poloha" +"Post" = "Odeslat" +"Preferences" = "Předvolby" +"Preferred" = "Preferované" +"Present" = "Prezentovat" +"Present or preview" = "Prezentovat nebo zobrazit náhled" +"Present this flow" = "Prezentovat tento tok" +"Presentation" = "Prezentace" +"Presentation slides" = "Snímky prezentace" +"Presenting: comments" = "Prezentace: komentáře" +"Presenting: cycle the size options" = "Prezentace: přepnout možnosti velikosti" +"Presenting: full screen" = "Prezentace: celá obrazovka" +"Presenting: next frame" = "Prezentace: další rámec" +"Presenting: previous frame" = "Prezentace: předchozí rámec" +"Presenting: restart" = "Prezentace: znovu spustit" +"Preserve scroll position" = "Zachovat pozici posouvání" +"Presets" = "Předvolby" +"Press a key…" = "Stiskněte klávesu…" +"Playground" = "Zkušební prostředí" +"Preview" = "Náhled" +"Preview this flow" = "Náhled tohoto toku" +"Preview this frame" = "Náhled tohoto rámce" +"Previous ready for dev" = "Předchozí připravené pro vývoj" +"Progressive" = "Postupné" +"Properties" = "Vlastnosti" +"Prototype" = "Prototyp" +"Prototype preview" = "Náhled prototypu" +"Prototype settings" = "Nastavení prototypu" +"Publish" = "Publikovat" +"Publish changes" = "Publikovat změny" +"Publish to another server…" = "Publikovat na jiný server…" +"Publish to server" = "Publikovat na server" +"Published version {v}" = "Publikovaná verze {v}" +"Published version {v} to the server" = "Verze {v} publikována na server" +"Published version {v}: save the file so other files can use it" = "Publikována verze {v}: uložte soubor, aby ji mohly používat ostatní soubory" +"Published · {people}" = "Publikováno · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publikování na server vyžaduje desktopovou aplikaci; v prohlížeči se můžete připojit k souborům na serveru" +"Publishing with the server's key." = "Publikování s klíčem serveru." +"Publishing…" = "Publikování…" +"Push" = "Vytlačení" +"Push changes to main component" = "Přenést změny do hlavní komponenty" +"Put text on path" = "Umístit text na cestu" +"R" = "R" +"Radial" = "Kruhový" +"Radius" = "Poloměr" +"Ratio" = "Poměr" +"Reading the library…" = "Načítání knihovny…" +"Ready for dev" = "Připraveno pro vývoj" +"Real name" = "Skutečné jméno" +"Recent" = "Nedávné" +"Recent files" = "Nedávné soubory" +"Recents" = "Nedávné" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "Po neočekávaném ukončení aplikace obnoven {n} neuložený soubor: uložte ho pro zachování", other = "Po neočekávaném ukončení aplikace obnoveny neuložené soubory (počet: {n}): uložte je pro zachování" } +"Rectangle" = "Obdélník" +"Rectangles" = "Obdélníky" +"Red" = "Červená" +"Reduce motion" = "Omezit pohyb" +"Refraction" = "Lom světla" +"Regular expression" = "Regulární výraz" +"Remove" = "Odebrat" +"Remove all" = "Odebrat vše" +"Remove annotation" = "Odebrat anotaci" +"Remove auto layout" = "Odebrat automatické rozvržení" +"Remove dev resource" = "Odebrat zdroj pro vývojáře" +"Remove export setting" = "Odebrat nastavení exportu" +"Remove from recents" = "Odebrat z nedávných" +"Remove from server" = "Odebrat ze serveru" +"Remove from server…" = "Odebrat ze serveru…" +"Remove guide" = "Odebrat vodítko" +"Remove interaction" = "Odebrat interakci" +"Remove library from this file" = "Odebrat knihovnu z tohoto souboru" +"Remove min and max" = "Odebrat minimum a maximum" +"Remove min and max height" = "Odebrat minimální a maximální výšku" +"Remove min and max width" = "Odebrat minimální a maximální šířku" +"Remove regions" = "Odebrat oblasti" +"Remove slot" = "Odebrat slot" +"Remove starting point" = "Odebrat počáteční bod" +"Remove stop" = "Odebrat zarážku" +"Remove text from path" = "Odebrat text z cesty" +"Remove the else and its actions" = "Odebrat větev Jinak a její akce" +"Remove this condition and its actions" = "Odebrat tuto podmínku a její akce" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Odebrat tento soubor ze serveru? Již odtud nepůjde sdílet ani otevřít. Vaše kopie zde zůstane otevřená." +"Remove {name} from {server}?" = "Odebrat {name} ze serveru {server}?" +"Removed" = "Odebráno" +"Removed from the server" = "Odebráno ze serveru" +"Removed since it was marked" = "Odebráno od označení" +"Rename" = "Přejmenovat" +"Rename duplicated layers" = "Přejmenovávat duplikované vrstvy" +"Rename folder" = "Přejmenovat složku" +"Rename group" = "Přejmenovat skupinu" +"Rename layers" = "Přejmenovat vrstvy" +"Rename mode" = "Přejmenovat režim" +"Rename on the server" = "Přejmenovat na serveru" +"Rename on the server…" = "Přejmenovat na serveru…" +"Rename page" = "Přejmenovat stránku" +"Rename to" = "Přejmenovat na" +"Replace all" = "Nahradit vše" +"Replace mixed export settings" = "Nahradit smíšená nastavení exportu" +"Replace with" = "Nahradit za" +"Replace with a new interaction" = "Nahradit novou interakcí" +"Reply" = "Odpovědět" +"Report" = "Nahlásit" +"Report this file…" = "Nahlásit tento soubor…" +"Reset" = "Obnovit výchozí" +"Reset adjustments" = "Obnovit úpravy" +"Reset all changes" = "Vrátit všechny změny" +"Reset all overrides on this layer" = "Zrušit všechna přepsání této vrstvy" +"Reset overrides" = "Zrušit přepsání" +"Reset slot" = "Obnovit slot" +"Reset this override" = "Zrušit toto přepsání" +"Reset to style" = "Obnovit podle stylu" +"Reset to the inherited value" = "Obnovit zděděnou hodnotu" +"Resize by the nudge amount" = "Změnit velikost o velikost posunu" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Přizpůsobit velikost rámců obrazovce zařízení (nebo oknu bez zařízení): omezení a automatické rozvržení se přizpůsobí" +"Resize to fit" = "Přizpůsobit velikost obsahu" +"Resolve" = "Vyřešit" +"Resolve change" = "Vyřešit změnu" +"Resolved" = "Vyřešené" +"Resources" = "Zdroje" +"Responsive" = "Responzivní" +"Restart" = "Znovu spustit" +"Restore" = "Obnovit" +"Restore main component" = "Obnovit hlavní komponentu" +"Restore this version" = "Obnovit tuto verzi" +"Restored “{name}”" = "Obnoveno: „{name}“" +"Reverse the order" = "Obrátit pořadí" +"Reversed triangle" = "Obrácený trojúhelník" +"Review and merge" = "Zkontrolovat a sloučit" +"Ribbon" = "Stuha" +"Right" = "Vpravo" +"Right side" = "Pravá strana" +"Right side width" = "Šířka pravé strany" +"Rotate 90°" = "Otočit o 90°" +"Rotate 90° right" = "Otočit o 90° doprava" +"Rotate to landscape" = "Otočit na šířku" +"Rotate to portrait" = "Otočit na výšku" +"Rotation" = "Natočení" +"Rotation jitter" = "Náhodná odchylka natočení" +"Round" = "Kulatý" +"Rounded" = "Zaoblený" +"Row" = "Řádek" +"Row gap" = "Mezera mezi řádky" +"Row sizes" = "Velikosti řádků" +"Row span" = "Rozpětí přes řádky" +"Rows" = "Řádky" +"Rows {count}" = "Řádky: {count}" +"Rulers" = "Pravítka" +"Run" = "Spustit" +"Run {label}" = "Spustit {label}" +"Search templates" = "Hledat šablony" +"See all templates" = "Zobrazit všechny šablony" +"Select layers and they space themselves as their content changes" = "Vyberte vrstvy a jejich rozestupy se přizpůsobí změnám obsahu" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "Stejné jako vrstva" +"Saturation" = "Sytost" +"Save" = "Uložit" +"Save as" = "Uložit jako" +"Save as preset…" = "Uložit jako předvolbu…" +"Save version {key}" = "Uložit verzi {key}" +"Saved" = "Uloženo" +"Saved {name}" = "Uloženo: {name}" +"Say something" = "Napište něco" +"Scale" = "Měřítko" +"Scatter" = "Rozptyl" +"Scoping: where pickers offer it" = "Rozsah použití: kde se nabízí ve výběru" +"Screen" = "Závoj" +"Scroll behavior" = "Chování při posouvání" +"Scroll to" = "Posunout na" +"Scroll to layer" = "Posunout na vrstvu" +"Scroll with parent" = "Posouvat s nadřazeným prvkem" +"Search plugins" = "Hledat pluginy" +"Search" = "Hledat" +"Search actions, layers and tools" = "Hledat akce, vrstvy a nástroje" +"Search assets" = "Hledat zdroje" +"Search comments" = "Hledat komentáře" +"Search fonts" = "Hledat písma" +"Search layers" = "Hledat vrstvy" +"Search shortcuts" = "Hledat zkratky" +"Section" = "Sekce" +"Section color" = "Barva sekce" +"Sections" = "Sekce" +"Select a frame and add a starting point to present it as a flow." = "Vyberte rámec a přidejte počáteční bod pro prezentaci jako tok." +"Select a frame to preview it" = "Vyberte rámec pro náhled" +"Select a layer to see its properties and code" = "Vyberte vrstvu a zobrazte její vlastnosti a kód" +"Select all" = "Vybrat vše" +"Select all with same" = "Vybrat vše se stejným" +"Select layer" = "Vybrat vrstvu" +"Select layers to apply the style to" = "Vyberte vrstvy, na které chcete použít styl" +"Select overlapping shapes" = "Vyberte překrývající se tvary" +"Select points to edit their position, radius and mirroring" = "Vyberte body a upravte jejich polohu, poloměr a zrcadlení" +"Select slot" = "Vybrat slot" +"Selection" = "Výběr" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Rámeček výběru, {w} × {h} na pozici {x}, {y}: šipky posouvají, Shift+šipky nebo {cmd}+šipky mění velikost, Enter vybere, Escape zruší" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Rámeček výběru: šipky posouvají, Shift+šipky nebo {cmd}+šipky mění velikost, Enter vybere, Escape zruší" +"Send backward" = "Přesunout o úroveň níž" +"Send report" = "Odeslat hlášení" +"Send to back" = "Přesunout do pozadí" +"Sep" = "září" +"Server address (ws://host:47900/)" = "Adresa serveru (ws://host:47900/)" +"Server address" = "Adresa serveru" +"Server for online sharing" = "Server pro online sdílení" +"Set as default" = "Nastavit jako výchozí" +"Set variable" = "Nastavit proměnnou" +"Set variable mode" = "Nastavit režim proměnných" +"Set variable {name}" = "Nastavit proměnnou {name}" +"Set {name} to {value}" = "Nastavit {name} na {value}" +"Shadows" = "Stíny" +"Shape" = "Tvar" +"Shape builder" = "Tvorba tvarů" +"Shape tools" = "Nástroje tvarů" +"Share" = "Sdílet" +"Share this file" = "Sdílet tento soubor" +"Share with a code" = "Sdílet pomocí kódu" +"Share: live session, export" = "Sdílet: živá relace, export" +"Shared" = "Sdíleno" +"Shared: copy its code from the file's menu" = "Sdíleno: zkopírujte kód z nabídky souboru" +"Sharing a file needs the desktop app; here you can join one." = "Sdílení souboru vyžaduje desktopovou aplikaci; zde se můžete k souboru připojit." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Sdílení v místní síti (online sdílení je vypnuté: v předvolbách není nastaven server)." +"Sharing on your network · {people}" = "Sdílení ve vaší síti · {people}" +"Sharing online · {people}" = "Sdílení online · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "Sdílení zastaveno: soubor zůstává na serveru, přístupný pouze vám" +"Show" = "Zobrazit" +"Show all templates" = "Zobrazit všechny šablony" +"Show as grid" = "Zobrazit jako mřížku" +"Show as list" = "Zobrazit jako seznam" +"Show autosaves" = "Zobrazit automaticky uložené verze" +"Show behind transparent areas" = "Zobrazit za průhlednými oblastmi" +"Show comments (Shift+C)" = "Zobrazit komentáře (Shift+C)" +"Show device frame" = "Zobrazit rámeček zařízení" +"Show dimensions on objects" = "Zobrazit rozměry objektů" +"Show hotspot hints" = "Zobrazit nápovědu interaktivních oblastí" +"Show in Explorer" = "Zobrazit v Průzkumníku" +"Show in Finder" = "Zobrazit v aplikaci Finder" +"Show in folder" = "Zobrazit ve složce" +"Show only this on the canvas" = "Zobrazit na plátně pouze toto" +"Show open" = "Zobrazit otevřené" +"Show section contents" = "Zobrazit obsah sekce" +"Show this layer" = "Zobrazit tuto vrstvu" +"Show usage" = "Zobrazit použití" +"Show/Hide" = "Zobrazit/Skrýt" +"Show/Hide UI" = "Zobrazit/Skrýt rozhraní" +"Side by side" = "Vedle sebe" +"Sidebar" = "Postranní panel" +"Simplify stroke" = "Zjednodušit tah" +"Size" = "Velikost" +"Size jitter" = "Náhodná odchylka velikosti" +"Skip ink" = "Vynechat tahy písmen" +"Slashed zero" = "Přeškrtnutá nula" +"Slice" = "Výřez" +"Slices" = "Výřezy" +"Slide in" = "Zasunutí" +"Slide out" = "Vysunutí" +"Slot" = "Slot" +"Slot content" = "Obsah slotu" +"Small capitals" = "Kapitálky" +"Small caps" = "Kapitálky" +"Smart animate" = "Chytrá animace" +"Snap to pixel grid" = "Přichytávat k pixelové mřížce" +"Social and slides" = "Sociální sítě a snímky" +"Social media" = "Sociální sítě" +"Social post sizes" = "Velikosti příspěvků na sociální sítě" +"Soft light" = "Měkké světlo" +"Solid" = "Plná barva" +"Some variants have the same property values: give each one a unique combination" = "Některé varianty mají stejné hodnoty vlastností: dejte každé jedinečnou kombinaci" +"Sort" = "Řadit" +"Sort by" = "Řadit podle" +"Sort by date" = "Řadit podle data" +"Sort by page" = "Řadit podle stránky" +"Sort by {column}" = "Řadit podle {column}" +"Space around" = "Mezery kolem" +"Space between" = "Mezery mezi" +"Space drag" = "Mezerník a tažení" +"Space evenly" = "Stejné mezery" +"Space+drag" = "Mezerník+tažení" +"Spacing" = "Rozestupy" +"Spam, stolen work, abuse…" = "Spam, odcizená práce, zneužití…" +"Splay" = "Rozevření" +"Spotlight me" = "Sdílet můj pohled" +"Spread" = "Rozšíření" +"Square" = "Čtverec" +"Standard" = "Standardní" +"Standard ligatures" = "Standardní ligatury" +"Star" = "Hvězda" +"Stars" = "Hvězdy" +"Start" = "Začátek" +"Start ascending from / stop descending at" = "Začít vzestupně od / skončit sestupně na" +"Start from a blank canvas" = "Začít s prázdným plátnem" +"Start from a template" = "Začít ze šablony" +"Start on path" = "Začátek na cestě" +"Start point" = "Počáteční bod" +"Start sharing" = "Zahájit sdílení" +"Start sharing with the Share button, then pass on the code it gives" = "Zahajte sdílení tlačítkem Sdílet a předejte získaný kód" +"Starting to share…" = "Zahajování sdílení…" +"Status" = "Stav" +"Sticky" = "Přichycené" +"Stop" = "Zastavit" +"Stop following {name}" = "Přestat sledovat: {name}" +"Stop session" = "Ukončit relaci" +"Stop sharing" = "Zastavit sdílení" +"Stop spotlighting" = "Ukončit sdílení pohledu" +"Stop {n} {hex}" = "Zarážka {n} {hex}" +"Stretch" = "Roztáhnout" +"Strikethrough" = "Přeškrtnutí" +"String" = "Textový řetězec" +"Stroke" = "Tah" +"Stroke style" = "Styl tahu" +"Stroke weight" = "Tloušťka tahu" +"Strokes" = "Tahy" +"Style" = "Styl" +"Styles" = "Styly" +"Stylistic set 1" = "Stylistická sada 1" +"Stylistic set 2" = "Stylistická sada 2" +"Stylistic set 3" = "Stylistická sada 3" +"Stylistic set 4" = "Stylistická sada 4" +"Stylistic set 5" = "Stylistická sada 5" +"Subtract" = "Odečíst" +"Subtracts" = "Odečtení" +"Suffix" = "Přípona" +"Suspend snapping while dragging" = "Dočasně vypnout přichytávání při tažení" +"Swap instance" = "Vyměnit instanci" +"Swap library: use another library's assets of the same names" = "Vyměnit knihovnu: použít stejně pojmenované zdroje jiné knihovny" +"Swap orientation" = "Změnit orientaci" +"Swap overlay" = "Vyměnit překryv" +"Swap to {name}" = "Vyměnit za {name}" +"Swap width and height" = "Prohodit šířku a výšku" +"Swapped instance" = "Instance vyměněna" +"Swapped {n} assets" = { one = "Vyměněn {n} zdroj", other = "Vyměněné zdroje: {n}" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "Vyměněn {n} zdroj; bez shody: {missed}, nadále používají původní knihovnu", other = "Vyměněné zdroje: {n}; bez shody: {missed}, nadále používají původní knihovnu" } +"Sweep" = "Rozsah" +"System" = "Systém" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "Tabulka" +"Tablet" = "Tablet" +"Tabular numbers" = "Tabulkové číslice" +"Take the values from the first selected layer" = "Převzít hodnoty z první vybrané vrstvy" +"Taper" = "Zúžení" +"Taper both ends" = "Zúžení obou konců" +"Tell the server's operator what is wrong with it" = "Sdělte provozovateli serveru, co je se souborem v nepořádku" +"Temperature" = "Teplota" +"Template" = "Šablona" +"Templates" = "Šablony" +"Terms" = "Podmínky" +"Text" = "Text" +"Text align" = "Zarovnání textu" +"Text content" = "Textový obsah" +"Text style" = "Textový styl" +"Text styles" = "Textové styly" +"Texts" = "Texty" +"Texture" = "Textura" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Plátno zobrazuje barvy v sRGB; exportované soubory PNG, JPG a WebP mají profil Display P3." +"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." = "Soubor se smaže ze serveru: nikdo ho odtud již neotevře ani se k němu nepřipojí a tuto akci nelze vrátit zpět. Otevřené kopie zůstanou těm, kteří je mají." +"The file now holds another library" = "Soubor nyní obsahuje jinou knihovnu" +"The file now holds another library (\"{name}\")" = "Soubor nyní obsahuje jinou knihovnu („{name}“)" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "Soubory sdílené nebo publikované online z tohoto počítače. Zůstanou na svém serveru, dokud je neodeberete." +"The link carries the session's secret: share it only with people you invite." = "Odkaz obsahuje tajný údaj relace: sdílejte ho pouze s pozvanými lidmi." +"The name in your online files and on the server. The document inside keeps its own name." = "Název v online souborech a na serveru. Dokument uvnitř si ponechá vlastní název." +"The selection uses several text styles: click to apply one" = "Výběr používá více textových stylů: kliknutím použijete jeden" +"The server deletes this file if nobody opens it before {date}." = "Server tento soubor smaže, pokud ho nikdo neotevře před {date}." +"The whole file" = "Celý soubor" +"Theme" = "Motiv" +"There's no destination for that action" = "Tato akce nemá cíl" +"Thickness" = "Tloušťka" +"This branch can't be reviewed: {e}" = "Tuto větev nelze zkontrolovat: {e}" +"This branch was merged or archived." = "Tato větev byla sloučena nebo archivována." +"This computer only" = "Pouze tento počítač" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Tento počítač si pamatuje token vlastníka, takže soubor můžete spravovat ze seznamu Online na domovské obrazovce. Zkopírujte ho pro správu souboru odjinud: je to heslo k tomuto souboru a znovu se nezobrazí." +"This condition is edited as an expression" = "Tato podmínka se upravuje jako výraz" +"This file" = "Tento soubor" +"This file has unsaved changes" = "Tento soubor obsahuje neuložené změny" +"This file is on {server}, open only to you. Share it to let others in with a new code." = "Tento soubor je na serveru {server}, přístupný pouze vám. Sdílením umožníte ostatním připojení pomocí nového kódu." +"This frame is new on the branch." = "Tento rámec je ve větvi nový." +"This frame isn't on the branch." = "Tento rámec není ve větvi." +"This server is open to the public: no key needed." = "Tento server je veřejný: klíč není potřeba." +"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Tento server vyžaduje klíč: přidejte do adresy ?key=… (příkaz `figcraft-server key` ho vypíše na počítači serveru)." +"This server takes files only with its key: see Advanced." = "Tento server přijímá soubory pouze s klíčem: viz Pokročilé." +"Tidy up" = "Uspořádat" +"Tile" = "Dlaždice" +"Tile type" = "Typ dlaždic" +"Tint" = "Tónování" +"Title" = "Název" +"Title case" = "Velká počáteční písmena slov" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Chcete-li sdílet tuto knihovnu s týmem, publikujte soubor na server (Sdílet ▸ Publikovat na server…) a pak tam publikujte knihovnu." +"Toggle" = "Přepnout" +"Toggle light/dark theme" = "Přepnout světlý/tmavý motiv" +"Toggle visibility" = "Přepnout viditelnost" +"Tools" = "Nástroje" +"Top" = "Nahoře" +"Top & bottom" = "Nahoře a dole" +"Top left radius" = "Poloměr levého horního rohu" +"Top right radius" = "Poloměr pravého horního rohu" +"Transform" = "Transformace" +"Triangle arrow" = "Trojúhelníková šipka" +"Trigger" = "Spouštěč" +"True" = "Pravda" +"Truncate text" = "Zkrátit text" +"Truncate with an ellipsis" = "Zkrátit výpustkou" +"Try again" = "Zkusit znovu" +"Try another search or filter." = "Zkuste jiné hledání nebo filtr." +"Try another word, or look at every kind." = "Zkuste jiné slovo nebo zobrazte všechny druhy." +"Turn a layer into a component, reuse it, and change every copy at once" = "Převeďte vrstvu na komponentu, opakovaně ji používejte a měňte všechny kopie najednou" +"Two digits" = "Dvě číslice" +"Type" = "Typ" +"Type settings" = "Nastavení písma" +"Type the join code you were given, or paste an invite link." = "Zadejte obdržený kód pro připojení nebo vložte odkaz s pozvánkou." +"Typography" = "Typografie" +"Underline" = "Podtržení" +"Undo takes back only your own changes; what others changed since stays." = "Zpět vrací pouze vaše vlastní změny; pozdější změny ostatních zůstanou." +"Ungroup" = "Zrušit seskupení" +"Uniform" = "Rovnoměrný" +"Union" = "Sjednocení" +"Unions" = "Sjednocení" +"Unknown — treated as sRGB" = "Neznámý — zpracovává se jako sRGB" +"Unread" = "Nepřečtené" +"Unresolve" = "Znovu otevřít" +"Unsaved changes" = "Neuložené změny" +"Unsaved changes in {names}. Close the window and lose them?" = "Neuložené změny v {names}. Zavřít okno a zahodit je?" +"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Neuložené změny: nejprve uložte ({save}) nebo zavřete kartu kliknutím s {alt} a zahoďte je" +"Unsaved changes: save first, or close again with {key} to discard" = "Neuložené změny: nejprve uložte nebo zavřete znovu s {key} a zahoďte je" +"Up" = "Nahoru" +"Update plugin" = "Aktualizovat plugin" +"Update" = "Aktualizovat" +"Update from main" = "Aktualizovat z hlavního souboru" +"Update from selection" = "Aktualizovat z výběru" +"Updated {components} and {styles}" = "Aktualizováno – {components}, {styles}" +"Updated {name}" = "Aktualizováno: {name}" +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Nahraje tento soubor na server FigCraft a udržuje s ním tuto kartu synchronizovanou. Získáte odkaz pro úpravy a odkaz pro prohlížení." +"Uppercase" = "Velká písmena" +"Use a fixed value" = "Použít pevnou hodnotu" +"Use an expression, e.g. count + 1" = "Použít výraz, např. count + 1" +"Use as mask" = "Použít jako masku" +"Use auto layout" = "Použít automatické rozvržení" +"Use number keys for opacity" = "Nastavovat krytí číselnými klávesami" +"Use the layer's fill" = "Použít výplň vrstvy" +"Used in {files} · {instances}" = "Použití – {files} · {instances}" +"Username" = "Uživatelské jméno" +"Value" = "Hodnota" +"Uses variables: each layer shows them in its own mode" = "Používá proměnné: každá vrstva je zobrazuje ve vlastním režimu" +"Variable" = "Proměnná" +"Variable collections" = "Kolekce proměnných" +"Variable modes" = "Režimy proměnných" +"Variable width" = "Proměnlivá šířka" +"Variables" = "Proměnné" +"Variables JSON…" = "Proměnné JSON…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "Proměnné uchovávají opakovaně použitelné hodnoty (barvy, čísla, textové řetězce, logické hodnoty) s režimy jako Světlý a Tmavý." +"Variant" = "Varianta" +"Vector" = "Vektor" +"Vectors" = "Vektory" +"Version" = "Verze" +"Version history" = "Historie verzí" +"Version {version}" = "Verze {version}" +"Version {v} · {components} · {styles} · {variables}" = "Verze {v} · {components} · {styles} · {variables}" +"Vertical" = "Svisle" +"Vertical align" = "Svislé zarovnání" +"Vertical hex" = "Svislý šestiúhelník" +"Vertical padding" = "Svislé vnitřní odsazení" +"Vertical spacing" = "Svislé rozestupy" +"Vertical trim" = "Svislý ořez" +"View" = "Zobrazení" +"View link" = "Odkaz pro prohlížení" +"View link copied" = "Odkaz pro prohlížení zkopírován" +"View only" = "Pouze prohlížení" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Pouze prohlížení: vidíte každou změnu průběžně, ale nemůžete upravovat. Opuštěním relace si ponecháte kopii, kterou můžete upravovat." +"Viewing “{name}”" = "Prohlížení „{name}“" +"Violet" = "Fialová" +"Visibility" = "Viditelnost" +"Visible" = "Viditelné" +"Vivid" = "Živé" +"Warm" = "Teplé" +"Watch" = "Hodinky" +"Wavy" = "Vlnitý" +"Web" = "Web" +"Websites" = "Webové stránky" +"WebSocket URL" = "URL WebSocketu" +"WebSocket URL copied" = "URL WebSocketu zkopírována" +"Wedge" = "Klín" +"Weight" = "Tloušťka" +"What you try here changes this preview and the code below, not the file" = "Zdejší zkoušení mění tento náhled a kód níže, soubor zůstává beze změny" +"What changed since it was marked ready for dev" = "Co se změnilo od označení jako připravené pro vývoj" +"What is wrong with this file? The server's operator gets your report." = "Co je s tímto souborem v nepořádku? Vaše hlášení obdrží provozovatel serveru." +"What it's for" = "K čemu slouží" +"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." = "Původní stav není znám: změna nastala dříve, než FigCraft uchovával kopie rámců připravených pro vývoj. Vyřešte změnu (nebo rámec znovu označte jako připravený), aby bylo možné porovnávat budoucí změny." +"While hovering" = "Při najetí ukazatelem" +"While pressing" = "Při stisku" +"White" = "Bílá" +"Who runs it, its terms and how long it keeps files" = "Kdo ho provozuje, jeho podmínky a doba uchovávání souborů" +"Why it changed" = "Důvod změny" +"Widgets" = "Widgety" +"Width" = "Šířka" +"Width point" = "Bod šířky" +"Width profile" = "Profil šířky" +"Wireframe kit" = "Sada pro wireframy" +"With the layers' export settings" = "S nastavením exportu vrstev" +"Wrap" = "Zalamovat" +"Wrap in new section" = "Zabalit do nové sekce" +"Yellow" = "Žlutá" +"Yesterday" = "Včera" +"You" = "Vy" +"You'll get the shared file as a new tab and edit it together." = "Sdílený soubor se otevře v nové kartě a budete ho upravovat společně." +"You're spotlighting: everyone follows your view" = "Sdílíte pohled: všichni sledují vaše zobrazení" +"Your copy of the file stays open" = "Vaše kopie souboru zůstane otevřená" +"Your copy stays open; the file stays on the server" = "Vaše kopie zůstane otevřená; soubor zůstane na serveru" +"Your name" = "Vaše jméno" +"Your name in comments" = "Vaše jméno v komentářích" +"Your system asks for reduced motion: animations stay off." = "Váš systém požaduje omezení pohybu: animace zůstanou vypnuté." +"Z" = "Z" +"Z to A" = "Od Z do A" +"Zoom" = "Přiblížení" +"Zoom around the pointer" = "Přibližovat kolem ukazatele" +"Zoom device to fill window" = "Přiblížit zařízení pro vyplnění okna" +"Zoom in" = "Přiblížit" +"Zoom out" = "Oddálit" +"Zoom to 100%" = "Přiblížit na 100 %" +"Zoom to 200%" = "Přiblížit na 200 %" +"Zoom to 50%" = "Přiblížit na 50 %" +"Zoom to fit" = "Přizpůsobit zobrazení obsahu" +"Zoom to selection" = "Přiblížit na výběr" +"Zoom tool: click to zoom in, drag an area" = "Nástroj přiblížení: kliknutím přiblížíte, tažením vyberete oblast" +"Zoom tool: zoom out" = "Nástroj přiblížení: oddálit" +"and {n} more" = { one = "a {n} další", other = "a další (počet: {n})" } +"bleed marks" = "značky spadávky" +"by {author}" = "autor: {author}" +"call any command its access allows" = "volat libovolný příkaz povolený jeho oprávněními" +"call only: {commands}" = "volat pouze: {commands}" +"can edit the document" = "může upravovat dokument" +"crop marks" = "ořezové značky" +"development: {path}" = "vývoj: {path}" +"e.g. count + 1" = "např. count + 1" +"e.g. count >= 3 and not done" = "např. count >= 3 and not done" +"figcraft:// links from a browser, chat or email open this app and join the session." = "Odkazy figcraft:// z prohlížeče, chatu nebo e-mailu otevřou tuto aplikaci a připojí k relaci." +"from {server}" = "ze serveru {server}" +"hidden" = "skryté" +"iOS" = "iOS" +"just now" = "právě teď" +"locked" = "zamknuté" +"no bleed" = "bez spadávky" +"nobody else here yet" = "zatím tu není nikdo další" +"now" = "nyní" +"or" = "nebo" +"put a widget on the canvas" = "umístit widget na plátno" +"read and edit the document" = "číst a upravovat dokument" +"read the document" = "číst dokument" +"reads the document" = "čte dokument" +"the plugin" = "plugin" +"this session only" = "pouze v této relaci" +"yesterday" = "včera" +"{instances} in {files}" = "{instances} · {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: návrh překladu, čeká na kontrolu rodilým mluvčím." +"{length} bleed" = "Spadávka {length}" +"{month} {day}, {year}" = "{day}. {month} {year}" +"{name} cleared from the list" = "Vymazáno ze seznamu: {name}" +"{name} is forgotten on this computer; the file stays on {server}" = "Zapomenuto v tomto počítači: {name}; soubor zůstává na serveru {server}" +"{name} is on the server." = "{name} je na serveru." +"{name} is spotlighting" = "{name} sdílí pohled" +"{name}\nClick to apply to the selection" = "{name}\nKliknutím použijete na výběr" +"{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}\nPřetáhněte na plátno nebo kliknutím umístěte doprostřed zobrazení.\nPři puštění na instanci podržte Alt (Option) a vyměňte ji." +"{name} (from {library})" = "{name} (z {library})" +"{name} (you)" = "{name} (vy)" +"{name} ({layers} layers)" = "{name} (vrstvy: {layers})" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, výchozí {default}. Dvojklikem na posuvník obnovíte hodnotu stylu; Escape během tažení akci zruší." +"{name} from {path}" = "{name} z {path}" +"{name} was removed from the server" = "Odebráno ze serveru: {name}" +"{name} · deleted on main" = "{name} · smazáno v hlavním souboru" +"{name} · deleted on the branch" = "{name} · smazáno ve větvi" +"{name} · not published" = "{name} · nepublikováno" +"{name} · on a server" = "{name} · na serveru" +"{name} · published version {v}" = "{name} · publikovaná verze {v}" +"{name} · version {v} on the server" = "{name} · verze {v} na serveru" +"{name}, in a live session" = "{name}, v živé relaci" +"{name}, online, open only to you" = "{name}, online, přístupný pouze vám" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, vybráno" +"{n} added" = { one = "Přidáno: {n}", other = "Přidáno: {n}" } +"{n} autosave versions" = { one = "{n} automaticky uložená verze", other = "Automaticky uložené verze: {n}" } +"{n} changed" = { one = "Změněno: {n}", other = "Změněno: {n}" } +"{n} columns" = { one = "{n} sloupec", other = "Sloupce: {n}" } +"{n} components" = { one = "{n} komponenta", other = "Komponenty: {n}" } +"{n} components and styles have newer versions" = { one = "{n} komponenta nebo styl má novější verzi", other = "Komponenty a styly s novější verzí: {n}" } +"{n} conflicts to resolve" = { one = "{n} konflikt k vyřešení", other = "Konflikty k vyřešení: {n}" } +"{n} days ago" = { one = "Před {n} dnem", other = "Před {n} dny" } +"{n} files" = { one = "{n} soubor", other = "Soubory: {n}" } +"{n} hours ago" = { one = "Před {n} hodinou", other = "Před {n} hodinami" } +"{n} instances" = { one = "{n} instance", other = "Instance: {n}" } +"{n} layers" = { one = "{n} vrstva", other = "Vrstvy: {n}" } +"{n} layers as one file" = { one = "{n} vrstva jako jeden soubor", other = "Vrstvy jako jeden soubor: {n}" } +"{n} layers selected" = { one = "Vybrána {n} vrstva", other = "Vybrané vrstvy: {n}" } +"{n} layers · {parts}" = { one = "{n} vrstva · {parts}", other = "Vrstvy: {n} · {parts}" } +"{n} matches" = { one = "{n} shoda", other = "Shody: {n}" } +"{n} minutes ago" = { one = "Před {n} minutou", other = "Před {n} minutami" } +"{n} months ago" = { one = "Před {n} měsícem", other = "Před {n} měsíci" } +"{n} of {total}" = "{n} z {total}" +"{n} people joined" = { one = "Připojena {n} osoba", other = "Připojené osoby: {n}" } +"{n} preferred components" = { one = "{n} preferovaná komponenta", other = "Preferované komponenty: {n}" } +"{n} ready" = { one = "Připraveno: {n}", other = "Připraveno: {n}" } +"{n} removed" = { one = "Odebráno: {n}", other = "Odebráno: {n}" } +"{n} replies" = { one = "{n} odpověď", other = "Odpovědi: {n}" } +"{n} rows" = { one = "{n} řádek", other = "Řádky: {n}" } +"{n} styles" = { one = "{n} styl", other = "Styly: {n}" } +"{n} variables" = { one = "{n} proměnná", other = "Proměnné: {n}" } +"{n} weeks ago" = { one = "Před {n} týdnem", other = "Před {n} týdny" } +"{n} years ago" = { one = "Před {n} rokem", other = "Před {n} lety" } +"{n}d" = "{n} d" +"{n}h" = "{n} h" +"{n}m" = "{n} min" +"{n}w" = "{n} týd." +"{page}, nothing selected" = "{page}, nic nevybráno" +"{profile} (current)" = "{profile} (aktuální)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} dosadí hodnotu; {property ? \"a\" : \"b\"} vybere řetězec. Ostatní {…} zůstává beze změny." +"{server} can't say what became of your files (it is older than this app)." = "{server} nemůže zjistit stav vašich souborů (je starší než tato aplikace)." +"{server} · No longer on the server" = "{server} · Již není na serveru" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · Otevřeno {date}" +"{tool} hold {key}" = "{tool} podržte {key}" +"{value} isn't a colour" = "{value} není barva" +"{when} · edited" = "{when} · upraveno" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"←" = "←" +"→, Space or N" = "→, mezerník nebo N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ šipky" +"⌘ click" = "⌘ kliknutí" +"⌘ scroll" = "⌘ posouvání" +"⌥ drag" = "⌥ tažení" + +[commands] +"align.bottom" = "Zarovnat dolů" +"align.hcenter" = "Zarovnat vodorovné středy" +"align.left" = "Zarovnat vlevo" +"align.right" = "Zarovnat vpravo" +"align.top" = "Zarovnat nahoru" +"align.vcenter" = "Zarovnat svislé středy" +"annotation.add" = "Přidat anotaci" +"annotation.addCategory" = "Přidat kategorii anotací" +"annotation.categories" = "Kategorie anotací" +"annotation.edit" = "Upravit anotaci" +"annotation.editCategory" = "Upravit kategorii anotací" +"annotation.list" = "Vypsat anotace" +"annotation.remove" = "Odebrat anotaci" +"annotation.removeCategory" = "Smazat kategorii anotací" +"app.actions" = "Akce" +"app.preferences" = "Předvolby" +"arrange.back" = "Přesunout do pozadí" +"arrange.backward" = "Přesunout o úroveň níž" +"arrange.forward" = "Přenést o úroveň výš" +"arrange.front" = "Přenést do popředí" +"arrange.packH" = "Rozmístit těsně vodorovně" +"arrange.packV" = "Rozmístit těsně svisle" +"arrange.roundToPixels" = "Zaokrouhlit na pixely" +"arrange.tidy" = "Uspořádat" +"bool.exclude" = "Vyloučit výběr" +"bool.flatten" = "Sloučit booleovskou skupinu do vektoru" +"bool.intersect" = "Průnik výběru" +"bool.subtract" = "Odečíst výběr" +"bool.union" = "Sjednotit výběr" +"branch.archive" = "Archivovat větev" +"branch.create" = "Vytvořit větev" +"branch.list" = "Vypsat větve" +"branch.merge" = "Sloučit větev" +"branch.open" = "Otevřít větev" +"branch.review" = "Zkontrolovat větev" +"branch.updateFromMain" = "Aktualizovat z hlavního souboru" +"codeConnect.export" = "Exportovat propojení s kódem" +"codeConnect.import" = "Importovat propojení s kódem" +"codeConnect.remove" = "Odpojit od kódu" +"codeConnect.set" = "Propojit s kódem" +"collab.cursor" = "Zobrazit kurzor na pozici" +"collab.cursorChat" = "Chat u kurzoru" +"collab.diff" = "Zobrazit změny synchronizace" +"collab.files" = "Vaše online soubory" +"collab.follow" = "Sledovat" +"collab.forgetFile" = "Zapomenout online soubor" +"collab.host" = "Zahájit živou relaci" +"collab.join" = "Připojit se k živé relaci" +"collab.leave" = "Opustit živou relaci" +"collab.manageFile" = "Spravovat publikovaný soubor" +"collab.openFile" = "Otevřít online soubor" +"collab.peers" = "Lidé v tomto souboru" +"collab.publish" = "Publikovat na server" +"collab.recoverFile" = "Obnovit online soubor" +"collab.registerLinks" = "Otevírat odkazy s pozvánkou v aplikaci FigCraft" +"collab.report" = "Nahlásit soubor provozovateli serveru" +"collab.serverInfo" = "Informace o serveru" +"collab.share" = "Sdílet pomocí kódu pro připojení" +"collab.spotlight" = "Sdílet můj pohled" +"collab.stop" = "Ukončit živou relaci" +"command.batch" = "Spustit více příkazů" +"comment.add" = "Přidat komentář" +"comment.delete" = "Smazat komentář" +"comment.edit" = "Upravit komentář" +"comment.link" = "Kopírovat odkaz na komentář" +"comment.list" = "Vypsat komentáře" +"comment.markRead" = "Označit komentář jako přečtený" +"comment.move" = "Přesunout komentář" +"comment.next" = "Další komentář" +"comment.open" = "Otevřít komentář" +"comment.people" = "Lidé v komentářích" +"comment.react" = "Reagovat na komentář" +"comment.reply" = "Odpovědět na komentář" +"comment.resolve" = "Vyřešit komentář" +"comment.zoom" = "Přiblížit na komentáře" +"component.addProperty" = "Přidat vlastnost komponenty" +"component.addVariant" = "Přidat variantu" +"component.bindProperty" = "Navázat vrstvu na vlastnost" +"component.combineVariants" = "Sloučit jako varianty" +"component.create" = "Vytvořit komponentu" +"component.createMultiple" = "Vytvořit více komponent" +"component.deleteProperty" = "Smazat vlastnost komponenty" +"component.detach" = "Odpojit instanci" +"component.editProperty" = "Upravit vlastnost komponenty" +"component.exposeInstance" = "Zpřístupnit vnořenou instanci" +"component.goToMain" = "Přejít na hlavní komponentu" +"component.hideWhenPublishing" = "Skrýt při publikování" +"component.list" = "Vypsat komponenty" +"component.makeSlot" = "Vytvořit slot" +"component.playground" = "Zkušební prostředí komponenty" +"component.properties" = "Vlastnosti komponenty" +"component.pushChanges" = "Přenést změny do hlavní komponenty" +"component.removeSlot" = "Odebrat slot" +"component.resetInstance" = "Vrátit všechny změny" +"component.resetOverride" = "Zrušit přepsání" +"component.restoreMain" = "Obnovit hlavní komponentu" +"dev.compare" = "Porovnat změny" +"dev.focus" = "Zaměřený pohled" +"dev.resolveChange" = "Vyřešit změnu" +"dev.unfocus" = "Opustit zaměřený pohled" +"devResource.add" = "Přidat zdroj pro vývojáře" +"devResource.edit" = "Upravit zdroj pro vývojáře" +"devResource.list" = "Vypsat zdroje pro vývojáře" +"devResource.remove" = "Odebrat zdroj pro vývojáře" +"distribute.horizontal" = "Rozmístit vodorovné rozestupy" +"distribute.vertical" = "Rozmístit svislé rozestupy" +"doc.home" = "Domů" +"doc.list" = "Vypsat dokumenty" +"doc.nextTab" = "Další karta" +"doc.previousTab" = "Předchozí karta" +"doc.switch" = "Přepnout dokument" +"edit.copy" = "Kopírovat" +"edit.copyAsCss" = "Kopírovat jako CSS" +"edit.copyAsPng" = "Kopírovat jako PNG" +"edit.copyAsSvg" = "Kopírovat jako SVG" +"edit.copyAsText" = "Kopírovat jako text" +"edit.copyLink" = "Kopírovat odkaz na výběr" +"edit.copyProperties" = "Kopírovat vlastnosti" +"edit.cut" = "Vyjmout" +"edit.delete" = "Smazat" +"edit.duplicate" = "Duplikovat" +"edit.findReplace" = "Najít a nahradit" +"edit.paste" = "Vložit" +"edit.pasteOver" = "Vložit přes výběr" +"edit.pasteProperties" = "Vložit vlastnosti" +"edit.pasteReplace" = "Vložit a nahradit" +"edit.pickColor" = "Vybrat barvu" +"edit.redo" = "Znovu" +"edit.repeat" = "Opakovat poslední příkaz" +"edit.setDefaultProperties" = "Nastavit výchozí vlastnosti" +"edit.undo" = "Zpět" +"effect.add" = "Přidat efekt" +"effect.move" = "Přesunout efekt" +"effect.remove" = "Odebrat efekt" +"effect.set" = "Nastavit efekt" +"export.add" = "Přidat nastavení exportu" +"export.remove" = "Odebrat nastavení exportu" +"export.update" = "Upravit nastavení exportu" +"file.close" = "Zavřít kartu" +"file.colorProfile" = "Barevný profil" +"file.duplicate" = "Duplikovat soubor" +"file.export" = "Exportovat" +"file.exportFramesPdf" = "Exportovat rámce do PDF" +"file.exportLayers" = "Exportovat vrstvy" +"file.exportSvg" = "Exportovat výběr jako SVG" +"file.import" = "Importovat" +"file.new" = "Nový soubor návrhu" +"file.newFromTemplate" = "Nový ze šablony" +"file.open" = "Otevřít" +"file.openRecent" = "Otevřít nedávné" +"file.placeImage" = "Umístit obrázek" +"file.recover" = "Obnovit soubor" +"file.rename" = "Přejmenovat soubor" +"file.revert" = "Vrátit k uloženému stavu" +"file.save" = "Uložit" +"file.saveAs" = "Uložit jako" +"file.saveVersion" = "Uložit do historie verzí" +"file.versionHistory" = "Zobrazit historii verzí" +"fill.add" = "Přidat výplň" +"fill.move" = "Přesunout výplň" +"fill.pattern" = "Výplň vzorem" +"fill.remove" = "Odebrat výplň" +"fill.set" = "Nastavit výplň" +"frame.create" = "Vytvořit rámec" +"frame.presets" = "Vypsat předvolby rámců" +"frame.swapOrientation" = "Změnit orientaci" +"grid.add" = "Přidat mřížku rozvržení" +"grid.move" = "Přesunout mřížku rozvržení" +"grid.remove" = "Odebrat mřížku rozvržení" +"guide.add" = "Přidat vodítko" +"guide.clear" = "Odebrat všechna vodítka" +"guide.move" = "Přesunout vodítko" +"guide.remove" = "Odebrat vodítko" +"guide.select" = "Vybrat vodítko" +"help.gettingStarted" = "Začínáme" +"help.about" = "O aplikaci FigCraft" +"help.page" = "Stránka nápovědy" +"help.shortcuts" = "Klávesové zkratky" +"image.adjust" = "Upravit obrázek" +"image.deletePreset" = "Smazat předvolbu obrázku" +"image.place" = "Umístit obrázek" +"image.presets" = "Předvolby obrázků" +"image.savePreset" = "Uložit předvolbu obrázku" +"inspect.assets" = "Prozkoumat zdroje" +"inspect.code" = "Kód vrstvy" +"inspect.componentCode" = "Kód komponenty" +"inspect.properties" = "Prozkoumat vlastnosti" +"inspect.readyForDev" = "Připraveno pro vývoj" +"instance.create" = "Vytvořit instanci" +"instance.exposed" = "Zpřístupněné vnořené instance" +"instance.place" = "Umístit instanci" +"instance.resetSlot" = "Obnovit slot" +"instance.setProperties" = "Nastavit vlastnosti instance" +"instance.slotInsert" = "Vložit do slotu" +"instance.swap" = "Vyměnit instanci" +"instance.swapAt" = "Vyměnit instanci v bodě" +"layers.collapseAll" = "Sbalit vrstvy" +"layout.add" = "Přidat automatické rozvržení" +"layout.remove" = "Odebrat automatické rozvržení" +"layout.setTrack" = "Nastavit velikost stopy mřížky" +"layout.suggest" = "Navrhnout automatické rozvržení" +"libraries.open" = "Knihovny" +"libraries.publish" = "Publikovat knihovnu…" +"library.acceptUpdates" = "Přijmout aktualizace knihovny" +"library.add" = "Přidat knihovnu" +"library.aliasVariable" = "Vytvořit alias proměnné knihovny" +"library.applyStyle" = "Použít styl knihovny" +"library.bindVariable" = "Navázat proměnnou knihovny" +"library.extendCollection" = "Rozšířit kolekci knihovny" +"library.import" = "Importovat komponentu knihovny" +"library.importVariable" = "Importovat proměnnou knihovny" +"library.list" = "Vypsat knihovny" +"library.place" = "Umístit komponentu knihovny" +"library.publish" = "Publikovat knihovnu" +"library.publishToServer" = "Publikovat knihovnu na server" +"library.remove" = "Odebrat knihovnu" +"library.setVariableMode" = "Nastavit režim proměnných knihovny" +"library.swap" = "Vyměnit knihovnu" +"library.swapAt" = "Vyměnit instanci za komponentu knihovny" +"library.updates" = "Aktualizace knihovny" +"library.usage" = "Použití knihovny" +"measure.distances" = "Měřit vzdálenosti" +"measure.padding" = "Měřit vnitřní odsazení automatického rozvržení" +"node.create" = "Vytvořit vrstvu" +"node.get" = "Získat vrstvu" +"node.reorder" = "Změnit pořadí vrstvy" +"node.reparent" = "Přesunout vrstvu do" +"node.set" = "Nastavit vlastnosti" +"object.batchRename" = "Hromadně přejmenovat" +"object.convertToFrame" = "Převést na rámec" +"object.convertToGroup" = "Převést na skupinu" +"object.flipH" = "Převrátit vodorovně" +"object.flipV" = "Převrátit svisle" +"object.frameSelection" = "Vytvořit rámec z výběru" +"object.group" = "Seskupit výběr" +"object.hideOthers" = "Skrýt ostatní vrstvy" +"object.mask" = "Použít jako masku" +"object.moveToPage" = "Přesunout na stránku" +"object.opacityKeys" = "Krytí z číselných kláves" +"object.rasterize" = "Rastrovat výběr" +"object.releaseMask" = "Uvolnit masku" +"object.removeFill" = "Odebrat výplň" +"object.removeFrame" = "Odebrat rámec" +"object.removeStroke" = "Odebrat tah" +"object.rename" = "Přejmenovat" +"object.resizeToFit" = "Přizpůsobit velikost obsahu" +"object.rotate180" = "Otočit o 180°" +"object.rotateLeft" = "Otočit o 90° doleva" +"object.rotateRight" = "Otočit o 90° doprava" +"object.setThumbnail" = "Nastavit jako miniaturu" +"object.swapFillStroke" = "Prohodit výplň a tah" +"object.toggleLock" = "Zamknout/Odemknout výběr" +"object.toggleVisible" = "Zobrazit/Skrýt výběr" +"object.ungroup" = "Zrušit seskupení výběru" +"object.wrapInSection" = "Zabalit do nové sekce" +"page.add" = "Přidat stránku" +"page.delete" = "Smazat stránku" +"page.duplicate" = "Duplikovat stránku" +"page.list" = "Vypsat stránky" +"page.move" = "Přesunout stránku" +"page.rename" = "Přejmenovat stránku" +"page.select" = "Přejít na stránku" +"page.setBackground" = "Pozadí stránky" +"paint.documentColors" = "Barvy dokumentu" +"plugin.close" = "Zavřít panel pluginu" +"plugin.directory.install" = "Nainstalovat plugin z katalogu" +"plugin.directory.list" = "Procházet katalog pluginů" +"plugin.directory.publish" = "Publikovat plugin v katalogu" +"plugin.directory.unpublish" = "Zrušit publikování pluginu v katalogu" +"plugin.event" = "Použít ovládací prvek panelu pluginu" +"plugin.import" = "Importovat plugin z manifestu…" +"plugin.list" = "Vypsat pluginy" +"plugin.remove" = "Odebrat plugin" +"plugin.run" = "Spustit plugin" +"plugin.ui" = "Panel pluginu" +"plugins.manage" = "Spravovat pluginy…" +"plugins.runLast" = "Spustit poslední plugin" +"pref.highlightHover" = "Zvýraznit vrstvy pod ukazatelem" +"pref.keepTool" = "Po použití ponechat nástroj vybraný" +"pref.language" = "Jazyk rozhraní" +"pref.nudge" = "Velikost posunu" +"pref.numberKeysOpacity" = "Nastavovat krytí číselnými klávesami" +"pref.publishServer" = "Server pro online sdílení" +"pref.reduceMotion" = "Omezit pohyb" +"pref.renameDuplicates" = "Přejmenovávat duplikované vrstvy" +"pref.showDimensions" = "Zobrazit rozměry objektů" +"pref.userName" = "Vaše jméno v komentářích" +"proto.action.add" = "Přidat akci prototypu" +"proto.action.move" = "Změnit pořadí akce prototypu" +"proto.action.remove" = "Odebrat akci prototypu" +"proto.action.set" = "Upravit akci prototypu" +"proto.closePreview" = "Zavřít náhled prototypu" +"proto.conditional.addBlock" = "Přidat větev Jinak pokud / Jinak" +"proto.conditional.removeBlock" = "Odebrat větev Pokud / Jinak pokud / Jinak" +"proto.conditional.setCondition" = "Upravit podmínku" +"proto.evaluate" = "Vyhodnotit výraz prototypu" +"proto.flow.add" = "Přidat počáteční bod toku" +"proto.flow.remove" = "Odebrat tok" +"proto.flow.rename" = "Přejmenovat tok" +"proto.interaction.add" = "Přidat interakci" +"proto.interaction.clear" = "Odebrat všechny interakce" +"proto.interaction.remove" = "Odebrat interakci" +"proto.interaction.set" = "Upravit interakci" +"proto.present" = "Prezentovat" +"proto.presentOptions" = "Možnosti prezentace" +"proto.preview" = "Otevřít náhled prototypu" +"proto.settings" = "Nastavení prototypu" +"section.toggleContents" = "Skrýt/Zobrazit obsah sekce" +"select.add" = "Přidat do výběru" +"select.all" = "Vybrat vše" +"select.area" = "Vybrat oblast" +"select.byName" = "Vybrat vrstvy podle názvu" +"select.children" = "Vybrat podřízené vrstvy" +"select.inverse" = "Obrátit výběr" +"select.layersAt" = "Vrstvy v bodě" +"select.matching" = "Vybrat odpovídající vrstvy" +"select.next" = "Vybrat další vrstvu stejné úrovně" +"select.none" = "Zrušit výběr" +"select.parent" = "Vybrat nadřazenou vrstvu" +"select.previous" = "Vybrat předchozí vrstvu stejné úrovně" +"select.remove" = "Odebrat z výběru" +"select.sameEffect" = "Vybrat stejný efekt" +"select.sameFill" = "Vybrat stejnou výplň" +"select.sameFont" = "Vybrat stejné písmo" +"select.sameInstance" = "Vybrat stejnou instanci" +"select.sameProperties" = "Vybrat stejné vlastnosti" +"select.sameStroke" = "Vybrat stejný tah" +"select.sameText" = "Vybrat stejné vlastnosti textu" +"select.set" = "Vybrat" +"shape.arrow" = "Šipka" +"shape.ellipse" = "Elipsa" +"shape.line" = "Čára" +"shape.polygon" = "Mnohoúhelník" +"shape.rectangle" = "Obdélník" +"shape.star" = "Hvězda" +"shapeBuilder.merge" = "Sloučit oblasti" +"shapeBuilder.mode" = "Režim tvorby tvarů" +"shapeBuilder.regions" = "Oblasti tvorby tvarů" +"shapeBuilder.remove" = "Odebrat oblasti" +"stroke.add" = "Přidat tah" +"stroke.brush" = "Štětec" +"stroke.move" = "Přesunout tah" +"stroke.remove" = "Odebrat tah" +"stroke.set" = "Nastavit tah" +"stroke.widthProfile" = "Profil šířky" +"style.apply" = "Použít styl" +"style.bind" = "Navázat proměnnou ve stylu" +"style.create" = "Vytvořit styl" +"style.delete" = "Smazat styl" +"style.detach" = "Odpojit styl" +"style.duplicate" = "Duplikovat styl" +"style.list" = "Vypsat styly" +"style.move" = "Přesunout styly" +"style.renameFolder" = "Přejmenovat složku stylů" +"style.update" = "Upravit styl" +"text.alignCenter" = "Zarovnat na střed" +"text.alignJustify" = "Do bloku" +"text.alignLeft" = "Zarovnat vlevo" +"text.alignRight" = "Zarovnat vpravo" +"text.bold" = "Tučné" +"text.bulletList" = "Odrážkový seznam" +"text.case" = "Velikost písmen" +"text.caseLower" = "Malá písmena" +"text.caseOriginal" = "Původní velikost písmen" +"text.caseSmallCaps" = "Kapitálky" +"text.caseTitle" = "Velká počáteční písmena slov" +"text.caseUpper" = "Velká písmena" +"text.create" = "Text" +"text.decoration" = "Dekorace" +"text.edit.begin" = "Upravit text" +"text.edit.caret" = "Nastavit textový kurzor" +"text.edit.end" = "Ukončit úpravy textu" +"text.edit.insert" = "Psát text" +"text.edit.key" = "Klávesa pro text" +"text.edit.preedit" = "Skládat text (IME)" +"text.edit.selectLine" = "Vybrat řádek" +"text.edit.selectWord" = "Vybrat slovo" +"text.edit.selection" = "Výběr textu" +"text.endPreview" = "Ukončit náhled textu" +"text.fonts" = "Vypsat písma" +"text.italic" = "Kurzíva" +"text.lineHeightDown" = "Zmenšit výšku řádku" +"text.lineHeightUp" = "Zvětšit výšku řádku" +"text.link" = "Vytvořit odkaz" +"text.listIndent" = "Zvětšit odsazení seznamu" +"text.listOutdent" = "Zmenšit odsazení seznamu" +"text.listStyle" = "Styl seznamu" +"text.maxLines" = "Max. řádků" +"text.numberList" = "Číslovaný seznam" +"text.onPath" = "Text na cestě" +"text.paragraphIndent" = "Odsazení odstavce" +"text.paragraphSpacing" = "Mezery mezi odstavci" +"text.pathOptions" = "Možnosti textu na cestě" +"text.preview" = "Náhled změny textu" +"text.removeFromPath" = "Odebrat text z cesty" +"text.set" = "Nastavit text" +"text.setFeatures" = "Funkce OpenType" +"text.setVariations" = "Osy variabilního písma" +"text.sizeDown" = "Zmenšit písmo" +"text.sizeUp" = "Zvětšit písmo" +"text.strikethrough" = "Přeškrtnutí" +"text.style" = "Nastavit styl části textu" +"text.trackingDown" = "Zmenšit prostrkání znaků" +"text.trackingUp" = "Zvětšit prostrkání znaků" +"text.underline" = "Podtržení" +"text.weightDown" = "Zmenšit tloušťku písma" +"text.weightUp" = "Zvětšit tloušťku písma" +"tool.arrow" = "Šipka" +"tool.comment" = "Komentář" +"tool.ellipse" = "Elipsa" +"tool.frame" = "Rámec" +"tool.hand" = "Ruka" +"tool.line" = "Čára" +"tool.move" = "Přesunout" +"tool.pen" = "Pero" +"tool.pencil" = "Tužka" +"tool.pencilBrush" = "Štětec tužky" +"tool.place" = "Umístit pomocí nástroje" +"tool.polygon" = "Mnohoúhelník" +"tool.rectangle" = "Obdélník" +"tool.scale" = "Měřítko" +"tool.scaleAnchor" = "Kotva měřítka" +"tool.section" = "Sekce" +"tool.set" = "Vybrat nástroj" +"tool.shapeBuilder" = "Tvorba tvarů" +"tool.slice" = "Výřez" +"tool.star" = "Hvězda" +"tool.text" = "Text" +"transform.move" = "Přesunout" +"transform.moveTo" = "Přesunout na" +"transform.nudge" = "Posunout" +"transform.nudgeSize" = "Změnit velikost o velikost posunu" +"transform.resize" = "Změnit velikost" +"transform.rotate" = "Otočit" +"transform.rotationOrigin" = "Přesunout střed otáčení" +"transform.scale" = "Měřítko" +"transform.setRotationOrigin" = "Nastavit střed otáčení" +"variable.addMode" = "Přidat režim" +"variable.bind" = "Navázat proměnnou" +"variable.create" = "Vytvořit proměnnou" +"variable.createCollection" = "Vytvořit kolekci proměnných" +"variable.delete" = "Smazat proměnnou" +"variable.deleteCollection" = "Smazat kolekci" +"variable.duplicate" = "Duplikovat proměnnou" +"variable.export" = "Exportovat proměnné" +"variable.import" = "Importovat proměnné" +"variable.list" = "Vypsat proměnné" +"variable.moveMode" = "Přesunout režim" +"variable.removeMode" = "Smazat režim" +"variable.renameCollection" = "Přejmenovat kolekci" +"variable.renameMode" = "Přejmenovat režim" +"variable.resetOverride" = "Zrušit přepsání proměnné" +"variable.set" = "Nastavit hodnotu proměnné" +"variable.setMode" = "Nastavit režim proměnných" +"variable.update" = "Upravit proměnnou" +"vector.create" = "Vektor" +"vector.deleteHeal" = "Smazat a zacelit výběr" +"vector.edit.addVertex" = "Přidat bod" +"vector.edit.begin" = "Upravit vektor" +"vector.edit.bend" = "Ohnout segment" +"vector.edit.bounds" = "Hranice vybraných bodů" +"vector.edit.connect" = "Propojit body" +"vector.edit.cornerRadius" = "Poloměr zaoblení bodu" +"vector.edit.cut" = "Rozříznout cestu" +"vector.edit.delete" = "Smazat body" +"vector.edit.end" = "Dokončit úpravy" +"vector.edit.erase" = "Gumovat" +"vector.edit.lasso" = "Vybrat lasem" +"vector.edit.mirroring" = "Zrcadlení táhel" +"vector.edit.moveHandles" = "Přesunout táhla" +"vector.edit.moveVertices" = "Přesunout body" +"vector.edit.nudge" = "Posunout body" +"vector.edit.paint" = "Plechovka barvy" +"vector.edit.pen" = "Bod pera" +"vector.edit.penEnd" = "Ukončit cestu pera" +"vector.edit.position" = "Poloha bodu" +"vector.edit.resize" = "Změnit velikost výběru bodů" +"vector.edit.rotate" = "Otočit body" +"vector.edit.select" = "Vybrat body" +"vector.edit.selectAll" = "Vybrat všechny body" +"vector.edit.setHandle" = "Nastavit táhlo" +"vector.edit.smooth" = "Vyhladit nebo zostřit body" +"vector.edit.state" = "Stav úprav vektoru" +"vector.edit.strokeCap" = "Zakončení tahu v bodě" +"vector.edit.strokeJoin" = "Spojení tahu v bodě" +"vector.edit.tool" = "Nástroj úprav vektoru" +"vector.edit.transform" = "Transformovat body" +"vector.edit.widthPoint" = "Bod šířky" +"vector.flatten" = "Sloučit do vektoru" +"vector.join" = "Spojit výběr" +"vector.offset" = "Odsadit cestu" +"vector.outlineStroke" = "Převést tah na obrysy" +"vector.reverse" = "Obrátit směr cesty" +"vector.simplify" = "Zjednodušit vektor" +"vector.smoothJoin" = "Hladce spojit výběr" +"version.autosave" = "Automaticky uložit verzi" +"version.delete" = "Smazat verzi" +"version.duplicate" = "Duplikovat verzi" +"version.exitPreview" = "Zpět na aktuální verzi" +"version.list" = "Vypsat verze" +"version.preview" = "Zobrazit verzi" +"version.rename" = "Přejmenovat verzi" +"version.restore" = "Obnovit verzi" +"view.assets" = "Panel zdrojů" +"view.comments" = "Komentáře" +"view.contextMenu" = "Zobrazit kontextovou nabídku" +"view.design" = "Panel Návrh" +"view.designPrototype" = "Přepnout mezi Návrhem a Prototypem" +"view.devMode" = "Režim pro vývojáře" +"view.frameOutlines" = "Obrysy rámců" +"view.get" = "Získat zobrazení" +"view.layers" = "Panel vrstev" +"view.layoutGrids" = "Mřížky rozvržení" +"view.leftSidebar" = "Zobrazit/Skrýt levý postranní panel" +"view.maskOutlines" = "Obrysy masek" +"view.multiplayerCursors" = "Kurzory spolupracovníků" +"view.nextFrame" = "Přiblížit na další rámec" +"view.nextPage" = "Další stránka" +"view.nextRegion" = "Zaměřit další oblast" +"view.outlines" = "Obrysy" +"view.outlinesHiddenLayers" = "Zahrnout skryté vrstvy do obrysů" +"view.outlinesObjectBounds" = "Zahrnout hranice objektů do obrysů" +"view.outlinesSelection" = "Obrysy výběru" +"view.pan" = "Posunout zobrazení" +"view.panelWidths" = "Obnovit šířky panelů" +"view.pixelGrid" = "Pixelová mřížka" +"view.pixelPreview" = "Pixelový náhled" +"view.prevFrame" = "Přiblížit na předchozí rámec" +"view.prevPage" = "Předchozí stránka" +"view.previousRegion" = "Zaměřit předchozí oblast" +"view.proofSrgb" = "Kontrolní náhled sRGB" +"view.fullscreen" = "Celá obrazovka" +"view.prototype" = "Panel Prototyp" +"view.rulers" = "Pravítka" +"view.set" = "Nastavit zobrazení" +"view.slices" = "Výřezy" +"view.snapGeometry" = "Přichytávat ke geometrii" +"view.snapObjects" = "Přichytávat k objektům" +"view.snapPixel" = "Přichytávat k pixelové mřížce" +"view.themeDark" = "Tmavý motiv" +"view.themeLight" = "Světlý motiv" +"view.themeSystem" = "Podle systémového motivu" +"view.toggleUi" = "Zobrazit/Skrýt rozhraní" +"view.zoom100" = "Přiblížit na 100 %" +"view.zoom200" = "Přiblížit na 200 %" +"view.zoom50" = "Přiblížit na 50 %" +"view.zoomAt" = "Nástroj přiblížení" +"view.zoomFit" = "Přizpůsobit zobrazení obsahu" +"view.zoomIn" = "Přiblížit" +"view.zoomOut" = "Oddálit" +"view.zoomSelection" = "Přiblížit na výběr" +"widget.click" = "Kliknout na widget" +"widget.insert" = "Vložit widget" +"widget.list" = "Vypsat widgety" +"window.focus" = "Přenést okno do popředí" +"window.new" = "Nové okno" +"template.list" = "Vypsat šablony" + +[menu] +"bool.exclude" = "Vyloučit" +"bool.flatten" = "Sloučit do vektoru" +"bool.intersect" = "Průnik" +"bool.subtract" = "Odečíst" +"bool.union" = "Sjednotit" +"file.duplicate" = "Duplikovat" +"file.import" = "Importovat ze souboru…" +"file.rename" = "Přejmenovat" +"libraries.open" = "Otevřít knihovny" +"select.sameEffect" = "Efekt" +"select.sameFill" = "Výplň" +"select.sameFont" = "Písmo" +"select.sameInstance" = "Instance" +"select.sameProperties" = "Vlastnosti" +"select.sameStroke" = "Tah" +"select.sameText" = "Vlastnosti textu" +"text.alignCenter" = "Zarovnat text na střed" +"text.alignJustify" = "Zarovnat text do bloku" +"text.alignLeft" = "Zarovnat text vlevo" +"text.alignRight" = "Zarovnat text vpravo" +"view.assets" = "Zdroje" +"view.design" = "Návrh" +"view.layers" = "Vrstvy" +"view.outlines" = "Zobrazit obrysy" +"view.outlinesHiddenLayers" = "Zahrnout skryté vrstvy" +"view.outlinesObjectBounds" = "Zahrnout hranice objektů" +"view.outlinesSelection" = "Zobrazit obrysy výběru" +"view.prototype" = "Prototyp" +"view.themeDark" = "Tmavý" +"view.themeLight" = "Světlý" +"view.themeSystem" = "Podle systému" diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 768434e34..eba176e88 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -308,6 +308,11 @@ fn system_languages_pick_the_first_one_with_a_catalog() { let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code; assert_eq!(code("system", Some("fr-CA")), "fr"); assert_eq!(code("system", Some("de_CH.UTF-8")), "de"); + assert_eq!(code("system", Some("cs_CZ.UTF-8")), "cs"); + assert_eq!(code("system", Some("cs-CZ,en-US")), "cs"); + assert_eq!(code("cs", None), "cs"); + assert_eq!(LANGS[resolve("cs", None)].name, "Čeština"); + assert!(LANGS[resolve("cs", None)].draft); assert_eq!(code("system", Some("pt-BR")), "pt-BR"); // Portugal's Portuguese and a bare "pt" read the Brazilian catalog. assert_eq!(code("system", Some("pt-PT")), "pt-BR"); @@ -355,8 +360,10 @@ fn french_and_portuguese_count_zero_as_singular() { for code in ["fr", "pt-BR"] { assert!(one(code, 0) && one(code, 1) && !one(code, 2), "{code}"); } - for code in ["en", "es", "de"] { - assert!(!one(code, 0) && one(code, 1) && !one(code, 2), "{code}"); + for code in ["en", "es", "de", "cs"] { + for n in [0, 1, 2, 3, 4, 5, 21] { + assert_eq!(one(code, n), n == 1, "{code}: {n}"); + } } } diff --git a/docs/gaps.md b/docs/gaps.md index 34c4f4390..a89b43d2a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (`.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Czech draft catalog; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -18,7 +18,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [collaboration-parity](collaboration-parity.md) | | 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | | 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | -| 5 | Localization: English plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts | Localization | 29–51 | [localization-parity](localization-parity.md) | +| 5 | Localization: English plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Czech drafts | Localization | 29–51 | [localization-parity](localization-parity.md) | | 6 | Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 8–26 | [target-app-parity](target-app-parity.md) | | 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | | 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | @@ -144,14 +144,15 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ### 5. Localization: English plus unreviewed drafts - **Missing:** native reviews of the Spanish, French, German, Brazilian Portuguese, Japanese, - Korean and Simplified Chinese drafts and the Latin American Spanish overlay, the 25 newest strings - in five drafts, Hindi, Arabic, Indonesian and Vietnamese, localized engine messages and number + Korean, Simplified Chinese and Czech drafts and the Latin American Spanish overlay, later strings + in older drafts, Hindi, Arabic, Indonesian and Vietnamese, localized engine messages and number formatting; CJK UI text on the web without the craft-fonts CJK build. - **Evidence (measured from source):** the layer landed 2026-10-11: catalogs in - `crates/ui-egui/locales/` (1,371 UI keys plus 524 command labels and 29 menu wordings), + `crates/ui-egui/locales/` (as of 2026-10-15: 1,577 UI keys plus 557 command labels and 31 menu wordings), Preferences ▸ Language, `pref.language`; `en.toml` is complete, and `es.toml`, `fr.toml`, - `de.toml`, `pt-BR.toml`, `ja.toml`, `ko.toml` and `zh-Hans.toml` (2026-10-12) are machine drafts - (all but `zh-Hans.toml` lack the 25 newest strings), with `es-419.toml` a Latin American overlay + `de.toml`, `pt-BR.toml`, `ja.toml`, `ko.toml`, `zh-Hans.toml` and `cs.toml` are machine drafts + (Czech covers all 2,165 entries as of 2026-10-15; the other drafts fall back to English for later additions), + with `es-419.toml` a Latin American overlay listing only what differs from Spanish (only Spanish is held to completeness by the check; the others show English for strings added later). CJK UI text uses an installed CJK face the desktop app loads when something on screen needs it @@ -394,6 +395,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Czech draft catalog: 1,577 UI keys, 557 command labels and 31 menu wordings (2,165 entries), marked as awaiting native review and kept out of `COMPLETE`. The build discovers `cs.toml` automatically; System resolves cs and cs-CZ without font changes. Singular uses `n == 1`; count-neutral `other` forms cover 0, 2–4 and 5+. Localization grades and estimates unchanged. | | 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Hours and the ranking unchanged | | 2026-10-12 | minor | Gap 2: your online files landed (the app records the files it put on a server with their owner tokens, Home ▸ Online lists them and opens them again as their owner, with sharing, rename, removal and forget from the list; one request per server tells which are still there; tabs mark files in a live session). What is left is unchanged: accounts, teams and per-person permissions, shared comments and versions. Hours unchanged | | 2026-10-12 | minor | Gap 2: sharing with a join code (Share ▸ Online or Local network, one Start sharing button, a short code to give others, one join field for a code or a link; the server names links with codes and limits wrong ones) added to the landed list; usability, so the estimate and ranking are unchanged | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 99f0f1a70..977a36f96 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,11 +1,11 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Czech draft catalog; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog and Spanish (with a Latin -American overlay), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese +American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Czech drafts (machine-drafted, waiting for native speakers' review). This page lists the twelve key -languages of the craftrules standard, then the other languages Figma ships. How to add a +languages of the craftrules standard, then additional languages. How to add a language: [`translating.md`](translating.md). ## What Figma ships @@ -17,18 +17,18 @@ browser's page translation. The `.lproj` folders in `/Applications/Figma.app` (6 Electron/Chromium locale packs, not Figma's own UI languages. Text *content* in any script is supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no vertical text. -## What FigCraft has (measured 2026-10-12, from source) +## What FigCraft has (measured 2026-10-15, from source) - **Layer:** one TOML catalog per language in `crates/ui-egui/locales/`, baked into the binary by the UI crate's build script (desktop and web; nothing read at run time). Keys are the English text (`tr("Fill")`, `trf` for `{named}` placeholders, `trn` for counts with one/other plural forms), so a missing translation shows English, never a raw key. One English text that needs two translations takes a context (`"Fill|sizing"`). -- **Strings:** 1,417 UI keys in `en.toml` (36 with plural forms; ~110 are labels from engine and - document data, such as tool names, blend modes and Dev Mode properties) plus 536 command labels - and 29 menu wordings by command id: 1,982 entries. Command ids, the CLI, MCP and the control +- **Strings:** 1,577 UI keys in `en.toml` (38 with plural forms; ~110 are labels from engine and + document data, such as tool names, blend modes and Dev Mode properties) plus 557 command labels + and 31 menu wordings by command id: 2,165 entries. Command ids, the CLI, MCP and the control channel stay English. Accessibility names come from the same strings. -- **Language preference:** Preferences ▸ Language (System, English, Deutsch, Español, Español +- **Language preference:** Preferences ▸ Language (System, English, Čeština, Deutsch, Español, Español (Latinoamérica), Français, 日本語, 한국어, Português (Brasil), 简体中文), applied at once without a restart; System takes the first of the OS's preferred languages that has a catalog (Windows' language list, macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional tag @@ -36,7 +36,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v Latin American and US Spanish tags to es-419; zh, zh-CN, zh-SG and zh-Hans read zh-Hans, while Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and reads English or the next OS language, never Simplified) or the browser's language on the web. - `pref.language {language}` sets it from the CLI, MCP and the control channel. + `pref.language {language}` sets it from the CLI, MCP and the control channel. Czech tags + (`cs`, `cs-CZ`, `cs_CZ.UTF-8`) resolve to Čeština automatically. - **Regional overlays:** a regional catalog whose language has a catalog lists only what differs (`es-419.toml` over `es.toml`); its UI strings, command labels and menu wordings read the overlay, then the base language, then English. @@ -44,12 +45,13 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v lacks, keys no code uses, unknown command ids, placeholders that differ from English, overlay entries that read as in their base language and, for complete languages (Spanish), anything untranslated. The other drafts are not held to it: a string added later shows English there - until someone translates it. Every draft translates every string as of 2026-10-12 (checked by - adding each to `COMPLETE` locally and running the check). + until someone translates it. Czech covers every current string (checked by temporarily + adding `cs` to `COMPLETE`); the older drafts have 2,077 of the current 2,165 entries. - **Not yet localized:** messages the engine writes (errors, a few toasts, Dev Mode property values such as "inside"), number formatting (decimal points stay `.`), and plural forms beyond one/other (French and Brazilian Portuguese take the singular for 0 as well; Japanese, Korean and Chinese - have one form; CLDR's "many" for millions reads as the plural). + have one form; CLDR's "many" for millions reads as the plural). Czech uses `one` for 1 and + count-neutral `other` wording for 0, 2–4 and 5+; it needs no script or font changes. - **Script support in documents:** Unicode bidi with Arabic shaping and mirrored brackets in text layers; right-to-left list markers and indents; right-to-left names in the layers panel, tabs and canvas labels (visual order, joined Arabic); inline IME composition with the candidate window @@ -78,20 +80,24 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full | |---|---|---|---|---|---|---|---| -| English | en | 2,076 / 2,076 (100%, source) | yes | Latin | yes (source) | full | 0 | -| Simplified Chinese (Mandarin) | zh-Hans | 2,076 / 2,076 (100%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) | -| Spanish | es, es-419 | 2,076 / 2,076 (100%, draft); es-419 overlays 98 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) | +| English | en | 2,165 / 2,165 (100%, source) | yes | Latin | yes (source) | full | 0 | +| Simplified Chinese (Mandarin) | zh-Hans | 2,077 / 2,165 (95%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) | +| Spanish | es, es-419 | 2,165 / 2,165 (100%, draft); es-419 overlays 98 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) | | Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 | | Arabic | ar | 0 (0%) | no | Bidi, shaping and RTL names work; the UI does not mirror | no | none | 9–15 | -| French | fr | 2,076 / 2,076 (100%, draft) | yes | Latin | no | draft | 1–2 (after native review) | -| Portuguese | pt-BR | 2,076 / 2,076 (100%, draft) | yes | Latin | no | draft | 1–2 (after native review) | +| French | fr | 2,077 / 2,165 (95%, draft) | yes | Latin | no | draft | 1–2 (after native review) | +| Portuguese | pt-BR | 2,077 / 2,165 (95%, draft) | yes | Latin | no | draft | 1–2 (after native review) | | Indonesian | id | 0 (0%) | no | Latin | no | none | 3–5 | -| Japanese | ja | 2,076 / 2,076 (100%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | -| German | de | 2,076 / 2,076 (100%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | -| Korean | ko | 2,076 / 2,076 (100%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | +| Japanese | ja | 2,077 / 2,165 (95%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | +| German | de | 2,077 / 2,165 (95%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | +| Korean | ko | 2,077 / 2,165 (95%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics | no | none | 3–5 | -Other languages: none shipped (Figma ships none beyond the eight above). +Other languages: + +| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full | +|---|---|---|---|---|---|---|---| +| Czech | cs | 2,165 / 2,165 (100%, draft) | yes | Latin; existing UI fonts | no | draft | native review and panel QA pending | The layer is done. Per-language hours above are agent hours for a catalog of ~1,800 entries (a machine draft, then a pass over the panels for overflow), plus each language's plural rule where @@ -99,14 +105,15 @@ it isn't one/other; every language also needs a native speaker's review (human). parity besides the languages: localizing engine messages and number formatting (4–6 h); right-to-left mirroring of the panels is in Arabic's hours. Total: **29–51 h**. -Priority: native reviews of the seven drafts and the es-419 overlay (every one of Figma's languages -and variants has one, plus Simplified Chinese, which Figma doesn't ship), then Hindi, Indonesian, +Priority: native reviews of the eight drafts and the es-419 overlay (every one of Figma's languages +and variants has one, plus Simplified Chinese and Czech, which Figma doesn't ship), then Hindi, Indonesian, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; its users see English). ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Czech draft catalog: 1,577 UI keys, 557 command labels and 31 menu wordings (2,165 entries), marked as awaiting native review and kept out of `COMPLETE`. The build discovers `cs.toml` automatically; System resolves cs and cs-CZ without font changes. Singular uses `n == 1`; count-neutral `other` forms cover 0, 2–4 and 5+. Localization grades and estimates unchanged. | | 2026-10-12 | minor | Drafts catch up again, and the counts with them: French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese translate the 71 UI strings and 5 command labels they lacked since sharing with a join code and open hosting (the Share popover, join codes, owner tokens, reports, the .fig export notes), and with Spanish the 26 strings and 3 command labels of your online files (Home ▸ Online, its menu and dialogs, the marks on tabs); machine-drafted in each file's conventions, still drafts. The Latin American overlay adds computadora and agrega where the new Spanish says ordenador and añade (98 entries). 2,076 entries per catalog (1,503 UI strings, 544 command labels, 29 menu wordings) | | 2026-10-12 | minor | Draft catalogs catch up: French, German, Brazilian Portuguese, Japanese and Korean translate the 66 UI strings and 17 command labels added since they were drafted (cursor chat and spotlight, the plugin directory, Rename layers, presentation and snapping keys, list levels, the rotation origin, placing with Enter…), Simplified Chinese the 46 and 12 it lacked; machine-drafted from the English in each file's conventions, still drafts (not in `COMPLETE`). The Latin American overlay adds Control for a gamepad controller and presiona in the plugin directory hint (90 entries, with Enter for the keycap). String counts re-measured: 1,417 UI keys, 536 command labels and 29 menu wordings (1,982 entries), every draft at 100%. Localization grade unchanged | | 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h | diff --git a/docs/mcp.md b/docs/mcp.md index 2b66657c2..c9d102cbc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -349,7 +349,7 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: nested layers), Escape or a click drops it. - `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's accessibility setting turns it on too). Prototype transitions still play. -- `pref.language {language: system|en|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system": +- `pref.language {language: system|en|cs|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system": the OS or browser language; a language without a catalog shows English). Command ids, labels and every result stay English. diff --git a/docs/roadmap.md b/docs/roadmap.md index 8e2780d68..4b798b5ab 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Czech draft catalog; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -16,7 +16,7 @@ are one Opus 5.5 agent; most milestones split across 4–6 agents on disjoint cr 3. **Tool-by-tool interaction pass** against the running Figma ([`ui-parity.md`](ui-parity.md)): 40–60 h, parallel by tool. 4. **Languages** on the landed localization layer: native reviews of the es, fr, de, pt-BR, ja, - ko and zh-Hans drafts and the es-419 overlay (people), then the other key languages: 3–7 h per language + ko, zh-Hans and cs drafts and the es-419 overlay (people), then the other key languages: 3–7 h per language plus native review. 5. **Multiplayer M9**: accounts and teams on top of the live sessions and the standalone server (the WebSocket transport, hosting from the app, invite links that open the app, document sync, live cursors, selections, text carets, avatars and follow, per-user undo, reconnect with offline edits, collision-free ids for styles, variables, collections and comments, joining from the browser, and `figcraft-server` with persistence, publishing from the app and share links to edit or view landed), per [`collab-protocol.md`](collab-protocol.md) and [`server.md`](server.md): 45–90 h. @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Czech draft catalog: 1,577 UI keys, 557 command labels and 31 menu wordings (2,165 entries), marked as awaiting native review and kept out of `COMPLETE`. The build discovers `cs.toml` automatically; System resolves cs and cs-CZ without font changes. Singular uses `n == 1`; count-neutral `other` forms cover 0, 2–4 and 5+. Localization grades and estimates unchanged. | | 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c34d59169..5873df486 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Czech draft catalog; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -129,7 +129,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) | | Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) | | Hardware | 3 | 60% | 10–21 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); several windows (screen-reader tree in the main one only); no GPU canvas, no P3 on screen | [hardware-parity](hardware-parity.md) | -| Localization | 3 | 39% | 29–51 | The layer, an English catalog and machine drafts of Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese (none reviewed by a native speaker; every draft covers all current strings), CJK UI text in installed faces loaded on demand (web: only the craft-fonts CJK build); engine messages stay English | [localization-parity](localization-parity.md) | +| Localization | 3 | 39% | 29–51 | The layer, an English catalog and machine drafts of Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Czech (none reviewed by a native speaker; Czech covers all 2,165 current entries, older drafts fall back to English for later additions), CJK UI text in installed faces loaded on demand (web: only the craft-fonts CJK build); engine messages stay English | [localization-parity](localization-parity.md) | | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | | Ecosystem/plugins | 3 | 38% | 8–26 | Own WASM plugins with a Rust SDK and three examples (#101), plugin panels and settings (ABI v2), widgets that take clicks (API 3), a self-hosted plugin directory on `figcraft-server`; no Figma plugin compatibility, no public catalogue or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | | AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | @@ -628,6 +628,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Czech draft catalog: 1,577 UI keys, 557 command labels and 31 menu wordings (2,165 entries), marked as awaiting native review and kept out of `COMPLETE`. The build discovers `cs.toml` automatically; System resolves cs and cs-CZ without font changes. Singular uses `n == 1`; count-neutral `other` forms cover 0, 2–4 and 5+. Localization grades and estimates unchanged. | | 2026-10-13 | minor | Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, spacing by octave folding fitted to measurements at several zoom levels, fixed to the shape); click, Shift-click and drag select and move areas with their points (drawn unselected); a click on an empty spot clears them and stays in edit mode (see [ui-parity](ui-parity.md)) | | 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. | | 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. | diff --git a/docs/translating.md b/docs/translating.md index b7c26a401..c56c3b277 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -13,8 +13,8 @@ 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`) -and Simplified Chinese (`zh-Hans`), machine-drafted and waiting for native speakers' review. +`es-419`), French (`fr`), German (`de`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), Korean (`ko`), +Simplified Chinese (`zh-Hans`) and Czech (`cs`), machine-drafted and waiting for native speakers' review. ## The catalogs @@ -43,7 +43,9 @@ Enter for the key, control for a gamepad, resorte) and keeps Spanish's tú. Japa concise, without です/ます endings, with full-width ()「」:、。 where Japanese UI text has them; Korean labels end without 합니다 (instructions in -세요). Simplified Chinese is concise, with full-width ():,、。“” in Chinese text and a space between Chinese and Latin words or numbers. -None of the three has plural forms. +None of the three has plural forms. Czech uses formal address, „ “ quotes and no space before +`:`. Use consistent design terms (rámec, komponenta, instance, varianta, automatické rozvržení, +omezení, výplň, tah, efekt, prototyp, proměnná, styl). CJK text needs a CJK font, which egui and Inter lack: the desktop app adds an installed one when something on screen needs it (see `crates/ui-egui/src/cjk.rs`). The web build has no system fonts, @@ -61,11 +63,13 @@ language also needs its script in `cjk::language`. 3. Plurals: give `one` only if the language has a singular form (Japanese, Korean and Chinese give only `other`, and `plural_one` returns false for them). `trn` picks `one` for a count of 1, and also for 0 in French and Brazilian Portuguese (keep `{n}` in their `one` forms); a language with other rules needs its rule in - `plural_one` in [`crates/ui-egui/src/i18n.rs`](../crates/ui-egui/src/i18n.rs). + `plural_one` in [`crates/ui-egui/src/i18n.rs`](../crates/ui-egui/src/i18n.rs). Czech keeps the + default `n == 1`: because only `one`/`other` are supported, its `other` must be count-neutral + for 0, 2–4 and 5+ (e.g. `Vrstvy: {n}`), rather than using a form valid only for 5+. 4. To require full coverage, add the code to `COMPLETE` in [`crates/ui-egui/src/i18n_tests.rs`](../crates/ui-egui/src/i18n_tests.rs). Every later PR that adds a string then has to translate it into each complete language, so only Spanish is there: - French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese are registered as + French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Czech are registered as drafts, and a string added later shows English in them until it is translated. To list what a draft lacks, add its code to `COMPLETE` locally and run the check. 5. Run the check and look at the panels in the app (longer words must fit the right panel): From 611fb324b99dd9e3973cdabeb3106826539e54b3 Mon Sep 17 00:00:00 2001 From: IanHollow <72767437+IanHollow@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:59:17 -0700 Subject: [PATCH 085/120] M7: find Unix user fonts under XDG_DATA_HOME Assisted-by: GPT-6 Signed-off-by: IanHollow <72767437+IanHollow@users.noreply.github.com> --- ROADMAP.md | 3 ++- crates/text/src/fontdb.rs | 21 +++++++++++++++++---- crates/text/src/fontdb_tests.rs | 26 ++++++++++++++++++++++++++ docs/cli.md | 4 ++++ 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29b..0f85fa14a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Unix user font discovery follows `XDG_DATA_HOME`; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -312,6 +312,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Unix font discovery reads `$XDG_DATA_HOME/fonts`, with the standard home-directory fallback for missing, empty or relative values; legacy `~/.fonts` remains scanned. No change to headline estimates. | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 2c584ebc6..fc9a7eba4 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -670,14 +670,27 @@ fn system_font_dirs() -> Vec { { v.push("/usr/share/fonts".into()); v.push("/usr/local/share/fonts".into()); - if let Some(h) = std::env::var_os("HOME") { - v.push(std::path::PathBuf::from(&h).join(".fonts")); - v.push(std::path::PathBuf::from(h).join(".local/share/fonts")); - } + v.extend(unix_user_font_dirs(std::env::var_os("HOME"), std::env::var_os("XDG_DATA_HOME"))); } v } +// Fontconfig's default configuration uses $XDG_DATA_HOME/fonts for user fonts. +// Ignore relative XDG paths, as required by the XDG Base Directory Specification. +#[cfg(all(unix, not(target_os = "macos")))] +fn unix_user_font_dirs(home: Option, data_home: Option) -> Vec { + let home = home.map(std::path::PathBuf::from); + let data = data_home.map(std::path::PathBuf::from).filter(|p| p.is_absolute()).or_else(|| home.as_ref().map(|h| h.join(".local/share"))); + let mut dirs = Vec::new(); + if let Some(home) = home { + dirs.push(home.join(".fonts")); + } + if let Some(data) = data { + dirs.push(data.join("fonts")); + } + dirs +} + fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap)>) { if depth > 6 { return; diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index b9add9c2f..466298667 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -67,3 +67,29 @@ fn the_scan_reads_names_like_a_whole_file_read() { assert_eq!(faces_in(&index_bytes(&p, whole.len()).unwrap()), faces_in(&whole), "{}", p.display()); } } + +#[cfg(all(unix, not(target_os = "macos")))] +#[test] +fn user_font_dirs_follow_xdg_data_home() { + use std::ffi::OsString; + use std::path::PathBuf; + let dirs = |home: Option<&str>, data: Option<&str>| unix_user_font_dirs(home.map(OsString::from), data.map(OsString::from)); + let paths = |items: &[&str]| items.iter().map(PathBuf::from).collect::>(); + assert_eq!(dirs(Some("/home/user"), Some("/custom user data")), paths(&["/home/user/.fonts", "/custom user data/fonts"])); + for data in [None, Some(""), Some("relative/data")] { + assert_eq!(dirs(Some("/home/user"), data), paths(&["/home/user/.fonts", "/home/user/.local/share/fonts"])); + } + assert_eq!(dirs(None, Some("/custom-data")), paths(&["/custom-data/fonts"])); + assert_eq!(dirs(None, None), paths(&[])); + assert_eq!(dirs(None, Some("relative")), paths(&[])); +} + +#[cfg(all(unix, not(target_os = "macos")))] +#[test] +fn user_font_dirs_preserve_non_utf8_paths() { + use std::ffi::OsString; + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + let dirs = unix_user_font_dirs(Some(OsString::from_vec(b"/home/\xff".to_vec())), Some(OsString::from_vec(b"/data/\xfe".to_vec()))); + assert_eq!(dirs[0].as_os_str().as_bytes(), b"/home/\xff/.fonts"); + assert_eq!(dirs[1].as_os_str().as_bytes(), b"/data/\xfe/fonts"); +} diff --git a/docs/cli.md b/docs/cli.md index ab6c1ea81..80ccfa596 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -15,6 +15,10 @@ Every subcommand takes `--no-system-fonts` (or set `FIGCRAFT_NO_SYSTEM_FONTS=1`, app honours too) to use only the built-in fonts, for renders that come out the same on every machine, e.g. in CI. +On Linux and other Unix systems except macOS, user fonts are read from +`$XDG_DATA_HOME/fonts`, or `$HOME/.local/share/fonts` when `XDG_DATA_HOME` is unset, +empty or relative. The legacy `$HOME/.fonts` directory is also scanned. + The collaboration server is a binary of its own, `figcraft-server` (`cargo build --release -p figcraft-server`): it keeps files with share links and serves their live sessions, and its `key`, `files`, `links`, `reports`, `block-ip` and `check` subcommands manage them (a server can From 7f2f8ee09af8be0f65cc85d7b4096659d122768d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:02:06 +0200 Subject: [PATCH 086/120] Refuse negative or fractional indexes in page.select and doc.switch Found while testing crates/engine/src/cmd/page.rs:50 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/cmd/page.rs | 2 +- crates/engine/src/panels_behaviour_tests.rs | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 781ed6b07..15086d4c4 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -219,7 +219,7 @@ pub fn specs() -> Vec { } ), spec!("doc.switch", "Switch document", "{index}", false, |s, p| { - let i = req_num("doc.switch", p, "index")? as usize; + let i = req_index("doc.switch", p, "index")?; if i >= s.docs.len() { return Err(bad("doc.switch", "no such document")); } diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index b2d46988e..dc760d77e 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -47,7 +47,7 @@ pub fn specs() -> Vec { let id = match id(p, "id") { Some(i) => i, None => { - let k = req_num("page.select", p, "index")? as usize; + let k = req_index("page.select", p, "index")?; *doc.pages.get(k).ok_or_else(|| bad("page.select", "no such page"))? } }; diff --git a/crates/engine/src/panels_behaviour_tests.rs b/crates/engine/src/panels_behaviour_tests.rs index f28024615..a5a15a1f1 100644 --- a/crates/engine/src/panels_behaviour_tests.rs +++ b/crates/engine/src/panels_behaviour_tests.rs @@ -337,6 +337,23 @@ fn pages_by_the_documented_rules() { assert_ne!(s.page().unwrap(), div, "deleting the open page opens a page, never a divider"); } +#[test] +fn page_select_and_doc_switch_refuse_negative_or_fractional_indexes() { + let mut s = Session::new(); + let p2 = id(&ex(&mut s, "page.add", json!({}))); + for bad_index in [json!(-1), json!(0.5)] { + assert!(s.execute("page.select", &json!({"index": bad_index})).is_err(), "page.select index {bad_index} must fail"); + assert_eq!(s.page().unwrap(), p2, "page.select index {bad_index} leaves the open page alone"); + } + ex(&mut s, "file.new", json!({})); + let active = s.active; + assert_eq!(active, 1); + for bad_index in [json!(-1), json!(0.5)] { + assert!(s.execute("doc.switch", &json!({"index": bad_index})).is_err(), "doc.switch index {bad_index} must fail"); + assert_eq!(s.active, active, "doc.switch index {bad_index} leaves the active tab alone"); + } +} + #[test] fn assets_panel_lists_files_and_drops_by_the_documented_rules() { let mut s = Session::new(); From 627933ae06308f957a97f24922e44a50e164fe82 Mon Sep 17 00:00:00 2001 From: Reva Doni Aprilio Date: Thu, 15 Oct 2026 14:39:08 +0700 Subject: [PATCH 087/120] M9: Indonesian draft catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds crates/ui-egui/locales/id.toml, a draft Indonesian catalog covering every UI string (1,577 keys), command label (557) and menu wording (31), machine-drafted and marked "draft — needs native review". Indonesian keeps a noun unchanged after a count, so plural entries give only `other` and plural_one needs no change. Preferences ▸ Language lists Bahasa Indonesia; System maps id / id_ID to it (new assertion in i18n_tests.rs) and `id` joins the cjk can-show list. Docs updated. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 7 +- contributors/people.toml | 3 + crates/ui-egui/locales/id.toml | 2180 ++++++++++++++++++++++++++++++ crates/ui-egui/src/cjk.rs | 2 +- crates/ui-egui/src/i18n_tests.rs | 1 + docs/gaps.md | 4 +- docs/localization-parity.md | 12 +- docs/translating.md | 9 +- 8 files changed, 2203 insertions(+), 15 deletions(-) create mode 100644 crates/ui-egui/locales/id.toml diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29b..9adc5bf02 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -63,7 +63,7 @@ native speakers' reviews of the six language drafts need people. | Stability | 55% | 20–40 | [gaps](docs/gaps.md) | | Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | | Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | -| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | +| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | | Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | | Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) | | AI features | 0% | 30–60 | [gaps](docs/gaps.md) | @@ -105,7 +105,7 @@ native speakers' reviews of the six language drafts need people. | Arabic | ar | 0% | none | | French | fr | 100% | draft (needs native review) | | Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) | -| Indonesian | id | 0% | none | +| Indonesian | id | 100% | draft (needs native review) | | Japanese | ja | 100% | draft (needs native review) | | German | de | 100% | draft (needs native review) | | Korean | ko | 100% | draft (needs native review) | @@ -113,7 +113,7 @@ native speakers' reviews of the six language drafts need people. No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), -French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸ +French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI text uses an installed CJK font, loaded only when needed; adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, @@ -312,6 +312,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Indonesian draft catalog: `id.toml` translates every UI string (1,577 keys; Indonesian keeps a noun unchanged after a count, so plurals give only `other`), command label (557) and menu wording (31), machine-drafted and marked as a draft awaiting native review (bare imperatives on buttons, formal Anda, established design terms such as frame, layer, component and auto layout kept in English); Preferences ▸ Language lists Bahasa Indonesia and System maps id and id-ID to it. Numbers unchanged | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/contributors/people.toml b/contributors/people.toml index e61cfd31a..cd8b8f296 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -16,3 +16,6 @@ # # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. + +[people.RevDonz] +sync_github_name = true diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml new file mode 100644 index 000000000..77ecc8066 --- /dev/null +++ b/crates/ui-egui/locales/id.toml @@ -0,0 +1,2180 @@ +# FigCraft interface strings: Indonesian. +# +# Draft — needs native review: machine-drafted from the English catalog. Brand names and keyboard +# glyphs stay as they are. See docs/translating.md. + +[meta] +name = "Bahasa Indonesia" +status = "draft — needs native review" + +[ui] + +"% of all commits" = "% dari semua commit" +"(no longer published)" = "(tidak lagi dipublikasikan)" +"({n} warnings: some entries were skipped)" = { other = "({n} peringatan: beberapa entri dilewati)" } +"+ Create variable" = "+ Buat variabel" +".fig (experimental)" = ".fig (eksperimental)" +"1…9, 0" = "1…9, 0" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commit, +{added} / −{deleted} baris (Δ {delta}), +{bin_added} / −{bin_deleted} aset biner, {first} – {last}" +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Koleksi file ini dengan mode dan variabel tersebut: timpa nilainya per mode dan tambahkan variabel sendiri" +"A responsive product page: hero, features, figures, a quote and a footer" = "Halaman produk responsif: hero, fitur, angka, kutipan, dan footer" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "Spring berjalan hingga stabil: massa, kekakuan, dan redamannya menentukan durasinya" +"A title, an agenda, a big figure, a chart and a closing slide" = "Judul, agenda, angka besar, bagan, dan slide penutup" +"A to Z" = "A sampai Z" +"About" = "Tentang" +"About FigCraft" = "Tentang FigCraft" +"About this server" = "Tentang server ini" +"Action" = "Tindakan" +"Actions" = "Tindakan" +"Actions for {name}" = "Tindakan untuk {name}" +"Actions…" = "Tindakan…" +"Active" = "Aktif" +"Actual size (100%)" = "Ukuran sebenarnya (100%)" +"Add a category" = "Tambah kategori" +"Add a comment" = "Tambah komentar" +"Add a description" = "Tambah deskripsi" +"Add a frame (F) or a shape (R) to get started" = "Tambahkan frame (F) atau bentuk (R) untuk memulai" +"Add a layer or component to tile first" = "Tambahkan dulu layer atau component untuk dijadikan ubin" +"Add a note for developers" = "Tambah catatan untuk developer" +"Add action" = "Tambah aksi" +"Add actions for when no condition holds" = "Tambah aksi saat tidak ada kondisi yang terpenuhi" +"Add an annotation for developers" = "Tambah anotasi untuk developer" +"Add another condition" = "Tambah kondisi lain" +"Add auto layout" = "Tambah auto layout" +"Add background behind overlay" = "Tambah latar belakang di balik overlay" +"Add content to this slot" = "Tambah konten ke slot ini" +"Add dev resource" = "Tambah sumber daya dev" +"Add effect" = "Tambah efek" +"Add export setting" = "Tambah pengaturan ekspor" +"Add fill" = "Tambah fill" +"Add interaction" = "Tambah interaksi" +"Add layout grid" = "Tambah layout grid" +"Add library" = "Tambah pustaka" +"Add library file…" = "Tambah file pustaka…" +"Add max height" = "Tambah tinggi maks" +"Add max width" = "Tambah lebar maks" +"Add min height" = "Tambah tinggi min" +"Add min width" = "Tambah lebar min" +"Add mode" = "Tambah mode" +"Add new page" = "Tambah halaman baru" +"Add or update collections from a variables JSON" = "Tambah atau perbarui koleksi dari JSON variabel" +"Add properties to make instances configurable" = "Tambahkan properti agar instance dapat dikonfigurasi" +"Add reaction" = "Tambah reaksi" +"Add starting point" = "Tambah titik awal" +"Add stroke" = "Tambah stroke" +"Add variant" = "Tambah variant" +"Add variant with a new value" = "Tambah variant dengan nilai baru" +"Added" = "Ditambahkan" +"Added the library \"{name}\"" = "Pustaka \"{name}\" ditambahkan" +"Adjust" = "Sesuaikan" +"Adjustment presets" = "Preset penyesuaian" +"Advanced" = "Lanjutan" +"Advanced stroke" = "Stroke lanjutan" +"After delay" = "Setelah jeda" +"After delay {ms}ms" = "Setelah jeda {ms}ms" +"Align" = "Ratakan" +"Align bottom" = "Ratakan bawah" +"Align center" = "Rata tengah" +"Align horizontal centers" = "Ratakan tengah horizontal" +"Align left" = "Rata kiri" +"Align middle" = "Ratakan tengah vertikal" +"Align right" = "Rata kanan" +"Align text baselines" = "Ratakan garis dasar teks" +"Align top" = "Ratakan atas" +"Align vertical centers" = "Ratakan tengah vertikal" +"Alignment" = "Perataan" +"All" = "Semua" +"All categories" = "Semua kategori" +"All comments" = "Semua komentar" +"All components" = "Semua component" +"All fonts" = "Semua font" +"All supported properties" = "Semua properti yang didukung" +"All variables" = "Semua variabel" +"Alpha" = "Alfa" +"Alphabetical" = "Alfabetis" +"Alt+drag" = "Alt+seret" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Layar analitik pada grid: angka, bagan dari vector, dan tabel" +"An invite link that opens FigCraft and joins" = "Link undangan yang membuka FigCraft dan langsung bergabung" +"Anchor" = "Jangkar" +"Android" = "Android" +"Angular" = "Angular" +"Animation" = "Animasi" +"Annotations" = "Anotasi" +"Annotations shown on the canvas" = "Anotasi yang ditampilkan di kanvas" +"Another server, or one that needs its key. The usual server is set in Preferences." = "Server lain, atau server yang memerlukan kunci. Server default diatur di Preferensi." +"Any status" = "Status apa pun" +"Anyone on your network with this code can edit the file, while this app stays open." = "Siapa pun di jaringan Anda dengan kode ini dapat mengedit file, selama aplikasi ini tetap terbuka." +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Siapa pun dengan link edit dapat mengubah file; link lihat hanya memungkinkan orang melihat. Link ini berisi kunci rahasia: bagikan seperti kata sandi. `figcraft-server links` membuat link tambahan dan mencabutnya." +"Anyone with this code can edit the file." = "Siapa pun dengan kode ini dapat mengedit file." +"Anyone with this code can look at the file, not change it." = "Siapa pun dengan kode ini dapat melihat file, tetapi tidak mengubahnya." +"Appearance" = "Tampilan" +"Apply a component property" = "Terapkan properti component" +"Apply variable" = "Terapkan variabel" +"Apply variable to text" = "Terapkan variabel ke teks" +"Apply variable to visibility" = "Terapkan variabel ke visibilitas" +"Apps" = "Aplikasi" +"Apr" = "Apr" +"Arc" = "Busur" +"Arc ratio" = "Rasio busur" +"Arc start" = "Awal busur" +"Arc sweep" = "Sapuan busur" +"Archive" = "Arsipkan" +"Archived" = "Diarsipkan" +"Arrange" = "Susun" +"Arrow" = "Panah" +"As typed" = "Sesuai ketikan" +"Asking the server…" = "Menghubungi server…" +"Assets" = "Aset" +"Aug" = "Agu" +"Auto" = "Otomatis" +"Auto (space around)" = "Otomatis (jarak di sekeliling)" +"Auto (space between)" = "Otomatis (jarak di antara)" +"Auto (space evenly)" = "Otomatis (jarak merata)" +"Auto ({mode})" = "Otomatis ({mode})" +"Auto columns" = "Kolom otomatis" +"Auto gap" = "Jarak otomatis" +"Auto height" = "Tinggi otomatis" +"Auto layout" = "Auto layout" +"Auto rows" = "Baris otomatis" +"Auto width" = "Lebar otomatis" +"Back to the FigCraft server" = "Kembali ke server FigCraft" +"Back to the font's value" = "Kembali ke nilai font" +"Back" = "Kembali" +"Back to Ready for dev" = "Kembali ke Siap untuk dev" +"Back to Ready for dev: the next change compares with how it is now" = "Kembali ke Siap untuk dev: perubahan berikutnya dibandingkan dengan kondisi saat ini" +"Back to assets" = "Kembali ke aset" +"Back to current" = "Kembali ke saat ini" +"Back to styles" = "Kembali ke gaya" +"Back to the canvas from a focused region" = "Kembali ke kanvas dari area fokus" +"Back to the current version" = "Kembali ke versi saat ini" +"Back to the values the font style implies" = "Kembali ke nilai bawaan gaya font" +"Background" = "Latar belakang" +"Background blur" = "Blur latar belakang" +"Basics" = "Dasar" +"Before" = "Sebelum" +"Bend" = "Lengkungan" +"Bevel" = "Bevel" +"Binary assets added" = "Aset biner ditambahkan" +"Binary assets removed" = "Aset biner dihapus" +"Black & white" = "Hitam & putih" +"Bleed" = "Bleed" +"Bleed marks" = "Tanda bleed" +"Bleed marks need a bleed" = "Tanda bleed memerlukan bleed" +"Blend" = "Campuran" +"Blend mode" = "Mode campuran" +"Blue" = "Biru" +"Blur" = "Blur" +"Boolean" = "Boolean" +"Boolean groups" = "Grup boolean" +"Both directions" = "Kedua arah" +"Bottom" = "Bawah" +"Bottom left radius" = "Radius kiri bawah" +"Bottom right radius" = "Radius kanan bawah" +"Branch" = "Branch" +"Branch archived" = "Branch diarsipkan" +"Branch merged" = "Branch di-merge" +"Branch name" = "Nama branch" +"Branch of {file}" = "Branch dari {file}" +"Branch of {file} · merged or archived" = "Branch dari {file} · di-merge atau diarsipkan" +"Branch updated from main" = "Branch diperbarui dari main" +"Branch {name}" = "Branch {name}" +"Branches" = "Branch" +"Bound to {name}. Click to detach the variable." = "Terikat ke {name}. Klik untuk melepas variabel." +"Bring forward" = "Bawa maju" +"Bring the main file's changes into the branch" = "Bawa perubahan file utama ke branch" +"Bring to front" = "Bawa ke paling depan" +"Bristle" = "Bulu kuas" +"Browse" = "Jelajahi" +"Brush" = "Kuas" +"Brush rotation jitter" = "Variasi rotasi kuas" +"Brush scatter" = "Sebaran kuas" +"Brush size jitter" = "Variasi ukuran kuas" +"Brush spacing" = "Jarak kuas" +"Bulleted list" = "Daftar berpoin" +"C or Shift+C" = "C atau Shift+C" +"C or ⇧C" = "C atau ⇧C" +"CMYK colour" = "Warna CMYK" +"Can view" = "Dapat melihat" +"Checking the server…" = "Memeriksa server…" +"Clear from this list" = "Hapus dari daftar ini" +"Code or invite link" = "Kode atau link undangan" +"Color profile: {name}" = "Profil warna: {name}" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Gaya warna dan teks, variabel Light dan Dark, tombol, input, dan kartu" +"CSS custom properties…" = "Properti kustom CSS…" +"Can edit" = "Dapat mengedit" +"Can't read this library: {e}" = "Tidak dapat membaca pustaka ini: {e}" +"Cancel" = "Batal" +"Canvas" = "Kanvas" +"Canvas stacking" = "Urutan tumpukan kanvas" +"Cap height to baseline" = "Tinggi kapital ke garis dasar" +"Case" = "Kapitalisasi" +"Case-sensitive forms" = "Bentuk peka huruf besar/kecil" +"Categories" = "Kategori" +"Center" = "Tengah" +"Change alias" = "Ubah alias" +"Change resolved" = "Perubahan diselesaikan" +"Change style" = "Ubah gaya" +"Change to" = "Ubah ke" +"Change to needs a layer inside a variant" = "Ubah ke memerlukan layer di dalam variant" +"Changed" = "Diubah" +"Changed on both sides: keep one" = "Diubah di kedua sisi: pilih salah satu" +"Changes on main" = "Perubahan di main" +"Changes on the branch" = "Perubahan di branch" +"Changes unknown" = "Perubahan tidak diketahui" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Pilih React, Leptos, SwiftUI, atau Compose di atas untuk menghubungkan component ini ke kodenya." +"Choose another profile to change it." = "Pilih profil lain untuk mengubahnya." +"Choose collection" = "Pilih koleksi" +"Choose image" = "Pilih gambar" +"Choose mode" = "Pilih mode" +"Choose variable" = "Pilih variabel" +"Circle" = "Lingkaran" +"Clear recent" = "Hapus daftar terbaru" +"Click + to replace mixed content" = "Klik + untuk mengganti konten campuran" +"Click anywhere on the canvas to leave one." = "Klik di mana saja pada kanvas untuk meninggalkan komentar." +"Click or drag to merge · {alt} removes" = "Klik atau seret untuk menggabungkan · {alt} menghapus" +"Click or drag to remove · {alt} merges" = "Klik atau seret untuk menghapus · {alt} menggabungkan" +"Click to record a key" = "Klik untuk merekam tombol" +"Clip content" = "Potong konten" +"Clip to shape" = "Potong sesuai bentuk" +"Close" = "Tutup" +"Close overlay" = "Tutup overlay" +"Close preview" = "Tutup pratinjau" +"Close tab" = "Tutup tab" +"Close version history" = "Tutup riwayat versi" +"Close when clicking outside" = "Tutup saat klik di luar" +"Close without saving" = "Tutup tanpa menyimpan" +"Code" = "Kode" +"Code syntax" = "Sintaks kode" +"Collapse" = "Ciutkan" +"Collapse panel" = "Ciutkan panel" +"Collection" = "Koleksi" +"Collections" = "Koleksi" +"Color" = "Warna" +"Color burn" = "Color burn" +"Color dodge" = "Color dodge" +"Color format" = "Format warna" +"Color profile" = "Profil warna" +"Color styles" = "Gaya warna" +"Color values in this file are in this color space." = "Nilai warna di file ini menggunakan ruang warna ini." +"Color variables" = "Variabel warna" +"Colour" = "Warna" +"Column" = "Kolom" +"Column gap" = "Jarak kolom" +"Column sizes" = "Ukuran kolom" +"Column span" = "Rentang kolom" +"Columns" = "Kolom" +"Columns {count}" = "Kolom {count}" +"Comment" = "Komentar" +"Comments" = "Komentar" +"Commits" = "Commit" +"Company" = "Perusahaan" +"Compare changes" = "Bandingkan perubahan" +"Completed" = "Selesai" +"Component" = "Component" +"Component name" = "Nama component" +"Component property" = "Properti component" +"Component set" = "Set component" +"Component sets" = "Set component" +"Components" = "Component" +"Conditional" = "Kondisional" +"Confetti" = "Konfeti" +"Conflicts" = "Konflik" +"Connect to code" = "Hubungkan ke kode" +"Connected" = "Terhubung" +"Connected to code: {from}" = "Terhubung ke kode: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "Koneksi terputus, menghubungkan ulang (percobaan {attempt})…" +"Constrain proportions" = "Kunci proporsi" +"Constraints" = "Batasan" +"Content" = "Konten" +"Contents hidden" = "Konten disembunyikan" +"Contents only" = "Hanya konten" +"Contextual alternates" = "Alternatif kontekstual" +"Contrast" = "Kontras" +"Contributors" = "Kontributor" +"Contributors: {people} · Commits: {commits}" = "Kontributor: {people} · Commit: {commits}" +"Controller" = "Pengontrol" +"Cool" = "Dingin" +"Copied as CSS" = "Disalin sebagai CSS" +"Copied as PNG" = "Disalin sebagai PNG" +"Copied as SVG" = "Disalin sebagai SVG" +"Copied as text" = "Disalin sebagai teks" +"Copied {value}" = "{value} disalin" +"Copy" = "Salin" +"Copy as" = "Salin sebagai" +"Copy as CSS" = "Salin sebagai CSS" +"Copy as PNG" = "Salin sebagai PNG" +"Copy as SVG" = "Salin sebagai SVG" +"Copy code" = "Salin kode" +"Copy link" = "Salin link" +"Copy the {label}" = "Salin {label}" +"Copy {value}" = "Salin {value}" +"Corner radius" = "Radius sudut" +"Corner smoothing" = "Penghalusan sudut" +"Couldn't reach {server}: showing what this computer remembers." = "Tidak dapat terhubung ke {server}: menampilkan data yang diingat komputer ini." +"Count" = "Jumlah" +"Create" = "Buat" +"Create a variable collection first" = "Buat koleksi variabel terlebih dahulu" +"Create a variable first" = "Buat variabel terlebih dahulu" +"Create alias" = "Buat alias" +"Create branch" = "Buat branch" +"Create collection" = "Buat koleksi" +"Create component" = "Buat component" +"Create component property" = "Buat properti component" +"Create components, styles or variables to publish them as a library." = "Buat component, gaya, atau variabel untuk memublikasikannya sebagai pustaka." +"Create property…" = "Buat properti…" +"Create style" = "Buat gaya" +"Create style from the selected text" = "Buat gaya dari teks yang dipilih" +"Created" = "Dibuat" +"Crop" = "Pangkas" +"Crop marks" = "Tanda potong" +"Ctrl+arrows" = "Ctrl+panah" +"Ctrl+click" = "Ctrl+klik" +"Ctrl+scroll" = "Ctrl+gulir" +"Current name" = "Nama saat ini" +"Current version" = "Versi saat ini" +"Cursor chat" = "Chat kursor" +"Custom" = "Kustom" +"Cut" = "Potong" +"Dark" = "Gelap" +"Darken" = "Gelapkan" +"Dash" = "Putus-putus" +"Dashboard" = "Dasbor" +"Date created" = "Tanggal dibuat" +"Dec" = "Des" +"Decoration style" = "Gaya dekorasi" +"Decoration" = "Dekorasi" +"Deep select" = "Pilih mendalam" +"Default" = "Default" +"Default content" = "Konten default" +"Default value (the top-left variant)" = "Nilai default (variant kiri atas)" +"Delay" = "Penundaan" +"Delete" = "Hapus" +"Delete category (its annotations stay)" = "Hapus kategori (anotasinya tetap ada)" +"Delete collection" = "Hapus koleksi" +"Delete group" = "Hapus grup" +"Delete mode" = "Hapus mode" +"Delete page" = "Hapus halaman" +"Delete preset" = "Hapus preset" +"Delete property" = "Hapus properti" +"Delete the style (layers keep their values)" = "Hapus gaya (layer mempertahankan nilainya)" +"Delete thread" = "Hapus utas" +"Delete variable" = "Hapus variabel" +"Delete “{name}”? This can't be undone." = "Hapus “{name}”? Tindakan ini tidak dapat diurungkan." +"Density" = "Kepadatan" +"Depth" = "Kedalaman" +"Describe how and when to use this component" = "Jelaskan cara dan kapan menggunakan component ini" +"Describe what changed" = "Jelaskan apa yang berubah" +"Description" = "Deskripsi" +"Description (optional)" = "Deskripsi (opsional)" +"Design" = "Desain" +"Design system starter" = "Dasar sistem desain" +"Desktop" = "Desktop" +"Destination" = "Tujuan" +"Detach alias" = "Lepas alias" +"Detach instance" = "Lepaskan instance" +"Detach property" = "Lepas properti" +"Detach style" = "Lepaskan gaya" +"Detach styles" = "Lepas gaya" +"Detach variable" = "Lepas variabel" +"Details" = "Detail" +"Dev Mode" = "Dev Mode" +"Dev resources" = "Sumber daya dev" +"Development" = "Pengembangan" +"Device" = "Perangkat" +"Device at 100%" = "Perangkat pada 100%" +"Diamond" = "Belah ketupat" +"Difference" = "Perbedaan" +"Dimensions" = "Dimensi" +"Direction" = "Arah" +"Disconnect from code" = "Putuskan dari kode" +"Dispersion" = "Dispersi" +"Display name" = "Nama tampilan" +"Dissolve" = "Larutkan" +"Distribute and tidy" = "Sebarkan dan rapikan" +"Distribute horizontal spacing" = "Sebarkan jarak horizontal" +"Distribute vertical spacing" = "Sebarkan jarak vertikal" +"Divider" = "Pembatas" +"Don't truncate" = "Jangan potong" +"Done" = "Selesai" +"Done editing" = "Selesai mengedit" +"Dots" = "Titik" +"Dotted" = "Bertitik" +"Down" = "Bawah" +"Drafts" = "Draf" +"Drag the stroke to add a width point" = "Seret stroke untuk menambah titik lebar" +"Draw" = "Gambar" +"Draw a frame" = "Gambar frame" +"Drawing tools" = "Alat gambar" +"Drop shadow" = "Bayangan luar" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Berlisensi ganda MIT OR Apache-2.0. Dibuat oleh tim ArtCraft dan komunitas." +"Duo" = "Duo" +"Duplicate" = "Duplikat" +"Duplicate by dragging" = "Duplikat dengan menyeret" +"Duplicate mode" = "Mode duplikat" +"Duplicate page" = "Duplikat halaman" +"Edit" = "Edit" +"Edit as an expression" = "Edit sebagai ekspresi" +"Edit colour" = "Edit warna" +"Edit comment" = "Edit komentar" +"Edit dev resource" = "Edit sumber daya dev" +"Edit link" = "Edit link" +"Edit link copied" = "Link edit disalin" +"Edit style" = "Edit gaya" +"Edit variable" = "Edit variabel" +"Edit with the builder" = "Edit dengan builder" +"Edited {when}" = "Diedit {when}" +"Effect settings" = "Pengaturan efek" +"Effect styles" = "Gaya efek" +"Effects" = "Efek" +"Ellipse" = "Elips" +"Ellipses" = "Elips" +"Else" = "Jika tidak" +"Else if" = "Jika tidak, jika" +"Embedded profile" = "Profil tertanam" +"Empty" = "Kosong" +"End" = "Akhir" +"End point" = "Titik akhir" +"Ends the session; everyone keeps their copy" = "Mengakhiri sesi; semua orang tetap memiliki salinannya" +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Masukkan alamat figcraft-server lalu tekan Jelajahi untuk melihat plugin yang ditawarkannya." +"Enter to save · Esc to cancel" = "Enter untuk menyimpan · Esc untuk membatalkan" +"Eraser" = "Penghapus" +"Esc" = "Esc" +"Essential" = "Esensial" +"Every conflict has a choice." = "Setiap konflik memiliki pilihan." +"Every top-level frame already starts a flow" = "Setiap frame tingkat atas sudah memulai alur" +"Exact opacity (4 then 5: 45%)" = "Opasitas tepat (4 lalu 5: 45%)" +"Exclude" = "Kecualikan" +"Excluded" = "Dikecualikan" +"Excluded from layout" = "Dikecualikan dari tata letak" +"Excludes" = "Pengecualian" +"Exclusion" = "Eksklusi" +"Exit focus view (Esc)" = "Keluar dari tampilan fokus (Esc)" +"Expand" = "Luaskan" +"Export" = "Ekspor" +"Export all" = "Ekspor semua" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Ekspor frame dan layer sebagai PNG, JPG, SVG, atau PDF" +"Export page as PNG" = "Ekspor halaman sebagai PNG" +"Export settings" = "Pengaturan ekspor" +"Export variables" = "Ekspor variabel" +"Export {name}" = "Ekspor {name}" +"Export {n} layers" = { other = "Ekspor {n} layer" } +"Exported {file}" = "{file} diekspor" +"Exported {name}" = "{name} diekspor" +"Exported {name}; {n} kinds of content were left out" = { other = "{name} diekspor; {n} jenis konten tidak disertakan" } +"Exported {n} files" = { other = "{n} file diekspor" } +"Export…" = "Ekspor…" +"Expose properties to instances" = "Tampilkan properti ke instance" +"Exposure" = "Eksposur" +"Expression" = "Ekspresi" +"Extend collection" = "Perluas koleksi" +"Extends \"{name}\": overridden values are marked" = "Memperluas \"{name}\": nilai yang ditimpa ditandai" +"Eye" = "Mata" +"F" = "F" +"Fade" = "Pudar" +"False" = "Salah" +"Feb" = "Feb" +"Fetching from the server…" = "Mengambil dari server…" +"File" = "File" +"File name" = "Nama file" +"File not found" = "File tidak ditemukan" +"File published" = "File dipublikasikan" +"Files aren't end-to-end encrypted: the server's operator can read them." = "File tidak dienkripsi end-to-end: operator server dapat membacanya." +"Files nobody opens for {n} days are deleted." = { other = "File yang tidak dibuka siapa pun selama {n} hari akan dihapus." } +"Fill" = "Fill" +"Fill (fr)" = "Fill (fr)" +"Fill container" = "Isi kontainer" +"Fill window" = "Isi jendela" +"Fills" = "Fill" +"Fill|sizing" = "Isi" +"Filter and sort" = "Filter dan urutkan" +"Find" = "Cari" +"Find and replace" = "Cari dan ganti" +"Find layers" = "Cari layer" +"First" = "Pertama" +"First commit" = "Commit pertama" +"First on top" = "Pertama di atas" +"Fit" = "Sesuaikan" +"Fit device to window" = "Sesuaikan perangkat dengan jendela" +"Fit to contents" = "Sesuaikan dengan konten" +"Fit to window" = "Sesuaikan dengan jendela" +"Fit width" = "Sesuaikan lebar" +"Fixed" = "Tetap" +"Fixed gap" = "Jarak tetap" +"Fixed size" = "Ukuran tetap" +"Flatten" = "Satukan" +"Flip" = "Balik" +"Flip horizontal" = "Balik horizontal" +"Flip text to the other side" = "Balik teks ke sisi lain" +"Flip vertical" = "Balik vertikal" +"Flip width profile" = "Balik profil lebar" +"Flow starting point" = "Titik awal alur" +"Flow starting points" = "Titik awal alur" +"Flows" = "Alur" +"Focus view" = "Tampilan fokus" +"Fold autosaves" = "Ciutkan simpanan otomatis" +"Folder: click to collapse" = "Folder: klik untuk menciutkan" +"Folder: click to expand" = "Folder: klik untuk meluaskan" +"Follow" = "Ikuti" +"Follow {name}" = "Ikuti {name}" +"Following" = "Mengikuti" +"Following {name} · {key} to stop" = "Mengikuti {name} · {key} untuk berhenti" +"Font" = "Font" +"Font not installed" = "Font tidak terinstal" +"Font size" = "Ukuran font" +"Font style" = "Gaya font" +"Forget on this computer" = "Lupakan di komputer ini" +"Four screens of a trail app with components and a prototype flow" = "Empat layar aplikasi jalur pendakian dengan component dan alur prototype" +"Fractions" = "Pecahan" +"Frame" = "Frame" +"Frame selection" = "Buat frame dari pilihan" +"Frame tools" = "Alat frame" +"Frames" = "Frame" +"From the library \"{name}\": read-only" = "Dari pustaka \"{name}\": hanya baca" +"From {name}" = "Dari {name}" +"From {server}" = "Dari {server}" +"Frost" = "Embun beku" +"Gap" = "Jarak" +"Gap between columns" = "Jarak antar kolom" +"Gap between items" = "Jarak antar item" +"Gap between rows" = "Jarak antar baris" +"Getting started" = "Memulai" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Berikan alamat server yang terbuka untuk publik, atau alamat server beserta kuncinya (`figcraft-server key` menampilkannya)." +"Glass" = "Kaca" +"Go to main component" = "Buka main component" +"Go to {name}" = "Buka {name}" +"Grab bag" = "Campuran" +"Gradient" = "Gradien" +"Gray" = "Abu-abu" +"Green" = "Hijau" +"Greyscale components and two example screens to sketch a flow with" = "Component skala abu-abu dan dua layar contoh untuk membuat sketsa alur" +"Grid" = "Grid" +"Grid ({size})" = "Grid ({size})" +"Grid styles" = "Gaya grid" +"Grid {size}" = "Grid {size}" +"Group" = "Grup" +"Group selection" = "Grupkan pilihan" +"Groups" = "Grup" +"Gutter" = "Gutter" +"Hand tool" = "Alat tangan" +"Hanging list" = "Daftar menggantung" +"Hanging punctuation" = "Tanda baca menggantung" +"Hard light" = "Hard light" +"Height" = "Tinggi" +"Help" = "Bantuan" +"Help and resources" = "Bantuan dan sumber daya" +"Help page" = "Halaman bantuan" +"Here: {names}" = "Di sini: {names}" +"Hidden" = "Tersembunyi" +"Hide contents" = "Sembunyikan konten" +"Hide from publishing" = "Sembunyikan dari publikasi" +"Hide section contents" = "Sembunyikan konten section" +"Hide these tips (Getting started in the Help menu shows them again)" = "Sembunyikan tips ini (tampilkan lagi lewat Memulai di menu Bantuan)" +"Hide when publishing" = "Sembunyikan saat dipublikasikan" +"Highlight layers on hover" = "Sorot layer saat hover" +"Highlights" = "Sorotan" +"Hold Alt" = "Tahan Alt" +"Hold Alt+Z" = "Tahan Alt+Z" +"Hold Ctrl" = "Tahan Ctrl" +"Hold Z" = "Tahan Z" +"Hold ⌃ or ⌘" = "Tahan ⌃ atau ⌘" +"Hold ⌥" = "Tahan ⌥" +"Hold ⌥Z" = "Tahan ⌥Z" +"Home" = "Beranda" +"Horizontal" = "Horizontal" +"Horizontal align" = "Perataan horizontal" +"Horizontal hex" = "Heksagon horizontal" +"Horizontal padding" = "Padding horizontal" +"Horizontal spacing" = "Jarak horizontal" +"Hosted by {operator}" = "Di-host oleh {operator}" +"Hover a layer to see its properties" = "Arahkan kursor ke layer untuk melihat propertinya" +"Hover other layers to measure the distance" = "Arahkan kursor ke layer lain untuk mengukur jarak" +"Hue" = "Hue" +"Hug" = "Hug" +"Hug contents" = "Hug konten" +"Icon components on a 24 px grid with keylines and a size preview" = "Component ikon pada grid 24 px dengan keyline dan pratinjau ukuran" +"Icon set starter" = "Set ikon awal" +"If" = "Jika" +"If {condition}" = "Jika {condition}" +"Ignore auto layout (absolute position)" = "Abaikan auto layout (posisi absolut)" +"Image" = "Gambar" +"Import" = "Impor" +"Import path" = "Jalur impor" +"Import plugin from manifest" = "Impor plugin dari manifest" +"Import plugin from manifest…" = "Impor plugin dari manifest…" +"Import variables" = "Impor variabel" +"Imported {name}" = "{name} diimpor" +"Imported {n} new variables, updated {updated}" = { other = "{n} variabel baru diimpor, {updated} diperbarui" } +"Import…" = "Impor…" +"In an instance, this comes from the main component" = "Pada instance, ini berasal dari main component" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "Di browser, Anda dapat bergabung ke sesi yang di-host aplikasi desktop dan file di server FigCraft. Halaman yang dibuka lewat https hanya dapat menjangkau komputer lain melalui link wss:// (server di balik TLS)." +"Include bounding box" = "Sertakan kotak pembatas" +"Include id attribute" = "Sertakan atribut id" +"Included" = "Disertakan" +"Included in layout" = "Disertakan dalam tata letak" +"Individual corners" = "Sudut individual" +"Individual strokes" = "Stroke individual" +"Ink" = "Tinta" +"Inner shadow" = "Bayangan dalam" +"Inside" = "Di dalam" +"Inspect" = "Inspeksi" +"Inspect the layer under the pointer: size, colours, typography" = "Inspeksi layer di bawah pointer: ukuran, warna, tipografi" +"Install plugin" = "Instal plugin" +"Install" = "Instal" +"Installed {name}" = "{name} diinstal" +"Installed" = "Terinstal" +"Installing…" = "Menginstal…" +"Instance" = "Instance" +"Instance properties" = "Properti instance" +"Instance swap" = "Tukar instance" +"Instance swap (no other component)" = "Tukar instance (tidak ada component lain)" +"Instances" = "Instance" +"Instant" = "Instan" +"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." +"Intersect" = "Irisan" +"Intersects" = "Beririsan" +"Invite link" = "Link undangan" +"Invite link copied" = "Link undangan disalin" +"It asks permission to:" = "Plugin ini meminta izin untuk:" +"Jan" = "Jan" +"Join" = "Gabung" +"Join code copied" = "Kode gabung disalin" +"Join code or invite link" = "Kode gabung atau link undangan" +"Join live session" = "Gabung sesi langsung" +"Join us on Discord" = "Gabung dengan kami di Discord" +"Join with a code" = "Gabung dengan kode" +"Joined" = "Bergabung" +"Joined {file}" = "Bergabung ke {file}" +"Joining…" = "Bergabung…" +"Join|stroke" = "Sambungan" +"Jul" = "Jul" +"Jun" = "Jun" +"Just now" = "Baru saja" +"Justify" = "Rata kiri-kanan" +"Keep appearance" = "Pertahankan tampilan" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Pertahankan tampilan mengonversi setiap nilai warna ke {profile} agar warna terlihat sama. Pertahankan nilai membiarkan angkanya apa adanya, sehingga warna bergeser." +"Keep tool selected after use" = "Tetap pilih alat setelah dipakai" +"Keep values" = "Pertahankan nilai" +"Key/Gamepad" = "Tombol/Gamepad" +"Key/Gamepad {key}" = "Tombol/Gamepad {key}" +"Keyboard shortcuts" = "Pintasan keyboard" +"Kits" = "Kit" +"Landing page" = "Landing page" +"Language" = "Bahasa" +"Lasso" = "Laso" +"Last" = "Terakhir" +"Last change: {note}" = "Perubahan terakhir: {note}" +"Last commit" = "Commit terakhir" +"Last modified" = "Terakhir diubah" +"Last on top" = "Terakhir di atas" +"Layer blur" = "Blur layer" +"Layer name" = "Nama layer" +"Layers" = "Layer" +"Layers in an instance take their position from the main component" = "Layer dalam instance mengambil posisinya dari main component" +"Layout" = "Tata letak" +"Layout grid" = "Grid tata letak" +"Layout grid settings" = "Pengaturan grid tata letak" +"Layout grids" = "Grid tata letak" +"Leave session" = "Tinggalkan sesi" +"Leaves" = "Daun" +"Left" = "Kiri" +"Left & right" = "Kiri & kanan" +"Left side" = "Sisi kiri" +"Left side width" = "Lebar sisi kiri" +"Letter case" = "Huruf besar/kecil" +"Letter spacing" = "Spasi huruf" +"Libraries" = "Pustaka" +"Libraries in this file" = "Pustaka di file ini" +"Library updates available" = "Pembaruan pustaka tersedia" +"Library: click to collapse" = "Pustaka: klik untuk menciutkan" +"Library: click to expand" = "Pustaka: klik untuk meluaskan" +"Library: its variables are read-only here; edit them in the library file and publish it" = "Pustaka: variabelnya hanya-baca di sini; edit di file pustaka lalu publikasikan" +"Light" = "Terang" +"Lighten" = "Lighten" +"Light|glass" = "Cahaya" +"Line" = "Garis" +"Line arrow" = "Garis panah" +"Line delta" = "Selisih baris" +"Line height" = "Tinggi baris" +"Linear" = "Linear" +"Lines" = "Baris" +"Lines +/−" = "Baris +/−" +"Lines added" = "Baris ditambahkan" +"Lines deleted" = "Baris dihapus" +"Link" = "Link" +"Link copied" = "Link disalin" +"Link docs, code or tickets for developers." = "Tautkan dokumen, kode, atau tiket untuk developer." +"Link frames on the Prototype tab, then present the flow" = "Tautkan frame di tab Prototype, lalu presentasikan alurnya" +"Link this component to its code component: instances then show your real snippet with their values." = "Tautkan component ini ke component kodenya: instance akan menampilkan snippet asli Anda beserta nilainya." +"List spacing" = "Jarak daftar" +"List style" = "Gaya daftar" +"Live session" = "Sesi langsung" +"Live · {people}" = "Langsung · {people}" +"Local components" = "Component lokal" +"Local network" = "Jaringan lokal" +"Local styles" = "Gaya lokal" +"Local variables" = "Variabel lokal" +"Lock/Unlock" = "Kunci/Buka kunci" +"Locked" = "Terkunci" +"Lowercase" = "Huruf kecil" +"Luminance" = "Luminans" +"Luminosity" = "Luminositas" +"Main" = "Utama" +"Main component deleted" = "Main component dihapus" +"Main menu" = "Menu utama" +"Make a component" = "Buat component" +"Make slot" = "Jadikan slot" +"Mar" = "Mar" +"Margin" = "Margin" +"Mark all as read" = "Tandai semua sudah dibaca" +"Mark as resolved" = "Tandai selesai" +"Mark as unread" = "Tandai belum dibaca" +"Mark as unresolved" = "Tandai belum selesai" +"Mask" = "Mask" +"Mass · Stiffness · Damping" = "Massa · Kekakuan · Redaman" +"Match (empty: the whole name)" = "Cocokkan (kosong: seluruh nama)" +"Max H" = "Maks T" +"Max W" = "Maks L" +"Max lines" = "Baris maksimum" +"Maximize" = "Maksimalkan" +"May" = "Mei" +"Measure to the selection" = "Ukur ke pilihan" +"Merge into main" = "Merge ke main" +"Merge regions" = "Gabungkan area" +"Merge the branch into the main file (one undo step) and archive it" = "Merge branch ke file utama (satu langkah urungkan) lalu arsipkan" +"Merged" = "Di-merge" +"Merged PRs" = "PR yang di-merge" +"Min H" = "Min T" +"Min W" = "Min L" +"Minimize" = "Minimalkan" +"Mirror angle" = "Cerminkan sudut" +"Mirror angle and length" = "Cerminkan sudut dan panjang" +"Mirroring" = "Pencerminan" +"Missing" = "Hilang" +"Missing layer" = "Layer hilang" +"Missing style" = "Gaya hilang" +"Missing variable" = "Variabel hilang" +"Miter" = "Miter" +"Miter angle" = "Sudut miter" +"Mixed" = "Campuran" +"Mixed fonts" = "Font campuran" +"Mixed interactions: click + to replace them" = "Interaksi campuran: klik + untuk menggantinya" +"Mobile app" = "Aplikasi seluler" +"Mode" = "Mode" +"Model" = "Model" +"Models" = "Model" +"Mono" = "Mono" +"More" = "Lainnya" +"More instance actions" = "Tindakan instance lainnya" +"Mouse down" = "Mouse down" +"Mouse enter" = "Mouse enter" +"Mouse leave" = "Mouse leave" +"Mouse up" = "Mouse up" +"Move along the toolbar or the layer rows" = "Berpindah di toolbar atau baris layer" +"Move" = "Pindahkan" +"Move column left" = "Pindahkan kolom ke kiri" +"Move column right" = "Pindahkan kolom ke kanan" +"Move down" = "Pindahkan ke bawah" +"Move in" = "Geser masuk" +"Move out" = "Geser keluar" +"Move tools" = "Alat pindah" +"Move up" = "Pindahkan ke atas" +"Moved to {name}" = "Dipindahkan ke {name}" +"Multi" = "Multi" +"Multiply" = "Multiply" +"Muted" = "Dibisukan" +"Name" = "Nama" +"Name (A–Z)" = "Nama (A–Z)" +"Named instance" = "Instance bernama" +"Navigate to" = "Navigasi ke" +"Nested instance" = "Instance bersarang" +"New design file" = "File desain baru" +"Newest first" = "Terbaru dulu" +"Next or previous control in the focused region" = "Kontrol berikutnya atau sebelumnya di wilayah yang difokuskan" +"Next ready for dev" = "Siap dev berikutnya" +"Next size option" = "Opsi ukuran berikutnya" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "Tidak ada aset: tidak ada pengaturan ekspor, ikon, atau gambar. Tambahkan pengaturan ekspor di tab Desain." +"No boolean variables fit here." = "Tidak ada variabel boolean yang cocok di sini." +"No boolean variables yet. Create them in Local variables." = "Belum ada variabel boolean. Buat di Variabel lokal." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Tidak ada branch. Branch adalah salinan file untuk mencoba perubahan, lalu ditinjau dan di-merge." +"No category" = "Tanpa kategori" +"No changes" = "Tidak ada perubahan" +"No colour variables fit here." = "Tidak ada variabel warna yang cocok di sini." +"No colour variables yet. Create them in Local variables." = "Belum ada variabel warna. Buat di Variabel lokal." +"No comments yet" = "Belum ada komentar" +"No components yet. Select layers and press {keys}." = "Belum ada component. Pilih layer lalu tekan {keys}." +"No conflicts." = "Tidak ada konflik." +"No contributor data was built into this copy." = "Tidak ada data kontributor yang disertakan dalam salinan ini." +"No fonts match" = "Tidak ada font yang cocok" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Tidak ada kode gabung di sini (versi server lebih lama dari fitur kode gabung, atau alamat ini tidak dapat dimasukkan ke kode): bagikan link-nya saja." +"No layer changes to show." = "Tidak ada perubahan layer untuk ditampilkan." +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Belum ada pustaka. Tambahkan file .figcraft yang dipublikasikan sebagai pustaka, atau tempel link pustaka tim." +"No list" = "Tanpa daftar" +"No matching comments" = "Tidak ada komentar yang cocok" +"No matching components" = "Tidak ada component yang cocok" +"No matching layers" = "Tidak ada layer yang cocok" +"No matching styles" = "Tidak ada gaya yang cocok" +"No matching variables" = "Tidak ada variabel yang cocok" +"No mirroring" = "Tanpa pencerminan" +"No model credits were built into this copy." = "Tidak ada kredit model yang disertakan dalam salinan ini." +"No number variables fit here." = "Tidak ada variabel angka yang cocok di sini." +"No number variables yet. Create them in Local variables." = "Belum ada variabel angka. Buat di Variabel lokal." +"No paints" = "Tidak ada paint" +"No plugins on {server}." = "Tidak ada plugin di {server}." +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Belum ada plugin. Impor plugin pengembangan dari file figcraft-plugin.json." +"No preferred components" = "Tidak ada component yang diutamakan" +"No preset" = "Tanpa preset" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "Belum ada versi tersimpan. Simpan versi untuk menandai titik kerja yang bisa Anda kunjungi lagi." +"No scrolling" = "Tanpa scroll" +"No shortcuts match" = "Tidak ada pintasan yang cocok" +"No status" = "Tanpa status" +"No string variables fit here." = "Tidak ada variabel string yang sesuai di sini." +"No string variables yet. Create them in Local variables." = "Belum ada variabel string. Buat di Variabel lokal." +"No styles yet." = "Belum ada gaya." +"No styles yet. Click + to create one from the selection." = "Belum ada gaya. Klik + untuk membuat dari pilihan." +"No templates match your search" = "Tidak ada templat yang cocok dengan pencarian Anda" +"No text styles yet. Click + to create one from the selected text." = "Belum ada gaya teks. Klik + untuk membuat dari teks yang dipilih." +"No variables fit here." = "Tidak ada variabel yang sesuai di sini." +"No variables yet. Create them in Local variables." = "Belum ada variabel. Buat di Variabel lokal." +"Nobody else is here yet" = "Belum ada orang lain di sini" +"Noise" = "Noise" +"None" = "Tidak ada" +"None." = "Tidak ada." +"Normal" = "Normal" +"Not bound" = "Tidak terikat" +"Not in a live session" = "Tidak dalam sesi langsung" +"Not in this font" = "Tidak ada di font ini" +"Not properties, kept as written: {names}" = "Bukan properti, dibiarkan apa adanya: {names}" +"Not read yet" = "Belum dibaca" +"Not shared" = "Tidak dibagikan" +"Note {n}" = "Catatan {n}" +"Nothing changed since this was marked ready for dev." = "Tidak ada perubahan sejak ditandai siap untuk dev." +"Nothing here yet. Set a frame's status to list it." = "Belum ada apa pun. Atur status frame untuk menampilkannya." +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Belum ada yang online. Gunakan Bagikan pada file agar file online dan tercantum di sini." +"Nothing to export" = "Tidak ada yang bisa diekspor" +"Nov" = "Nov" +"Now" = "Sekarang" +"Nudge (Shift: big nudge)" = "Geser sedikit (Shift: geser jauh)" +"Nudge amount" = "Jarak geser" +"Number" = "Angka" +"Number ↑" = "Angka ↑" +"Number ↓" = "Angka ↓" +"Numbered list" = "Daftar bernomor" +"Object" = "Objek" +"Oct" = "Okt" +"Off" = "Nonaktif" +"Offered first when filling the slot (any content is allowed)" = "Ditawarkan pertama saat mengisi slot (konten apa pun diizinkan)" +"Offered first when swapping the instance (any component is allowed)" = "Ditawarkan pertama saat menukar instance (component apa pun diizinkan)" +"Offset" = "Offset" +"Oldest first" = "Terlama dulu" +"On click" = "Saat diklik" +"On drag" = "Saat diseret" +"On this page" = "Di halaman ini" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Satu pengumuman dalam lima ukuran umum: persegi, potret, story, link, dan banner" +"Online" = "Online" +"Online files" = "File online" +"Online sharing is off: files are shared on the local network only." = "Berbagi online nonaktif: file hanya dibagikan di jaringan lokal." +"Only the first changed layers are listed." = "Hanya beberapa layer pertama yang berubah yang dicantumkan." +"Only the first {n} changed layers are listed." = "Hanya {n} layer pertama yang berubah yang dicantumkan." +"Only your threads" = "Hanya utas Anda" +"Opacity" = "Opasitas" +"Opacity & blend" = "Opasitas & campuran" +"Opacity 10%…90%, 0: 100%" = "Opasitas 10%…90%, 0: 100%" +"Open" = "Buka" +"Open .figcraft, .svg or images" = "Buka .figcraft, .svg, atau gambar" +"Open branch" = "Buka branch" +"Open file" = "Buka file" +"Open files" = "Buka file" +"Open in focus view" = "Buka di tampilan fokus" +"Open in new window" = "Buka di jendela baru" +"Open invite links in FigCraft" = "Buka link undangan di FigCraft" +"Open link" = "Buka link" +"Open link {url}" = "Buka link {url}" +"Open overlay" = "Buka overlay" +"Open prototype preview" = "Buka pratinjau prototype" +"Open variables ({count})" = "Buka variabel ({count})" +"Opened “{name}” in a new tab" = "“{name}” dibuka di tab baru" +"Open|comments" = "Terbuka" +"Options" = "Opsi" +"Orange" = "Oranye" +"Order" = "Urutan" +"Other apps may not open every .fig file exported here." = "Aplikasi lain mungkin tidak dapat membuka setiap file .fig yang diekspor di sini." +"Others lose access and keep their copy; the file stays on the server for you" = "Orang lain kehilangan akses tetapi tetap memiliki salinannya; file tetap di server untuk Anda" +"Others type this under Share ▸ Join with a code" = "Orang lain mengetik ini di Bagikan ▸ Gabung dengan kode" +"Outline stroke" = "Outline stroke" +"Outline text" = "Outline teks" +"Outlines" = "Garis luar" +"Outside" = "Luar" +"Overflow" = "Overflow" +"Overlay" = "Overlay" +"Overlay position" = "Posisi overlay" +"Overridden here (the collection it extends has another value)" = "Ditimpa di sini (koleksi yang diperluasnya memiliki nilai lain)" +"Overrides" = "Override" +"Owner token" = "Token pemilik" +"Owner token copied" = "Token pemilik disalin" +"PDF print options" = "Opsi cetak PDF" +"PDF print settings" = "Pengaturan cetak PDF" +"Padding" = "Padding" +"Page" = "Halaman" +"Pages" = "Halaman" +"Pages: {pages}" = "Halaman: {pages}" +"Paint bucket" = "Ember cat" +"Pan the canvas" = "Geser kanvas" +"Pan with nothing selected (Shift: farther)" = "Geser tanpa ada yang dipilih (Shift: lebih jauh)" +"Panels" = "Panel" +"Paper" = "Kertas" +"Paragraph indent" = "Indentasi paragraf" +"Paragraph indent applies to left-aligned text" = "Inden paragraf berlaku untuk teks rata kiri" +"Paragraph spacing" = "Spasi paragraf" +"Pass through" = "Pass through" +"Paste a link (https://…)" = "Tempel link (https://…)" +"Paste a team library link (a view link)" = "Tempel link pustaka tim (link akses lihat)" +"Paste here" = "Tempel di sini" +"Paste to replace" = "Tempel dan ganti" +"Pattern" = "Pola" +"Pattern alignment" = "Perataan pola" +"Pattern scale" = "Skala pola" +"Pattern source" = "Sumber pola" +"Pen" = "Pena" +"Pencil" = "Pensil" +"People in this file" = "Orang di file ini" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "Orang bergabung dari mana saja dengan kode. File disimpan di {server} selama Anda membagikannya." +"People on your network join with a code. Nothing goes through a server." = "Orang di jaringan Anda bergabung dengan kode. Tidak ada yang melewati server." +"Phone" = "Ponsel" +"Pick color" = "Ambil warna" +"Pick color (I)" = "Ambil warna (I)" +"Pick one font family first" = "Pilih satu keluarga font dulu" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Pilih alat Frame, lalu seret di kanvas atau pilih ukuran perangkat" +"Pin a property" = "Sematkan properti" +"Pinch" = "Cubit" +"Pixel grid" = "Grid piksel" +"Pixel preview 1x" = "Pratinjau piksel 1x" +"Pixel preview 2x" = "Pratinjau piksel 2x" +"Place a shape or frame (its tool chosen)" = "Tempatkan bentuk atau frame (saat alatnya dipilih)" +"Place automatically" = "Tempatkan otomatis" +"Place image" = "Tempatkan gambar" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Plugin berjalan di sandbox FigCraft, tanpa file, jaringan, atau jam. Pasang hanya plugin yang Anda percayai." +"Plugins" = "Plugin" +"Plus darker" = "Plus darker" +"Plus lighter" = "Plus lighter" +"Point" = "Titik" +"Points" = "Titik" +"Polygon" = "Poligon" +"Polygons" = "Poligon" +"Port" = "Port" +"Position" = "Posisi" +"Post" = "Kirim" +"Preferences" = "Preferensi" +"Preferred" = "Diutamakan" +"Present" = "Presentasikan" +"Present or preview" = "Presentasikan atau pratinjau" +"Present this flow" = "Presentasikan alur ini" +"Presentation" = "Presentasi" +"Presentation slides" = "Slide presentasi" +"Presenting: comments" = "Presentasi: komentar" +"Presenting: cycle the size options" = "Presentasi: beralih opsi ukuran" +"Presenting: full screen" = "Presentasi: layar penuh" +"Presenting: next frame" = "Presentasi: frame berikutnya" +"Presenting: previous frame" = "Presentasi: frame sebelumnya" +"Presenting: restart" = "Presentasi: mulai ulang" +"Preserve scroll position" = "Pertahankan posisi scroll" +"Presets" = "Preset" +"Press a key…" = "Tekan tombol…" +"Playground" = "Playground" +"Preview" = "Pratinjau" +"Preview this flow" = "Pratinjau alur ini" +"Preview this frame" = "Pratinjau frame ini" +"Previous ready for dev" = "Versi siap dev sebelumnya" +"Progressive" = "Progresif" +"Properties" = "Properti" +"Prototype" = "Prototype" +"Prototype preview" = "Pratinjau prototype" +"Prototype settings" = "Pengaturan prototype" +"Publish" = "Publikasikan" +"Publish changes" = "Publikasikan perubahan" +"Publish to another server…" = "Publikasikan ke server lain…" +"Publish to server" = "Publikasikan ke server" +"Published version {v}" = "Versi {v} dipublikasikan" +"Published version {v} to the server" = "Versi {v} dipublikasikan ke server" +"Published version {v}: save the file so other files can use it" = "Versi {v} dipublikasikan: simpan file agar file lain dapat menggunakannya" +"Published · {people}" = "Dipublikasikan · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publikasi ke server memerlukan aplikasi desktop; di browser Anda dapat bergabung ke file server" +"Publishing with the server's key." = "Memublikasikan dengan kunci server." +"Publishing…" = "Memublikasikan…" +"Push" = "Dorong" +"Push changes to main component" = "Terapkan perubahan ke main component" +"Put text on path" = "Tempatkan teks di path" +"R" = "R" +"Radial" = "Radial" +"Radius" = "Radius" +"Ratio" = "Rasio" +"Reading the library…" = "Membaca pustaka…" +"Ready for dev" = "Siap untuk dev" +"Real name" = "Nama asli" +"Recent" = "Terbaru" +"Recent files" = "File terbaru" +"Recents" = "Terbaru" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { other = "Memulihkan {n} file yang belum disimpan setelah aplikasi keluar tiba-tiba: simpan agar tidak hilang" } +"Rectangle" = "Persegi panjang" +"Rectangles" = "Persegi panjang" +"Red" = "Merah" +"Reduce motion" = "Kurangi gerakan" +"Refraction" = "Refraksi" +"Regular expression" = "Ekspresi reguler" +"Remove" = "Hapus" +"Remove all" = "Hapus semua" +"Remove annotation" = "Hapus anotasi" +"Remove auto layout" = "Hapus auto layout" +"Remove dev resource" = "Hapus sumber daya dev" +"Remove export setting" = "Hapus pengaturan ekspor" +"Remove from recents" = "Hapus dari terbaru" +"Remove from server" = "Hapus dari server" +"Remove from server…" = "Hapus dari server…" +"Remove guide" = "Hapus garis bantu" +"Remove interaction" = "Hapus interaksi" +"Remove library from this file" = "Hapus pustaka dari file ini" +"Remove min and max" = "Hapus min dan maks" +"Remove min and max height" = "Hapus tinggi min dan maks" +"Remove min and max width" = "Hapus lebar min dan maks" +"Remove regions" = "Hapus area" +"Remove slot" = "Hapus slot" +"Remove starting point" = "Hapus titik awal" +"Remove stop" = "Hapus stop" +"Remove text from path" = "Lepaskan teks dari path" +"Remove the else and its actions" = "Hapus else beserta aksinya" +"Remove this condition and its actions" = "Hapus kondisi ini beserta aksinya" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Hapus file ini dari server? File tidak dapat dibagikan atau dibuka dari sana lagi. Salinan Anda tetap terbuka di sini." +"Remove {name} from {server}?" = "Hapus {name} dari {server}?" +"Removed" = "Dihapus" +"Removed from the server" = "Dihapus dari server" +"Removed since it was marked" = "Dihapus sejak ditandai" +"Rename" = "Ganti nama" +"Rename duplicated layers" = "Ganti nama layer duplikat" +"Rename folder" = "Ganti nama folder" +"Rename group" = "Ganti nama grup" +"Rename layers" = "Ganti nama layer" +"Rename mode" = "Ganti nama mode" +"Rename on the server" = "Ganti nama di server" +"Rename on the server…" = "Ganti nama di server…" +"Rename page" = "Ganti nama halaman" +"Rename to" = "Ganti nama menjadi" +"Replace all" = "Ganti semua" +"Replace mixed export settings" = "Ganti pengaturan ekspor campuran" +"Replace with" = "Ganti dengan" +"Replace with a new interaction" = "Ganti dengan interaksi baru" +"Reply" = "Balas" +"Report" = "Laporkan" +"Report this file…" = "Laporkan file ini…" +"Reset" = "Reset" +"Reset adjustments" = "Reset penyesuaian" +"Reset all changes" = "Reset semua perubahan" +"Reset all overrides on this layer" = "Reset semua override pada layer ini" +"Reset overrides" = "Reset override" +"Reset slot" = "Reset slot" +"Reset this override" = "Reset override ini" +"Reset to style" = "Reset ke gaya" +"Reset to the inherited value" = "Reset ke nilai turunan" +"Resize by the nudge amount" = "Ubah ukuran sebesar jarak geser" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Ubah ukuran frame ke layar perangkat (atau jendela tanpa perangkat): batasan dan auto layout-nya menyesuaikan" +"Resize to fit" = "Ubah ukuran agar pas" +"Resolve" = "Selesaikan" +"Resolve change" = "Selesaikan perubahan" +"Resolved" = "Terselesaikan" +"Resources" = "Sumber daya" +"Responsive" = "Responsif" +"Restart" = "Mulai ulang" +"Restore" = "Pulihkan" +"Restore main component" = "Pulihkan main component" +"Restore this version" = "Pulihkan versi ini" +"Restored “{name}”" = "“{name}” dipulihkan" +"Reverse the order" = "Balik urutan" +"Reversed triangle" = "Segitiga terbalik" +"Review and merge" = "Tinjau dan merge" +"Ribbon" = "Pita" +"Right" = "Kanan" +"Right side" = "Sisi kanan" +"Right side width" = "Lebar sisi kanan" +"Rotate 90°" = "Putar 90°" +"Rotate 90° right" = "Putar 90° ke kanan" +"Rotate to landscape" = "Putar ke lanskap" +"Rotate to portrait" = "Putar ke potret" +"Rotation" = "Rotasi" +"Rotation jitter" = "Variasi rotasi" +"Round" = "Bulat" +"Rounded" = "Membulat" +"Row" = "Baris" +"Row gap" = "Jarak baris" +"Row sizes" = "Ukuran baris" +"Row span" = "Rentang baris" +"Rows" = "Baris" +"Rows {count}" = "Baris {count}" +"Rulers" = "Penggaris" +"Run" = "Jalankan" +"Run {label}" = "Jalankan {label}" +"Search templates" = "Cari templat" +"See all templates" = "Lihat semua templat" +"Select layers and they space themselves as their content changes" = "Pilih layer dan jaraknya akan menyesuaikan saat kontennya berubah" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "Sama dengan layer" +"Saturation" = "Saturasi" +"Save" = "Simpan" +"Save as" = "Simpan sebagai" +"Save as preset…" = "Simpan sebagai preset…" +"Save version {key}" = "Simpan versi {key}" +"Saved" = "Tersimpan" +"Saved {name}" = "{name} disimpan" +"Say something" = "Tulis sesuatu" +"Scale" = "Skala" +"Scatter" = "Hamburan" +"Scoping: where pickers offer it" = "Cakupan: tempat pemilih menampilkannya" +"Screen" = "Screen" +"Scroll behavior" = "Perilaku gulir" +"Scroll to" = "Gulir ke" +"Scroll to layer" = "Gulir ke layer" +"Scroll with parent" = "Gulir bersama induk" +"Search plugins" = "Cari plugin" +"Search" = "Cari" +"Search actions, layers and tools" = "Cari tindakan, layer, dan alat" +"Search assets" = "Cari aset" +"Search comments" = "Cari komentar" +"Search fonts" = "Cari font" +"Search layers" = "Cari layer" +"Search shortcuts" = "Cari pintasan" +"Section" = "Section" +"Section color" = "Warna section" +"Sections" = "Section" +"Select a frame and add a starting point to present it as a flow." = "Pilih frame dan tambahkan titik awal untuk menyajikannya sebagai alur." +"Select a frame to preview it" = "Pilih frame untuk mempratinjaunya" +"Select a layer to see its properties and code" = "Pilih layer untuk melihat properti dan kodenya" +"Select all" = "Pilih semua" +"Select all with same" = "Pilih semua yang sama" +"Select layer" = "Pilih layer" +"Select layers to apply the style to" = "Pilih layer untuk menerapkan gaya" +"Select overlapping shapes" = "Pilih bentuk yang tumpang tindih" +"Select points to edit their position, radius and mirroring" = "Pilih titik untuk mengedit posisi, radius, dan pencerminannya" +"Select slot" = "Pilih slot" +"Selection" = "Pilihan" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Kotak pilihan, {w} x {h} di {x}, {y}: panah untuk memindahkan, Shift+panah atau {cmd}+panah untuk mengubah ukuran, Enter untuk memilih, Escape untuk membatalkan" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Kotak pilihan: panah untuk memindahkan, Shift+panah atau {cmd}+panah untuk mengubah ukuran, Enter untuk memilih, Escape untuk membatalkan" +"Send backward" = "Bawa mundur" +"Send report" = "Kirim laporan" +"Send to back" = "Bawa ke paling belakang" +"Sep" = "Sep" +"Server address (ws://host:47900/)" = "Alamat server (ws://host:47900/)" +"Server address" = "Alamat server" +"Server for online sharing" = "Server untuk berbagi online" +"Set as default" = "Jadikan default" +"Set variable" = "Atur variabel" +"Set variable mode" = "Atur mode variabel" +"Set variable {name}" = "Atur variabel {name}" +"Set {name} to {value}" = "Atur {name} ke {value}" +"Shadows" = "Bayangan" +"Shape" = "Bentuk" +"Shape builder" = "Shape builder" +"Shape tools" = "Alat bentuk" +"Share" = "Bagikan" +"Share this file" = "Bagikan file ini" +"Share with a code" = "Bagikan dengan kode" +"Share: live session, export" = "Bagikan: sesi langsung, ekspor" +"Shared" = "Dibagikan" +"Shared: copy its code from the file's menu" = "Dibagikan: salin kodenya dari menu file" +"Sharing a file needs the desktop app; here you can join one." = "Berbagi file memerlukan aplikasi desktop; di sini Anda hanya dapat bergabung." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Berbagi di jaringan lokal (berbagi online nonaktif: tidak ada server yang diatur di Preferensi)." +"Sharing on your network · {people}" = "Berbagi di jaringan Anda · {people}" +"Sharing online · {people}" = "Berbagi online · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "Berbagi dihentikan: file tetap di server, hanya dapat dibuka oleh Anda" +"Show" = "Tampilkan" +"Show all templates" = "Tampilkan semua templat" +"Show as grid" = "Tampilkan sebagai grid" +"Show as list" = "Tampilkan sebagai daftar" +"Show autosaves" = "Tampilkan simpanan otomatis" +"Show behind transparent areas" = "Tampilkan di balik area transparan" +"Show comments (Shift+C)" = "Tampilkan komentar (Shift+C)" +"Show device frame" = "Tampilkan frame perangkat" +"Show dimensions on objects" = "Tampilkan dimensi pada objek" +"Show hotspot hints" = "Tampilkan petunjuk hotspot" +"Show in Explorer" = "Tampilkan di Explorer" +"Show in Finder" = "Tampilkan di Finder" +"Show in folder" = "Tampilkan di folder" +"Show only this on the canvas" = "Tampilkan hanya ini di kanvas" +"Show open" = "Tampilkan yang terbuka" +"Show section contents" = "Tampilkan isi section" +"Show this layer" = "Tampilkan layer ini" +"Show usage" = "Tampilkan penggunaan" +"Show/Hide" = "Tampilkan/Sembunyikan" +"Show/Hide UI" = "Tampilkan/Sembunyikan UI" +"Side by side" = "Berdampingan" +"Sidebar" = "Bilah samping" +"Simplify stroke" = "Sederhanakan stroke" +"Size" = "Ukuran" +"Size jitter" = "Variasi ukuran" +"Skip ink" = "Lewati tinta" +"Slashed zero" = "Nol bergaris miring" +"Slice" = "Slice" +"Slices" = "Slice" +"Slide in" = "Geser masuk" +"Slide out" = "Geser keluar" +"Slot" = "Slot" +"Slot content" = "Konten slot" +"Small capitals" = "Huruf kapital kecil" +"Small caps" = "Kapital kecil" +"Smart animate" = "Smart animate" +"Snap to pixel grid" = "Snap ke grid piksel" +"Social and slides" = "Media sosial dan slide" +"Social media" = "Media sosial" +"Social post sizes" = "Ukuran postingan media sosial" +"Soft light" = "Soft light" +"Solid" = "Solid" +"Some variants have the same property values: give each one a unique combination" = "Beberapa variant memiliki nilai properti yang sama: berikan kombinasi unik untuk masing-masing" +"Sort" = "Urutkan" +"Sort by" = "Urutkan menurut" +"Sort by date" = "Urutkan menurut tanggal" +"Sort by page" = "Urutkan menurut halaman" +"Sort by {column}" = "Urutkan menurut {column}" +"Space around" = "Space around" +"Space between" = "Space between" +"Space drag" = "Seret dengan Space" +"Space evenly" = "Space evenly" +"Space+drag" = "Space+seret" +"Spacing" = "Spasi" +"Spam, stolen work, abuse…" = "Spam, karya curian, penyalahgunaan…" +"Splay" = "Bukaan" +"Spotlight me" = "Sorot saya" +"Spread" = "Sebaran" +"Square" = "Persegi" +"Standard" = "Standar" +"Standard ligatures" = "Ligatur standar" +"Star" = "Bintang" +"Stars" = "Bintang" +"Start" = "Mulai" +"Start ascending from / stop descending at" = "Mulai naik dari / berhenti turun di" +"Start from a blank canvas" = "Mulai dari kanvas kosong" +"Start from a template" = "Mulai dari templat" +"Start on path" = "Mulai pada jalur" +"Start point" = "Titik awal" +"Start sharing" = "Mulai berbagi" +"Start sharing with the Share button, then pass on the code it gives" = "Mulai berbagi dengan tombol Bagikan, lalu teruskan kode yang diberikannya" +"Starting to share…" = "Memulai berbagi…" +"Status" = "Status" +"Sticky" = "Menempel" +"Stop" = "Hentikan" +"Stop following {name}" = "Berhenti mengikuti {name}" +"Stop session" = "Hentikan sesi" +"Stop sharing" = "Berhenti berbagi" +"Stop spotlighting" = "Hentikan sorotan" +"Stop {n} {hex}" = "Titik {n} {hex}" +"Stretch" = "Regangkan" +"Strikethrough" = "Coret" +"String" = "String" +"Stroke" = "Stroke" +"Stroke style" = "Gaya stroke" +"Stroke weight" = "Ketebalan stroke" +"Strokes" = "Stroke" +"Style" = "Gaya" +"Styles" = "Gaya" +"Stylistic set 1" = "Set gaya 1" +"Stylistic set 2" = "Set gaya 2" +"Stylistic set 3" = "Set gaya 3" +"Stylistic set 4" = "Set gaya 4" +"Stylistic set 5" = "Set gaya 5" +"Subtract" = "Kurangi" +"Subtracts" = "Pengurangan" +"Suffix" = "Sufiks" +"Suspend snapping while dragging" = "Tangguhkan snapping saat menyeret" +"Swap instance" = "Tukar instance" +"Swap library: use another library's assets of the same names" = "Tukar pustaka: gunakan aset bernama sama dari pustaka lain" +"Swap orientation" = "Tukar orientasi" +"Swap overlay" = "Tukar overlay" +"Swap to {name}" = "Tukar ke {name}" +"Swap width and height" = "Tukar lebar dan tinggi" +"Swapped instance" = "Instance ditukar" +"Swapped {n} assets" = { other = "{n} aset ditukar" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { other = "{n} aset ditukar; {missed} tidak memiliki padanan dan masih menggunakan pustaka lama" } +"Sweep" = "Sapuan" +"System" = "Sistem" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "Tabel" +"Tablet" = "Tablet" +"Tabular numbers" = "Angka tabular" +"Take the values from the first selected layer" = "Ambil nilai dari layer pertama yang dipilih" +"Taper" = "Runcing" +"Taper both ends" = "Runcingkan kedua ujung" +"Tell the server's operator what is wrong with it" = "Beri tahu operator server apa masalahnya" +"Temperature" = "Suhu" +"Template" = "Templat" +"Templates" = "Templat" +"Terms" = "Ketentuan" +"Text" = "Teks" +"Text align" = "Perataan teks" +"Text content" = "Konten teks" +"Text style" = "Gaya teks" +"Text styles" = "Gaya teks" +"Texts" = "Teks" +"Texture" = "Tekstur" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Kanvas mempratinjau warna dalam sRGB; file PNG, JPG, dan WebP yang diekspor ditandai Display P3." +"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." = "File dihapus dari server: tidak ada yang dapat membuka atau bergabung ke file itu dari sana lagi, dan tindakan ini tidak dapat diurungkan. Salinan yang sedang terbuka tetap ada pada pemiliknya." +"The file now holds another library" = "File kini berisi pustaka lain" +"The file now holds another library (\"{name}\")" = "File kini berisi pustaka lain (\"{name}\")" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "File yang Anda bagikan atau publikasikan secara online dari komputer ini. File tersebut tetap di servernya hingga Anda menghapusnya." +"The link carries the session's secret: share it only with people you invite." = "Link ini memuat rahasia sesi: bagikan hanya kepada orang yang Anda undang." +"The name in your online files and on the server. The document inside keeps its own name." = "Nama di file online Anda dan di server. Dokumen di dalamnya tetap memakai namanya sendiri." +"The selection uses several text styles: click to apply one" = "Pilihan menggunakan beberapa gaya teks: klik untuk menerapkan salah satunya" +"The server deletes this file if nobody opens it before {date}." = "Server akan menghapus file ini jika tidak ada yang membukanya sebelum {date}." +"The whole file" = "Seluruh file" +"Theme" = "Tema" +"There's no destination for that action" = "Tidak ada tujuan untuk tindakan itu" +"Thickness" = "Ketebalan" +"This branch can't be reviewed: {e}" = "Branch ini tidak dapat ditinjau: {e}" +"This branch was merged or archived." = "Branch ini telah di-merge atau diarsipkan." +"This computer only" = "Hanya komputer ini" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Komputer ini mengingat token pemilik, sehingga file dapat dikelola dari daftar Online di layar beranda. Salin token untuk mengelola file dari tempat lain: token ini adalah kata sandi untuk file ini dan tidak akan ditampilkan lagi." +"This condition is edited as an expression" = "Kondisi ini diedit sebagai ekspresi" +"This file" = "File ini" +"This file has unsaved changes" = "File ini memiliki perubahan yang belum disimpan" +"This file is on {server}, open only to you. Share it to let others in with a new code." = "File ini ada di {server}, hanya dapat dibuka oleh Anda. Bagikan untuk mengizinkan orang lain masuk dengan kode baru." +"This frame is new on the branch." = "Frame ini baru di branch." +"This frame isn't on the branch." = "Frame ini tidak ada di branch." +"This server is open to the public: no key needed." = "Server ini terbuka untuk publik: tidak perlu kunci." +"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Server ini memerlukan kuncinya: tambahkan ?key=… ke alamat (`figcraft-server key` menampilkannya di mesin server)." +"This server takes files only with its key: see Advanced." = "Server ini hanya menerima file dengan kuncinya: lihat Lanjutan." +"Tidy up" = "Rapikan" +"Tile" = "Ubin" +"Tile type" = "Jenis ubin" +"Tint" = "Rona" +"Title" = "Judul" +"Title case" = "Huruf Awal Kapital" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Untuk membagikan pustaka ini kepada tim Anda, publikasikan file ke server (Bagikan ▸ Publikasikan ke server…), lalu publikasikan pustaka di sana." +"Toggle" = "Alihkan" +"Toggle light/dark theme" = "Alihkan tema terang/gelap" +"Toggle visibility" = "Alihkan visibilitas" +"Tools" = "Alat" +"Top" = "Atas" +"Top & bottom" = "Atas & bawah" +"Top left radius" = "Radius kiri atas" +"Top right radius" = "Radius kanan atas" +"Transform" = "Transformasi" +"Triangle arrow" = "Panah segitiga" +"Trigger" = "Pemicu" +"True" = "Benar" +"Truncate text" = "Potong teks" +"Truncate with an ellipsis" = "Potong dengan elipsis" +"Try again" = "Coba lagi" +"Try another search or filter." = "Coba pencarian atau filter lain." +"Try another word, or look at every kind." = "Coba kata lain, atau lihat semua jenis." +"Turn a layer into a component, reuse it, and change every copy at once" = "Ubah layer menjadi component, gunakan ulang, dan ubah semua salinannya sekaligus" +"Two digits" = "Dua digit" +"Type" = "Tipe" +"Type settings" = "Pengaturan teks" +"Type the join code you were given, or paste an invite link." = "Ketik kode gabung yang Anda terima, atau tempel link undangan." +"Typography" = "Tipografi" +"Underline" = "Garis bawah" +"Undo takes back only your own changes; what others changed since stays." = "Urungkan hanya membatalkan perubahan Anda sendiri; perubahan orang lain sejak itu tetap ada." +"Ungroup" = "Pisahkan grup" +"Uniform" = "Seragam" +"Union" = "Gabungkan" +"Unions" = "Gabungan" +"Unknown — treated as sRGB" = "Tidak diketahui — dianggap sRGB" +"Unread" = "Belum dibaca" +"Unresolve" = "Buka kembali" +"Unsaved changes" = "Perubahan belum disimpan" +"Unsaved changes in {names}. Close the window and lose them?" = "Perubahan belum disimpan di {names}. Tutup jendela dan buang perubahan?" +"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Perubahan belum disimpan: simpan dulu ({save}), atau tutup tab dengan {alt}-klik untuk membuangnya" +"Unsaved changes: save first, or close again with {key} to discard" = "Perubahan belum disimpan: simpan dulu, atau tutup lagi dengan {key} untuk membuangnya" +"Up" = "Atas" +"Update plugin" = "Perbarui plugin" +"Update" = "Perbarui" +"Update from main" = "Perbarui dari main" +"Update from selection" = "Perbarui dari pilihan" +"Updated {components} and {styles}" = "{components} dan {styles} diperbarui" +"Updated {name}" = "{name} diperbarui" +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Mengunggah file ini ke server FigCraft dan menjaga tab ini tetap sinkron dengannya. Anda mendapat link untuk mengedit dan link untuk melihat." +"Uppercase" = "Huruf kapital" +"Use a fixed value" = "Gunakan nilai tetap" +"Use an expression, e.g. count + 1" = "Gunakan ekspresi, mis. count + 1" +"Use as mask" = "Gunakan sebagai mask" +"Use auto layout" = "Gunakan auto layout" +"Use number keys for opacity" = "Gunakan tombol angka untuk opasitas" +"Use the layer's fill" = "Gunakan fill layer" +"Used in {files} · {instances}" = "Digunakan di {files} · {instances}" +"Username" = "Nama pengguna" +"Value" = "Nilai" +"Uses variables: each layer shows them in its own mode" = "Menggunakan variabel: tiap layer menampilkannya dalam mode masing-masing" +"Variable" = "Variabel" +"Variable collections" = "Koleksi variabel" +"Variable modes" = "Mode variabel" +"Variable width" = "Lebar variabel" +"Variables" = "Variabel" +"Variables JSON…" = "JSON variabel…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "Variabel menyimpan nilai yang dapat digunakan ulang (warna, angka, string, boolean) dengan mode seperti Terang dan Gelap." +"Variant" = "Variant" +"Vector" = "Vector" +"Vectors" = "Vector" +"Version" = "Versi" +"Version history" = "Riwayat versi" +"Version {version}" = "Versi {version}" +"Version {v} · {components} · {styles} · {variables}" = "Versi {v} · {components} · {styles} · {variables}" +"Vertical" = "Vertikal" +"Vertical align" = "Perataan vertikal" +"Vertical hex" = "Heksagon vertikal" +"Vertical padding" = "Padding vertikal" +"Vertical spacing" = "Jarak vertikal" +"Vertical trim" = "Pangkas vertikal" +"View" = "Tampilan" +"View link" = "Link lihat" +"View link copied" = "Link lihat disalin" +"View only" = "Hanya lihat" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Hanya lihat: Anda melihat setiap perubahan saat terjadi tetapi tidak dapat mengedit. Keluar untuk menyimpan salinan yang dapat diedit." +"Viewing “{name}”" = "Melihat “{name}”" +"Violet" = "Ungu" +"Visibility" = "Visibilitas" +"Visible" = "Terlihat" +"Vivid" = "Cerah" +"Warm" = "Hangat" +"Watch" = "Jam tangan" +"Wavy" = "Bergelombang" +"Web" = "Web" +"Websites" = "Situs web" +"WebSocket URL" = "URL WebSocket" +"WebSocket URL copied" = "URL WebSocket disalin" +"Wedge" = "Baji" +"Weight" = "Ketebalan" +"What you try here changes this preview and the code below, not the file" = "Yang Anda coba di sini mengubah pratinjau ini dan kode di bawah, bukan file-nya" +"What changed since it was marked ready for dev" = "Yang berubah sejak ditandai siap untuk dev" +"What is wrong with this file? The server's operator gets your report." = "Apa yang salah dengan file ini? Operator server akan menerima laporan Anda." +"What it's for" = "Kegunaannya" +"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." = "Asal perubahan ini tidak diketahui: perubahan terjadi sebelum FigCraft menyimpan salinan frame siap dev. Selesaikan perubahan (atau tandai siap lagi) agar dapat dibandingkan mulai sekarang." +"While hovering" = "Saat hover" +"While pressing" = "Saat ditekan" +"White" = "Putih" +"Who runs it, its terms and how long it keeps files" = "Siapa pengelolanya, ketentuannya, dan berapa lama file disimpan" +"Why it changed" = "Alasan perubahan" +"Widgets" = "Widget" +"Width" = "Lebar" +"Width point" = "Titik lebar" +"Width profile" = "Profil lebar" +"Wireframe kit" = "Kit wireframe" +"With the layers' export settings" = "Dengan pengaturan ekspor layer" +"Wrap" = "Wrap" +"Wrap in new section" = "Bungkus dalam section baru" +"Yellow" = "Kuning" +"Yesterday" = "Kemarin" +"You" = "Anda" +"You'll get the shared file as a new tab and edit it together." = "Anda akan mendapat file bersama sebagai tab baru dan mengeditnya bersama." +"You're spotlighting: everyone follows your view" = "Anda sedang spotlight: semua orang mengikuti tampilan Anda" +"Your copy of the file stays open" = "Salinan file Anda tetap terbuka" +"Your copy stays open; the file stays on the server" = "Salinan Anda tetap terbuka; file tetap ada di server" +"Your name" = "Nama Anda" +"Your name in comments" = "Nama Anda di komentar" +"Your system asks for reduced motion: animations stay off." = "Sistem Anda meminta gerakan dikurangi: animasi tetap nonaktif." +"Z" = "Z" +"Z to A" = "Z ke A" +"Zoom" = "Zoom" +"Zoom around the pointer" = "Zoom berpusat di penunjuk" +"Zoom device to fill window" = "Zoom perangkat agar memenuhi jendela" +"Zoom in" = "Perbesar" +"Zoom out" = "Perkecil" +"Zoom to 100%" = "Zoom ke 100%" +"Zoom to 200%" = "Zoom ke 200%" +"Zoom to 50%" = "Zoom ke 50%" +"Zoom to fit" = "Zoom agar pas" +"Zoom to selection" = "Zoom ke pilihan" +"Zoom tool: click to zoom in, drag an area" = "Alat zoom: klik untuk memperbesar, seret area" +"Zoom tool: zoom out" = "Alat zoom: perkecil" +"and {n} more" = { other = "dan {n} lainnya" } +"bleed marks" = "tanda bleed" +"by {author}" = "oleh {author}" +"call any command its access allows" = "memanggil perintah apa pun yang diizinkan aksesnya" +"call only: {commands}" = "hanya memanggil: {commands}" +"can edit the document" = "dapat mengedit dokumen" +"crop marks" = "tanda potong" +"development: {path}" = "pengembangan: {path}" +"e.g. count + 1" = "mis. count + 1" +"e.g. count >= 3 and not done" = "mis. count >= 3 and not done" +"figcraft:// links from a browser, chat or email open this app and join the session." = "Link figcraft:// dari browser, chat, atau email membuka aplikasi ini dan bergabung ke sesi." +"from {server}" = "dari {server}" +"hidden" = "tersembunyi" +"iOS" = "iOS" +"just now" = "baru saja" +"locked" = "terkunci" +"no bleed" = "tanpa bleed" +"nobody else here yet" = "belum ada orang lain di sini" +"now" = "sekarang" +"or" = "atau" +"put a widget on the canvas" = "menaruh widget di kanvas" +"read and edit the document" = "membaca dan mengedit dokumen" +"read the document" = "membaca dokumen" +"reads the document" = "membaca dokumen" +"the plugin" = "plugin" +"this session only" = "hanya sesi ini" +"yesterday" = "kemarin" +"{instances} in {files}" = "{instances} di {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: terjemahan draf, menunggu tinjauan penutur asli." +"{length} bleed" = "bleed {length}" +"{month} {day}, {year}" = "{day} {month} {year}" +"{name} cleared from the list" = "{name} dihapus dari daftar" +"{name} is forgotten on this computer; the file stays on {server}" = "{name} dilupakan di komputer ini; file tetap ada di {server}" +"{name} is on the server." = "{name} ada di server." +"{name} is spotlighting" = "{name} sedang spotlight" +"{name}\nClick to apply to the selection" = "{name}\nKlik untuk menerapkan ke pilihan" +"{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}\nSeret ke kanvas, atau klik untuk menaruh di tengah tampilan.\nTahan Alt (Option) saat melepas di atas instance untuk menukarnya." +"{name} (from {library})" = "{name} (dari {library})" +"{name} (you)" = "{name} (Anda)" +"{name} ({layers} layers)" = "{name} ({layers} layer)" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, default {default}. Klik dua kali slider untuk kembali ke nilai gaya; Escape saat menyeret untuk membatalkan." +"{name} from {path}" = "{name} dari {path}" +"{name} was removed from the server" = "{name} dihapus dari server" +"{name} · deleted on main" = "{name} · dihapus di main" +"{name} · deleted on the branch" = "{name} · dihapus di branch" +"{name} · not published" = "{name} · belum dipublikasikan" +"{name} · on a server" = "{name} · di server" +"{name} · published version {v}" = "{name} · versi terpublikasi {v}" +"{name} · version {v} on the server" = "{name} · versi {v} di server" +"{name}, in a live session" = "{name}, dalam sesi langsung" +"{name}, online, open only to you" = "{name}, online, hanya terbuka untuk Anda" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} x {h}, dipilih" +"{n} added" = { other = "{n} ditambahkan" } +"{n} autosave versions" = { other = "{n} versi simpan otomatis" } +"{n} changed" = { other = "{n} diubah" } +"{n} columns" = { other = "{n} kolom" } +"{n} components" = { other = "{n} component" } +"{n} components and styles have newer versions" = { other = "{n} component dan gaya memiliki versi lebih baru" } +"{n} conflicts to resolve" = { other = "{n} konflik untuk diselesaikan" } +"{n} days ago" = { other = "{n} hari lalu" } +"{n} files" = { other = "{n} file" } +"{n} hours ago" = { other = "{n} jam lalu" } +"{n} instances" = { other = "{n} instance" } +"{n} layers" = { other = "{n} layer" } +"{n} layers as one file" = { other = "{n} layer sebagai satu file" } +"{n} layers selected" = { other = "{n} layer dipilih" } +"{n} layers · {parts}" = { other = "{n} layer · {parts}" } +"{n} matches" = { other = "{n} kecocokan" } +"{n} minutes ago" = { other = "{n} menit lalu" } +"{n} months ago" = { other = "{n} bulan lalu" } +"{n} of {total}" = "{n} dari {total}" +"{n} people joined" = { other = "{n} orang bergabung" } +"{n} preferred components" = { other = "{n} component pilihan" } +"{n} ready" = { other = "{n} siap" } +"{n} removed" = { other = "{n} dihapus" } +"{n} replies" = { other = "{n} balasan" } +"{n} rows" = { other = "{n} baris" } +"{n} styles" = { other = "{n} gaya" } +"{n} variables" = { other = "{n} variabel" } +"{n} weeks ago" = { other = "{n} minggu lalu" } +"{n} years ago" = { other = "{n} tahun lalu" } +"{n}d" = "{n} hr" +"{n}h" = "{n} j" +"{n}m" = "{n} mnt" +"{n}w" = "{n} mgg" +"{page}, nothing selected" = "{page}, tidak ada yang dipilih" +"{profile} (current)" = "{profile} (saat ini)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} adalah nilainya; {property ? \"a\" : \"b\"} memilih string. {…} lainnya tetap seperti tertulis." +"{server} can't say what became of your files (it is older than this app)." = "{server} tidak dapat memberi tahu nasib file Anda (versinya lebih lama dari aplikasi ini)." +"{server} · No longer on the server" = "{server} · Tidak ada lagi di server" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · Dibuka {date}" +"{tool} hold {key}" = "{tool} tahan {key}" +"{value} isn't a colour" = "{value} bukan warna" +"{when} · edited" = "{when} · diedit" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"←" = "←" +"→, Space or N" = "→, Spasi atau N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ panah" +"⌘ click" = "⌘ klik" +"⌘ scroll" = "⌘ gulir" +"⌥ drag" = "⌥ seret" + +[commands] +"align.bottom" = "Ratakan bawah" +"align.hcenter" = "Ratakan tengah horizontal" +"align.left" = "Rata kiri" +"align.right" = "Rata kanan" +"align.top" = "Ratakan atas" +"align.vcenter" = "Ratakan tengah vertikal" +"annotation.add" = "Tambah anotasi" +"annotation.addCategory" = "Tambah kategori anotasi" +"annotation.categories" = "Kategori anotasi" +"annotation.edit" = "Edit anotasi" +"annotation.editCategory" = "Edit kategori anotasi" +"annotation.list" = "Daftar anotasi" +"annotation.remove" = "Hapus anotasi" +"annotation.removeCategory" = "Hapus kategori anotasi" +"app.actions" = "Tindakan" +"app.preferences" = "Preferensi" +"arrange.back" = "Bawa ke paling belakang" +"arrange.backward" = "Bawa mundur" +"arrange.forward" = "Bawa maju" +"arrange.front" = "Bawa ke paling depan" +"arrange.packH" = "Rapatkan horizontal" +"arrange.packV" = "Rapatkan vertikal" +"arrange.roundToPixels" = "Bulatkan ke piksel" +"arrange.tidy" = "Rapikan" +"bool.exclude" = "Kecualikan pilihan" +"bool.flatten" = "Satukan boolean" +"bool.intersect" = "Iriskan pilihan" +"bool.subtract" = "Kurangi pilihan" +"bool.union" = "Gabungkan pilihan" +"branch.archive" = "Arsipkan branch" +"branch.create" = "Buat branch" +"branch.list" = "Daftar branch" +"branch.merge" = "Merge branch" +"branch.open" = "Buka branch" +"branch.review" = "Tinjau branch" +"branch.updateFromMain" = "Perbarui dari main" +"codeConnect.export" = "Ekspor koneksi kode" +"codeConnect.import" = "Impor koneksi kode" +"codeConnect.remove" = "Putuskan dari kode" +"codeConnect.set" = "Hubungkan ke kode" +"collab.cursor" = "Tampilkan kursor di" +"collab.cursorChat" = "Chat kursor" +"collab.diff" = "Tampilkan perubahan sinkronisasi" +"collab.files" = "File online Anda" +"collab.follow" = "Ikuti" +"collab.forgetFile" = "Lupakan file online" +"collab.host" = "Mulai sesi langsung" +"collab.join" = "Gabung sesi langsung" +"collab.leave" = "Keluar dari sesi langsung" +"collab.manageFile" = "Kelola file yang dipublikasikan" +"collab.openFile" = "Buka file online" +"collab.peers" = "Orang di file ini" +"collab.publish" = "Publikasikan ke server" +"collab.recoverFile" = "Pulihkan file online" +"collab.registerLinks" = "Buka link undangan di FigCraft" +"collab.report" = "Laporkan file ke operator server" +"collab.serverInfo" = "Info server" +"collab.share" = "Bagikan dengan kode bergabung" +"collab.spotlight" = "Sorot saya" +"collab.stop" = "Hentikan sesi langsung" +"command.batch" = "Jalankan beberapa perintah" +"comment.add" = "Tambah komentar" +"comment.delete" = "Hapus komentar" +"comment.edit" = "Edit komentar" +"comment.link" = "Salin link ke komentar" +"comment.list" = "Daftar komentar" +"comment.markRead" = "Tandai komentar sudah dibaca" +"comment.move" = "Pindahkan komentar" +"comment.next" = "Komentar berikutnya" +"comment.open" = "Buka komentar" +"comment.people" = "Orang di komentar" +"comment.react" = "Beri reaksi pada komentar" +"comment.reply" = "Balas komentar" +"comment.resolve" = "Selesaikan komentar" +"comment.zoom" = "Zoom ke komentar" +"component.addProperty" = "Tambah properti component" +"component.addVariant" = "Tambah variant" +"component.bindProperty" = "Ikat layer ke properti" +"component.combineVariants" = "Gabungkan sebagai variant" +"component.create" = "Buat component" +"component.createMultiple" = "Buat beberapa component" +"component.deleteProperty" = "Hapus properti component" +"component.detach" = "Lepaskan instance" +"component.editProperty" = "Edit properti component" +"component.exposeInstance" = "Tampilkan instance bersarang" +"component.goToMain" = "Buka main component" +"component.hideWhenPublishing" = "Sembunyikan saat dipublikasikan" +"component.list" = "Daftar component" +"component.makeSlot" = "Jadikan slot" +"component.playground" = "Playground component" +"component.properties" = "Properti component" +"component.pushChanges" = "Terapkan perubahan ke main component" +"component.removeSlot" = "Hapus slot" +"component.resetInstance" = "Reset semua perubahan" +"component.resetOverride" = "Reset override" +"component.restoreMain" = "Pulihkan main component" +"dev.compare" = "Bandingkan perubahan" +"dev.focus" = "Tampilan fokus" +"dev.resolveChange" = "Selesaikan perubahan" +"dev.unfocus" = "Keluar dari tampilan fokus" +"devResource.add" = "Tambah sumber daya dev" +"devResource.edit" = "Edit sumber daya dev" +"devResource.list" = "Daftar sumber daya dev" +"devResource.remove" = "Hapus sumber daya dev" +"distribute.horizontal" = "Sebarkan jarak horizontal" +"distribute.vertical" = "Sebarkan jarak vertikal" +"doc.home" = "Beranda" +"doc.list" = "Daftar dokumen" +"doc.nextTab" = "Tab berikutnya" +"doc.previousTab" = "Tab sebelumnya" +"doc.switch" = "Ganti dokumen" +"edit.copy" = "Salin" +"edit.copyAsCss" = "Salin sebagai CSS" +"edit.copyAsPng" = "Salin sebagai PNG" +"edit.copyAsSvg" = "Salin sebagai SVG" +"edit.copyAsText" = "Salin sebagai teks" +"edit.copyLink" = "Salin link ke pilihan" +"edit.copyProperties" = "Salin properti" +"edit.cut" = "Potong" +"edit.delete" = "Hapus" +"edit.duplicate" = "Duplikat" +"edit.findReplace" = "Cari dan ganti" +"edit.paste" = "Tempel" +"edit.pasteOver" = "Tempel di atas pilihan" +"edit.pasteProperties" = "Tempel properti" +"edit.pasteReplace" = "Tempel dan ganti" +"edit.pickColor" = "Ambil warna" +"edit.redo" = "Ulangi" +"edit.repeat" = "Ulangi perintah terakhir" +"edit.setDefaultProperties" = "Atur properti default" +"edit.undo" = "Urungkan" +"effect.add" = "Tambah efek" +"effect.move" = "Pindahkan efek" +"effect.remove" = "Hapus efek" +"effect.set" = "Atur efek" +"export.add" = "Tambah pengaturan ekspor" +"export.remove" = "Hapus pengaturan ekspor" +"export.update" = "Edit pengaturan ekspor" +"file.close" = "Tutup tab" +"file.colorProfile" = "Profil warna" +"file.duplicate" = "Duplikat file" +"file.export" = "Ekspor" +"file.exportFramesPdf" = "Ekspor frame ke PDF" +"file.exportLayers" = "Ekspor layer" +"file.exportSvg" = "Ekspor pilihan sebagai SVG" +"file.import" = "Impor" +"file.new" = "File desain baru" +"file.newFromTemplate" = "Baru dari templat" +"file.open" = "Buka" +"file.openRecent" = "Buka terbaru" +"file.placeImage" = "Tempatkan gambar" +"file.recover" = "Pulihkan file" +"file.rename" = "Ganti nama file" +"file.revert" = "Kembalikan ke versi tersimpan" +"file.save" = "Simpan" +"file.saveAs" = "Simpan sebagai" +"file.saveVersion" = "Simpan ke riwayat versi" +"file.versionHistory" = "Tampilkan riwayat versi" +"fill.add" = "Tambah fill" +"fill.move" = "Pindahkan fill" +"fill.pattern" = "Fill pola" +"fill.remove" = "Hapus fill" +"fill.set" = "Atur fill" +"frame.create" = "Buat frame" +"frame.presets" = "Daftar preset frame" +"frame.swapOrientation" = "Tukar orientasi" +"grid.add" = "Tambah layout grid" +"grid.move" = "Pindahkan layout grid" +"grid.remove" = "Hapus layout grid" +"guide.add" = "Tambah garis bantu" +"guide.clear" = "Hapus semua garis bantu" +"guide.move" = "Pindahkan garis bantu" +"guide.remove" = "Hapus garis bantu" +"guide.select" = "Pilih garis bantu" +"help.gettingStarted" = "Memulai" +"help.about" = "Tentang FigCraft" +"help.page" = "Halaman bantuan" +"help.shortcuts" = "Pintasan keyboard" +"image.adjust" = "Sesuaikan gambar" +"image.deletePreset" = "Hapus preset gambar" +"image.place" = "Tempatkan gambar" +"image.presets" = "Preset gambar" +"image.savePreset" = "Simpan preset gambar" +"inspect.assets" = "Inspeksi aset" +"inspect.code" = "Kode untuk layer" +"inspect.componentCode" = "Kode component" +"inspect.properties" = "Inspeksi properti" +"inspect.readyForDev" = "Siap untuk dev" +"instance.create" = "Buat instance" +"instance.exposed" = "Instance bersarang yang ditampilkan" +"instance.place" = "Tempatkan instance" +"instance.resetSlot" = "Reset slot" +"instance.setProperties" = "Atur properti instance" +"instance.slotInsert" = "Sisipkan ke slot" +"instance.swap" = "Tukar instance" +"instance.swapAt" = "Tukar instance di titik" +"layers.collapseAll" = "Ciutkan layer" +"layout.add" = "Tambah auto layout" +"layout.remove" = "Hapus auto layout" +"layout.setTrack" = "Atur ukuran track grid" +"layout.suggest" = "Sarankan auto layout" +"libraries.open" = "Pustaka" +"libraries.publish" = "Publikasikan pustaka…" +"library.acceptUpdates" = "Terima pembaruan pustaka" +"library.add" = "Tambah pustaka" +"library.aliasVariable" = "Buat alias variabel pustaka" +"library.applyStyle" = "Terapkan gaya pustaka" +"library.bindVariable" = "Ikat variabel pustaka" +"library.extendCollection" = "Perluas koleksi pustaka" +"library.import" = "Impor component pustaka" +"library.importVariable" = "Impor variabel pustaka" +"library.list" = "Daftar pustaka" +"library.place" = "Tempatkan component pustaka" +"library.publish" = "Publikasikan pustaka" +"library.publishToServer" = "Publikasikan pustaka ke server" +"library.remove" = "Hapus pustaka" +"library.setVariableMode" = "Atur mode variabel pustaka" +"library.swap" = "Tukar pustaka" +"library.swapAt" = "Tukar instance ke component pustaka" +"library.updates" = "Pembaruan pustaka" +"library.usage" = "Penggunaan pustaka" +"measure.distances" = "Ukur jarak" +"measure.padding" = "Ukur padding auto layout" +"node.create" = "Buat layer" +"node.get" = "Ambil layer" +"node.reorder" = "Ubah urutan layer" +"node.reparent" = "Pindahkan layer ke dalam" +"node.set" = "Atur properti" +"object.batchRename" = "Ganti nama massal" +"object.convertToFrame" = "Konversi ke frame" +"object.convertToGroup" = "Konversi ke grup" +"object.flipH" = "Balik horizontal" +"object.flipV" = "Balik vertikal" +"object.frameSelection" = "Buat frame dari pilihan" +"object.group" = "Grupkan pilihan" +"object.hideOthers" = "Sembunyikan layer lain" +"object.mask" = "Gunakan sebagai mask" +"object.moveToPage" = "Pindahkan ke halaman" +"object.opacityKeys" = "Opasitas dari tombol angka" +"object.rasterize" = "Rasterisasi pilihan" +"object.releaseMask" = "Lepaskan mask" +"object.removeFill" = "Hapus fill" +"object.removeFrame" = "Hapus frame" +"object.removeStroke" = "Hapus stroke" +"object.rename" = "Ganti nama" +"object.resizeToFit" = "Ubah ukuran agar pas" +"object.rotate180" = "Putar 180°" +"object.rotateLeft" = "Putar 90° ke kiri" +"object.rotateRight" = "Putar 90° ke kanan" +"object.setThumbnail" = "Jadikan thumbnail" +"object.swapFillStroke" = "Tukar fill dan stroke" +"object.toggleLock" = "Kunci/Buka kunci pilihan" +"object.toggleVisible" = "Tampilkan/Sembunyikan pilihan" +"object.ungroup" = "Pisahkan grup pilihan" +"object.wrapInSection" = "Bungkus dalam section baru" +"page.add" = "Tambah halaman" +"page.delete" = "Hapus halaman" +"page.duplicate" = "Duplikat halaman" +"page.list" = "Daftar halaman" +"page.move" = "Pindahkan halaman" +"page.rename" = "Ganti nama halaman" +"page.select" = "Buka halaman" +"page.setBackground" = "Latar belakang halaman" +"paint.documentColors" = "Warna dokumen" +"plugin.close" = "Tutup panel plugin" +"plugin.directory.install" = "Instal plugin dari direktori" +"plugin.directory.list" = "Jelajahi direktori plugin" +"plugin.directory.publish" = "Publikasikan plugin ke direktori" +"plugin.directory.unpublish" = "Batalkan publikasi plugin dari direktori" +"plugin.event" = "Gunakan kontrol panel plugin" +"plugin.import" = "Impor plugin dari manifest…" +"plugin.list" = "Daftar plugin" +"plugin.remove" = "Hapus plugin" +"plugin.run" = "Jalankan plugin" +"plugin.ui" = "Panel plugin" +"plugins.manage" = "Kelola plugin…" +"plugins.runLast" = "Jalankan plugin terakhir" +"pref.highlightHover" = "Sorot layer saat hover" +"pref.keepTool" = "Tetap pilih alat setelah dipakai" +"pref.language" = "Bahasa antarmuka" +"pref.nudge" = "Jarak geser" +"pref.numberKeysOpacity" = "Gunakan tombol angka untuk opasitas" +"pref.publishServer" = "Server untuk berbagi online" +"pref.reduceMotion" = "Kurangi gerakan" +"pref.renameDuplicates" = "Ganti nama layer duplikat" +"pref.showDimensions" = "Tampilkan dimensi pada objek" +"pref.userName" = "Nama Anda di komentar" +"proto.action.add" = "Tambah aksi prototype" +"proto.action.move" = "Ubah urutan aksi prototype" +"proto.action.remove" = "Hapus aksi prototype" +"proto.action.set" = "Edit aksi prototype" +"proto.closePreview" = "Tutup pratinjau prototype" +"proto.conditional.addBlock" = "Tambah else if / else" +"proto.conditional.removeBlock" = "Hapus if / else if / else" +"proto.conditional.setCondition" = "Edit kondisi" +"proto.evaluate" = "Evaluasi ekspresi prototype" +"proto.flow.add" = "Tambah titik awal flow" +"proto.flow.remove" = "Hapus flow" +"proto.flow.rename" = "Ganti nama flow" +"proto.interaction.add" = "Tambah interaksi" +"proto.interaction.clear" = "Hapus semua interaksi" +"proto.interaction.remove" = "Hapus interaksi" +"proto.interaction.set" = "Edit interaksi" +"proto.present" = "Presentasikan" +"proto.presentOptions" = "Opsi presentasi" +"proto.preview" = "Buka pratinjau prototype" +"proto.settings" = "Pengaturan prototype" +"section.toggleContents" = "Sembunyikan/Tampilkan isi section" +"select.add" = "Tambah ke pilihan" +"select.all" = "Pilih semua" +"select.area" = "Pilih area" +"select.byName" = "Pilih layer berdasarkan nama" +"select.children" = "Pilih anak" +"select.inverse" = "Balik pilihan" +"select.layersAt" = "Layer di satu titik" +"select.matching" = "Pilih layer yang cocok" +"select.next" = "Pilih saudara berikutnya" +"select.none" = "Batalkan pilihan" +"select.parent" = "Pilih induk" +"select.previous" = "Pilih saudara sebelumnya" +"select.remove" = "Hapus dari pilihan" +"select.sameEffect" = "Pilih efek yang sama" +"select.sameFill" = "Pilih fill yang sama" +"select.sameFont" = "Pilih font yang sama" +"select.sameInstance" = "Pilih instance yang sama" +"select.sameProperties" = "Pilih properti yang sama" +"select.sameStroke" = "Pilih stroke yang sama" +"select.sameText" = "Pilih properti teks yang sama" +"select.set" = "Pilih" +"shape.arrow" = "Panah" +"shape.ellipse" = "Elips" +"shape.line" = "Garis" +"shape.polygon" = "Poligon" +"shape.rectangle" = "Persegi panjang" +"shape.star" = "Bintang" +"shapeBuilder.merge" = "Gabungkan area" +"shapeBuilder.mode" = "Mode shape builder" +"shapeBuilder.regions" = "Area shape builder" +"shapeBuilder.remove" = "Hapus area" +"stroke.add" = "Tambah stroke" +"stroke.brush" = "Kuas" +"stroke.move" = "Pindahkan stroke" +"stroke.remove" = "Hapus stroke" +"stroke.set" = "Atur stroke" +"stroke.widthProfile" = "Profil lebar" +"style.apply" = "Terapkan gaya" +"style.bind" = "Ikat variabel di gaya" +"style.create" = "Buat gaya" +"style.delete" = "Hapus gaya" +"style.detach" = "Lepaskan gaya" +"style.duplicate" = "Duplikat gaya" +"style.list" = "Daftar gaya" +"style.move" = "Pindahkan gaya" +"style.renameFolder" = "Ganti nama folder gaya" +"style.update" = "Edit gaya" +"text.alignCenter" = "Rata tengah" +"text.alignJustify" = "Rata kiri-kanan" +"text.alignLeft" = "Rata kiri" +"text.alignRight" = "Rata kanan" +"text.bold" = "Tebal" +"text.bulletList" = "Daftar berpoin" +"text.case" = "Huruf besar/kecil" +"text.caseLower" = "Huruf kecil" +"text.caseOriginal" = "Kapitalisasi asli" +"text.caseSmallCaps" = "Kapital kecil" +"text.caseTitle" = "Huruf Awal Kapital" +"text.caseUpper" = "Huruf kapital" +"text.create" = "Teks" +"text.decoration" = "Dekorasi" +"text.edit.begin" = "Edit teks" +"text.edit.caret" = "Atur kursor" +"text.edit.end" = "Berhenti mengedit teks" +"text.edit.insert" = "Ketik teks" +"text.edit.key" = "Tombol teks" +"text.edit.preedit" = "Susun teks (IME)" +"text.edit.selectLine" = "Pilih baris" +"text.edit.selectWord" = "Pilih kata" +"text.edit.selection" = "Pilihan teks" +"text.endPreview" = "Akhiri pratinjau teks" +"text.fonts" = "Daftar font" +"text.italic" = "Miring" +"text.lineHeightDown" = "Kurangi tinggi baris" +"text.lineHeightUp" = "Tambah tinggi baris" +"text.link" = "Buat link" +"text.listIndent" = "Tambah indentasi daftar" +"text.listOutdent" = "Kurangi indentasi daftar" +"text.listStyle" = "Gaya daftar" +"text.maxLines" = "Baris maksimum" +"text.numberList" = "Daftar bernomor" +"text.onPath" = "Teks pada path" +"text.paragraphIndent" = "Indentasi paragraf" +"text.paragraphSpacing" = "Spasi paragraf" +"text.pathOptions" = "Opsi path teks" +"text.preview" = "Pratinjau perubahan teks" +"text.removeFromPath" = "Lepaskan teks dari path" +"text.set" = "Atur teks" +"text.setFeatures" = "Fitur OpenType" +"text.setVariations" = "Sumbu font variabel" +"text.sizeDown" = "Perkecil ukuran font" +"text.sizeUp" = "Perbesar ukuran font" +"text.strikethrough" = "Coret" +"text.style" = "Gaya rentang teks" +"text.trackingDown" = "Kurangi spasi huruf" +"text.trackingUp" = "Tambah spasi huruf" +"text.underline" = "Garis bawah" +"text.weightDown" = "Kurangi ketebalan font" +"text.weightUp" = "Tambah ketebalan font" +"tool.arrow" = "Panah" +"tool.comment" = "Komentar" +"tool.ellipse" = "Elips" +"tool.frame" = "Frame" +"tool.hand" = "Tangan" +"tool.line" = "Garis" +"tool.move" = "Pindahkan" +"tool.pen" = "Pena" +"tool.pencil" = "Pensil" +"tool.pencilBrush" = "Kuas pensil" +"tool.place" = "Tempatkan dengan alat" +"tool.polygon" = "Poligon" +"tool.rectangle" = "Persegi panjang" +"tool.scale" = "Skala" +"tool.scaleAnchor" = "Titik acuan skala" +"tool.section" = "Section" +"tool.set" = "Pilih alat" +"tool.shapeBuilder" = "Shape builder" +"tool.slice" = "Slice" +"tool.star" = "Bintang" +"tool.text" = "Teks" +"transform.move" = "Pindahkan" +"transform.moveTo" = "Pindahkan ke" +"transform.nudge" = "Geser sedikit" +"transform.nudgeSize" = "Ubah ukuran dengan geser sedikit" +"transform.resize" = "Ubah ukuran" +"transform.rotate" = "Putar" +"transform.rotationOrigin" = "Pindahkan titik pusat rotasi" +"transform.scale" = "Skala" +"transform.setRotationOrigin" = "Atur titik pusat rotasi" +"variable.addMode" = "Tambah mode" +"variable.bind" = "Ikat variabel" +"variable.create" = "Buat variabel" +"variable.createCollection" = "Buat koleksi variabel" +"variable.delete" = "Hapus variabel" +"variable.deleteCollection" = "Hapus koleksi" +"variable.duplicate" = "Duplikat variabel" +"variable.export" = "Ekspor variabel" +"variable.import" = "Impor variabel" +"variable.list" = "Daftar variabel" +"variable.moveMode" = "Pindahkan mode" +"variable.removeMode" = "Hapus mode" +"variable.renameCollection" = "Ganti nama koleksi" +"variable.renameMode" = "Ganti nama mode" +"variable.resetOverride" = "Reset override variabel" +"variable.set" = "Atur nilai variabel" +"variable.setMode" = "Atur mode variabel" +"variable.update" = "Edit variabel" +"vector.create" = "Vector" +"vector.deleteHeal" = "Hapus dan sambungkan pilihan" +"vector.edit.addVertex" = "Tambah titik" +"vector.edit.begin" = "Edit vector" +"vector.edit.bend" = "Lengkungkan segmen" +"vector.edit.bounds" = "Batas titik terpilih" +"vector.edit.connect" = "Hubungkan titik" +"vector.edit.cornerRadius" = "Radius sudut titik" +"vector.edit.cut" = "Potong path" +"vector.edit.delete" = "Hapus titik" +"vector.edit.end" = "Selesai mengedit" +"vector.edit.erase" = "Hapus sebagian" +"vector.edit.lasso" = "Pilih dengan laso" +"vector.edit.mirroring" = "Pencerminan handle" +"vector.edit.moveHandles" = "Pindahkan handle" +"vector.edit.moveVertices" = "Pindahkan titik" +"vector.edit.nudge" = "Geser sedikit titik" +"vector.edit.paint" = "Ember cat" +"vector.edit.pen" = "Titik pena" +"vector.edit.penEnd" = "Akhiri path pena" +"vector.edit.position" = "Posisi titik" +"vector.edit.resize" = "Ubah ukuran titik" +"vector.edit.rotate" = "Putar titik" +"vector.edit.select" = "Pilih titik" +"vector.edit.selectAll" = "Pilih semua titik" +"vector.edit.setHandle" = "Atur handle" +"vector.edit.smooth" = "Haluskan atau tajamkan titik" +"vector.edit.state" = "Status edit vector" +"vector.edit.strokeCap" = "Ujung stroke titik" +"vector.edit.strokeJoin" = "Sambungan stroke titik" +"vector.edit.tool" = "Alat edit vector" +"vector.edit.transform" = "Transformasi titik" +"vector.edit.widthPoint" = "Titik lebar" +"vector.flatten" = "Satukan" +"vector.join" = "Gabungkan pilihan" +"vector.offset" = "Offset path" +"vector.outlineStroke" = "Outline stroke" +"vector.reverse" = "Balik arah path" +"vector.simplify" = "Sederhanakan vector" +"vector.smoothJoin" = "Gabungkan pilihan dengan halus" +"version.autosave" = "Simpan versi otomatis" +"version.delete" = "Hapus versi" +"version.duplicate" = "Duplikat versi" +"version.exitPreview" = "Kembali ke versi saat ini" +"version.list" = "Daftar versi" +"version.preview" = "Lihat versi" +"version.rename" = "Ganti nama versi" +"version.restore" = "Pulihkan versi" +"view.assets" = "Panel aset" +"view.comments" = "Komentar" +"view.contextMenu" = "Tampilkan menu konteks" +"view.design" = "Panel desain" +"view.designPrototype" = "Beralih antara Desain dan Prototype" +"view.devMode" = "Dev Mode" +"view.frameOutlines" = "Outline frame" +"view.get" = "Ambil tampilan" +"view.layers" = "Panel layer" +"view.layoutGrids" = "Grid tata letak" +"view.leftSidebar" = "Tampilkan/Sembunyikan sidebar kiri" +"view.maskOutlines" = "Outline mask" +"view.multiplayerCursors" = "Kursor multipemain" +"view.nextFrame" = "Zoom ke frame berikutnya" +"view.nextPage" = "Halaman berikutnya" +"view.nextRegion" = "Fokus ke area berikutnya" +"view.outlines" = "Garis luar" +"view.outlinesHiddenLayers" = "Sertakan layer tersembunyi di outline" +"view.outlinesObjectBounds" = "Sertakan batas objek di outline" +"view.outlinesSelection" = "Outline untuk pilihan" +"view.pan" = "Geser tampilan" +"view.panelWidths" = "Reset lebar panel" +"view.pixelGrid" = "Grid piksel" +"view.pixelPreview" = "Pratinjau piksel" +"view.prevFrame" = "Zoom ke frame sebelumnya" +"view.prevPage" = "Halaman sebelumnya" +"view.previousRegion" = "Fokus ke area sebelumnya" +"view.proofSrgb" = "Soft-proof sRGB" +"view.fullscreen" = "Layar penuh" +"view.prototype" = "Panel prototype" +"view.rulers" = "Penggaris" +"view.set" = "Atur tampilan" +"view.slices" = "Slice" +"view.snapGeometry" = "Lekatkan ke geometri" +"view.snapObjects" = "Lekatkan ke objek" +"view.snapPixel" = "Snap ke grid piksel" +"view.themeDark" = "Tema gelap" +"view.themeLight" = "Tema terang" +"view.themeSystem" = "Ikuti tema sistem" +"view.toggleUi" = "Tampilkan/Sembunyikan UI" +"view.zoom100" = "Zoom ke 100%" +"view.zoom200" = "Zoom ke 200%" +"view.zoom50" = "Zoom ke 50%" +"view.zoomAt" = "Alat zoom" +"view.zoomFit" = "Zoom agar pas" +"view.zoomIn" = "Perbesar" +"view.zoomOut" = "Perkecil" +"view.zoomSelection" = "Zoom ke pilihan" +"widget.click" = "Klik widget" +"widget.insert" = "Sisipkan widget" +"widget.list" = "Daftar widget" +"window.focus" = "Bawa jendela ke depan" +"window.new" = "Jendela baru" +"template.list" = "Daftar templat" + +[menu] +"bool.exclude" = "Kecualikan" +"bool.flatten" = "Satukan" +"bool.intersect" = "Iriskan" +"bool.subtract" = "Kurangi" +"bool.union" = "Gabungkan" +"file.duplicate" = "Duplikat" +"file.import" = "Impor dari file…" +"file.rename" = "Ganti nama" +"libraries.open" = "Buka pustaka" +"select.sameEffect" = "Efek" +"select.sameFill" = "Fill" +"select.sameFont" = "Font" +"select.sameInstance" = "Instance" +"select.sameProperties" = "Properti" +"select.sameStroke" = "Stroke" +"select.sameText" = "Properti teks" +"text.alignCenter" = "Rata tengah teks" +"text.alignJustify" = "Rata kiri-kanan teks" +"text.alignLeft" = "Rata kiri teks" +"text.alignRight" = "Rata kanan teks" +"view.assets" = "Aset" +"view.design" = "Desain" +"view.layers" = "Layer" +"view.outlines" = "Tampilkan outline" +"view.outlinesHiddenLayers" = "Sertakan layer tersembunyi" +"view.outlinesObjectBounds" = "Sertakan batas objek" +"view.outlinesSelection" = "Tampilkan outline untuk pilihan" +"view.prototype" = "Prototype" +"view.themeDark" = "Gelap" +"view.themeLight" = "Terang" +"view.themeSystem" = "Ikuti sistem" diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs index 354d0e605..b199e1a7d 100644 --- a/crates/ui-egui/src/cjk.rs +++ b/crates/ui-egui/src/cjk.rs @@ -128,7 +128,7 @@ mod tests { assert_eq!(language("zh-Hant"), HAN); assert_eq!(language("zh-Hans"), HAN); assert_eq!(language("fr"), 0); - assert!(["en", "es", "es-419", "fr", "de", "pt-BR"].iter().all(|c| can_show(c))); + assert!(["en", "es", "es-419", "fr", "de", "pt-BR", "id"].iter().all(|c| can_show(c))); } #[test] diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 768434e34..8b41fbe98 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -309,6 +309,7 @@ fn system_languages_pick_the_first_one_with_a_catalog() { assert_eq!(code("system", Some("fr-CA")), "fr"); assert_eq!(code("system", Some("de_CH.UTF-8")), "de"); assert_eq!(code("system", Some("pt-BR")), "pt-BR"); + assert_eq!(code("system", Some("id_ID.UTF-8")), "id"); // 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"); diff --git a/docs/gaps.md b/docs/gaps.md index 34c4f4390..d76eb8732 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -144,8 +144,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ### 5. Localization: English plus unreviewed drafts - **Missing:** native reviews of the Spanish, French, German, Brazilian Portuguese, Japanese, - Korean and Simplified Chinese drafts and the Latin American Spanish overlay, the 25 newest strings - in five drafts, Hindi, Arabic, Indonesian and Vietnamese, localized engine messages and number + Korean, Simplified Chinese and Indonesian drafts and the Latin American Spanish overlay, the 25 newest strings + in five drafts, Hindi, Arabic and Vietnamese, localized engine messages and number formatting; CJK UI text on the web without the craft-fonts CJK build. - **Evidence (measured from source):** the layer landed 2026-10-11: catalogs in `crates/ui-egui/locales/` (1,371 UI keys plus 524 command labels and 29 menu wordings), diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 99f0f1a70..db59218f1 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,9 +1,9 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Indonesian draft catalog; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog and Spanish (with a Latin -American overlay), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese +American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (machine-drafted, waiting for native speakers' review). This page lists the twelve key languages of the craftrules standard, then the other languages Figma ships. How to add a language: [`translating.md`](translating.md). @@ -28,7 +28,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v document data, such as tool names, blend modes and Dev Mode properties) plus 536 command labels and 29 menu wordings by command id: 1,982 entries. Command ids, the CLI, MCP and the control channel stay English. Accessibility names come from the same strings. -- **Language preference:** Preferences ▸ Language (System, English, Deutsch, Español, Español +- **Language preference:** Preferences ▸ Language (System, English, Bahasa Indonesia, Deutsch, Español, Español (Latinoamérica), Français, 日本語, 한국어, Português (Brasil), 简体中文), applied at once without a restart; System takes the first of the OS's preferred languages that has a catalog (Windows' language list, macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional tag @@ -85,7 +85,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Arabic | ar | 0 (0%) | no | Bidi, shaping and RTL names work; the UI does not mirror | no | none | 9–15 | | French | fr | 2,076 / 2,076 (100%, draft) | yes | Latin | no | draft | 1–2 (after native review) | | Portuguese | pt-BR | 2,076 / 2,076 (100%, draft) | yes | Latin | no | draft | 1–2 (after native review) | -| Indonesian | id | 0 (0%) | no | Latin | no | none | 3–5 | +| Indonesian | id | 2,165 / 2,165 (100%, draft) | yes | Latin | no | draft | 1–2 (after native review) | | Japanese | ja | 2,076 / 2,076 (100%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | | German | de | 2,076 / 2,076 (100%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | | Korean | ko | 2,076 / 2,076 (100%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | @@ -99,8 +99,8 @@ it isn't one/other; every language also needs a native speaker's review (human). parity besides the languages: localizing engine messages and number formatting (4–6 h); right-to-left mirroring of the panels is in Arabic's hours. Total: **29–51 h**. -Priority: native reviews of the seven drafts and the es-419 overlay (every one of Figma's languages -and variants has one, plus Simplified Chinese, which Figma doesn't ship), then Hindi, Indonesian, +Priority: native reviews of the eight drafts and the es-419 overlay (every one of Figma's languages +and variants has one, plus Simplified Chinese, which Figma doesn't ship), then Hindi, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; its users see English). ## Revision history diff --git a/docs/translating.md b/docs/translating.md index b7c26a401..fc9ecfc5d 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -14,7 +14,7 @@ Simplified. 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`) -and Simplified Chinese (`zh-Hans`), machine-drafted and waiting for native speakers' review. +Simplified Chinese (`zh-Hans`) and Indonesian (`id`), machine-drafted and waiting for native speakers' review. ## The catalogs @@ -43,7 +43,10 @@ Enter for the key, control for a gamepad, resorte) and keeps Spanish's tú. Japa concise, without です/ます endings, with full-width ()「」:、。 where Japanese UI text has them; Korean labels end without 합니다 (instructions in -세요). Simplified Chinese is concise, with full-width ():,、。“” in Chinese text and a space between Chinese and Latin words or numbers. -None of the three has plural forms. +None of the three has plural forms. Indonesian uses bare imperatives for buttons and menu items +(Simpan, Hapus), the formal Anda (never kamu), keeps established design terms in English (frame, +layer, component, instance, auto layout, plugin, file) and keeps a noun unchanged after a count, so +its plural entries give only `other`. CJK text needs a CJK font, which egui and Inter lack: the desktop app adds an installed one when something on screen needs it (see `crates/ui-egui/src/cjk.rs`). The web build has no system fonts, @@ -65,7 +68,7 @@ language also needs its script in `cjk::language`. 4. To require full coverage, add the code to `COMPLETE` in [`crates/ui-egui/src/i18n_tests.rs`](../crates/ui-egui/src/i18n_tests.rs). Every later PR that adds a string then has to translate it into each complete language, so only Spanish is there: - French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese are registered as + French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian are registered as drafts, and a string added later shows English in them until it is translated. To list what a draft lacks, add its code to `COMPLETE` locally and run the check. 5. Run the check and look at the panels in the app (longer words must fit the right panel): From 471694dc483b20fb8fb68716e9b1fb57110c94d6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:00:02 +0700 Subject: [PATCH 088/120] #297: recovery retry tests fail the copy with a folder in its place, so they also run on Windows --- apps/figcraft/src/recovery_tests.rs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/figcraft/src/recovery_tests.rs b/apps/figcraft/src/recovery_tests.rs index 18d18f6a2..bada7d98b 100644 --- a/apps/figcraft/src/recovery_tests.rs +++ b/apps/figcraft/src/recovery_tests.rs @@ -69,17 +69,24 @@ fn copy_count(root: &std::path::Path) -> usize { std::fs::read_dir(dir).unwrap().flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count() } +/// A folder at the path of `s`'s first document's copy in the run folder under `root`. +fn blocked_copy(root: &std::path::Path, s: &Session) -> std::path::PathBuf { + let dir = std::fs::read_dir(root).unwrap().flatten().next().unwrap().path(); + let blocker = dir.join(format!("{}.figcraft", s.docs[0].uid)); + std::fs::create_dir(&blocker).unwrap(); + blocker +} + #[test] fn a_copy_that_failed_is_tried_again_without_another_edit() { let root = root("retry"); let s = edited(); let mut r = Recovery::start(&root).unwrap(); - let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path(); - let moved = root.join("moved"); - // The run folder is gone while the copy is written: it fails. - std::fs::rename(&dir, &moved).unwrap(); + // A folder where the copy goes makes its write fail on every platform (renaming the run folder + // away fails on Windows, where the run's lock file keeps it open). + let blocker = blocked_copy(&root, &s); r.write(&s, true); - std::fs::rename(&moved, &dir).unwrap(); + std::fs::remove_dir(&blocker).unwrap(); // Nothing was edited since: the same revision still has to be copied. r.write(&s, true); assert_eq!(copy_count(&root), 1, "the failed revision was copied again"); @@ -92,13 +99,11 @@ fn a_background_copy_that_failed_is_tried_again_without_another_edit() { let root = root("retry-bg"); let s = edited(); let mut r = Recovery::start(&root).unwrap(); - let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path(); - let moved = root.join("moved"); - std::fs::rename(&dir, &moved).unwrap(); + let blocker = blocked_copy(&root, &s); r.write(&s, false); - // Let the failing write end before the folder comes back. + // Let the failing write end before the way is clear again. std::thread::sleep(std::time::Duration::from_millis(300)); - std::fs::rename(&moved, &dir).unwrap(); + std::fs::remove_dir(&blocker).unwrap(); for _ in 0..50 { r.write(&s, false); if copy_count(&root) == 1 { From 8616f3d1c5f0af0c4cb68280df0c5c20cb0bf738 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:11:52 +0700 Subject: [PATCH 089/120] #305: save a file from a snapshot encoded elsewhere FileSnapshot takes what saving writes, encodes it (thumbnail and all) on any thread, and writes it back as of the snapshot: edits since stay unsaved, and bytes from before another save never overwrite the newer file. --- crates/engine/src/autosave_tests.rs | 30 ++++++++++++ crates/engine/src/cmd/file.rs | 73 ++++++++++++++++++++++++++--- 2 files changed, 96 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/autosave_tests.rs b/crates/engine/src/autosave_tests.rs index e9f0427d7..1e8ba30fe 100644 --- a/crates/engine/src/autosave_tests.rs +++ b/crates/engine/src/autosave_tests.rs @@ -92,3 +92,33 @@ fn files_that_save_themselves_still_get_checkpoints() { assert!(!autosave(&mut t)); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn a_file_encoded_elsewhere_is_saved_as_of_its_snapshot() { + use crate::cmd::file::FileSnapshot; + let dir = std::env::temp_dir().join(format!("figcraft-snapshot-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("snap.figcraft").to_string_lossy().to_string(); + let mut s = Session::new(); + s.execute("file.save", &json!({"path": path})).unwrap(); + rect(&mut s, 0.0); + let file = FileSnapshot::of(s.ds().unwrap()); + let bytes = file.encode().unwrap(); + rect(&mut s, 50.0); + assert!(file.save(&mut s, &bytes).unwrap()); + assert!(s.ds().unwrap().is_dirty(), "the edit since the snapshot is still unsaved"); + let mut t = Session::empty(); + t.execute("file.open", &json!({"path": path})).unwrap(); + assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 10.0, "the file is the snapshot"); + // Saved meanwhile: the older bytes never overwrite the newer file. + let file = FileSnapshot::of(s.ds().unwrap()); + let bytes = file.encode().unwrap(); + rect(&mut s, 100.0); + s.execute("file.save", &json!({})).unwrap(); + assert!(!file.save(&mut s, &bytes).unwrap()); + assert!(!s.ds().unwrap().is_dirty()); + let mut t = Session::empty(); + t.execute("file.open", &json!({"path": path})).unwrap(); + assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 110.0); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index c3b243c7b..5043ea402 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -696,10 +696,71 @@ pub fn thumbnail(doc: &Document) -> Option> { /// The `.figcraft` file for a document: content, thumbnail and version history. pub fn file_bytes(ds: &DocState) -> Result> { - let mut doc = (*ds.doc).clone(); - doc.current_page = Some(ds.page); - let thumb = thumbnail(&doc); - figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &ds.versions, &ds.branches).map_err(|e| EngineError::Other(e.to_string())) + FileSnapshot::of(ds).encode() +} + +/// A document's file as it is at one moment, so the slow part of saving it (the thumbnail and the +/// encoding) can run on another thread while editing goes on: [`encode`](Self::encode) there, then +/// [`save`](Self::save) the bytes back on the session's thread. +pub struct FileSnapshot { + uid: u64, + path: Option, + revision: u64, + doc: Arc, + /// The document as last saved when the snapshot was taken: another save since makes it stale. + saved: Arc, + page: NodeId, + versions: Vec, + branches: Vec, +} + +impl FileSnapshot { + pub fn of(ds: &DocState) -> Self { + Self { + uid: ds.uid, + path: ds.path.clone(), + revision: ds.revision, + doc: ds.doc.clone(), + saved: ds.saved_doc.clone(), + page: ds.page, + versions: ds.versions.clone(), + branches: ds.branches.clone(), + } + } + + /// The file's bytes: content, thumbnail and version history. + pub fn encode(&self) -> Result> { + let mut doc = (*self.doc).clone(); + doc.current_page = Some(self.page); + let thumb = thumbnail(&doc); + figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &self.versions, &self.branches) + .map_err(|e| EngineError::Other(e.to_string())) + } + + /// Write `bytes` (this snapshot's [`encode`](Self::encode)) to the document's file: it is saved + /// as of the snapshot, and edits since stay unsaved. False (nothing written) when the document + /// was closed, saved, reverted or given another path since: the bytes are out of date. + pub fn save(self, s: &mut Session, bytes: &[u8]) -> Result { + let Some(ds) = s.docs.iter_mut().find(|d| d.uid == self.uid && d.path == self.path && Arc::ptr_eq(&d.saved_doc, &self.saved)) else { + return Ok(false); + }; + write(self.path.as_deref().ok_or(EngineError::NoDocument)?, bytes)?; + // The history or branches may have changed since: then they still count as unsaved. + if ds.revision == self.revision { + ds.versions_changed = false; + } + ds.saved_doc = self.doc; + ds.revision += 1; + branch_tabs_saved(s, self.uid); + Ok(true) + } +} + +/// The branch tabs of the file in tab `uid` were saved with it. +fn branch_tabs_saved(s: &mut Session, uid: u64) { + for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) { + tab.mark_saved(); + } } fn color_profile(s: &mut Session, p: &Value) -> Result { @@ -724,9 +785,7 @@ fn save_to(s: &mut Session, path: String) -> Result { ds.mark_saved(); ds.revision += 1; let uid = ds.uid; - for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) { - tab.mark_saved(); - } + branch_tabs_saved(s, uid); remember(s, &path); Ok(json!({"path": path, "bytes": bytes.len()})) } From b75e5bea3bed0062914d583fbb8b952f2fc7de44 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:11:54 +0700 Subject: [PATCH 090/120] #305: auto-save after a pause, off the UI thread Nothing is collected while auto-save is off. The wait restarts whenever an unsaved file changes, so continuous editing doesn't save every few seconds, and the file is encoded on a thread of its own, then written. --- crates/ui-egui/src/idle_tests.rs | 19 +++++- crates/ui-egui/src/lib.rs | 103 ++++++++++++++++++++++--------- 2 files changed, 91 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 33a9086ce..44d76806a 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -301,13 +301,26 @@ fn auto_save_writes_a_changed_file_then_rests() { h.run("shape.rectangle", json!({"x": 500, "y": 300, "width": 40, "height": 40})); assert!(h.frame(vec![]) <= Duration::from_secs_f64(crate::AUTO_SAVE_SECS), "wakes up to save it"); assert!(h.app.session.ds().unwrap().is_dirty(), "not saved at once"); - h.time += crate::AUTO_SAVE_SECS; + // Another change restarts the wait. + h.time += crate::AUTO_SAVE_SECS - 0.5; + h.run("shape.rectangle", json!({"x": 600, "y": 300, "width": 40, "height": 40})); h.frame(vec![]); - assert!(!h.app.session.ds().unwrap().is_dirty(), "saved"); + h.time += 1.0; + h.frame(vec![]); + assert!(h.app.ui.auto_saving.is_empty() && h.app.session.ds().unwrap().is_dirty(), "waits for a pause in the changes"); + h.time += crate::AUTO_SAVE_SECS; + // The file is encoded on a thread of its own, then written. + let start = Instant::now(); + while h.app.session.ds().unwrap().is_dirty() { + assert!(start.elapsed() < Duration::from_secs(20), "saved"); + let delay = h.frame(vec![]); + assert!(h.app.ui.auto_saving.is_empty() || delay <= Duration::from_millis(100), "looks again for the file being encoded"); + std::thread::sleep(Duration::from_millis(5)); + } let mut t = Session::empty(); t.execute("file.open", &json!({"path": path})).unwrap(); let ds = t.ds().unwrap(); - assert!(ds.doc.page_content_bounds(ds.page).unwrap().x1 >= 540.0, "the new rectangle is in the file"); + assert!(ds.doc.page_content_bounds(ds.page).unwrap().x1 >= 640.0, "the new rectangles are in the file"); h.assert_idle("an auto-saved file"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ad811b437..25fe084ed 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -271,11 +271,14 @@ pub enum TitleBar { /// How often open documents get an autosave checkpoint (seconds). pub const AUTOSAVE_SECS: f64 = 30.0 * 60.0; -/// With auto-save on: how long after a file changes it is saved, and how long a failed save waits -/// before trying again (seconds). +/// With auto-save on: how long after a file last changed it is saved, and how long a failed save +/// waits before trying again (seconds). pub const AUTO_SAVE_SECS: f64 = 3.0; const AUTO_SAVE_RETRY_SECS: f64 = 60.0; +/// A file being encoded for auto-save: its snapshot and, once done, its bytes. +pub type AutoSaveEncoding = std::thread::JoinHandle<(figcraft_engine::cmd::file::FileSnapshot, figcraft_engine::Result>)>; + /// How long a toast shows (seconds). const TOAST_SECS: f64 = 3.0; @@ -411,8 +414,11 @@ pub struct UiState { pub last_copy: Option, /// When (UI clock, seconds) open documents next get an autosave checkpoint. pub next_autosave: f64, - /// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved. - pub auto_save_at: Option, + /// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved, and + /// the revisions of those files it waits on (a change restarts the wait). + pub auto_save_at: Option<(f64, u64)>, + /// Files being encoded for auto-save on threads of their own (tab `uid`). + pub auto_saving: Vec<(u64, AutoSaveEncoding)>, /// The Assets panel shows the libraries view (publish this file, enabled libraries). pub libraries: bool, /// Keyboard navigation: the region with the keyboard, keyboard mode (F6, Tab). @@ -498,6 +504,7 @@ impl Default for UiState { last_copy: None, next_autosave: 0.0, auto_save_at: None, + auto_saving: Vec::new(), libraries: false, nav: a11y::Nav::default(), plugins_window: false, @@ -961,39 +968,79 @@ impl FigApp { repaint_in(ctx, AUTOSAVE_SECS); } - /// With auto-save on, files that have a path are saved [`AUTO_SAVE_SECS`] after they change - /// (not during a gesture, a live preview or while a version is shown). + /// With auto-save on, files that have a path are saved [`AUTO_SAVE_SECS`] after they last + /// changed (not during a gesture, a live preview or while a version is shown). The file is + /// encoded on a thread of its own, so a big one doesn't hold up the window, and written here. fn auto_save_tick(&mut self, ctx: &egui::Context) { - let s = &self.session; - let unsaved = |d: &figcraft_engine::DocState| d.path.is_some() && d.branch_of.is_none() && d.preview.is_none() && d.is_dirty(); - let due: Vec = s.docs.iter().enumerate().filter(|(_, d)| unsaved(d)).map(|(i, _)| i).collect(); - if !s.prefs.auto_save || due.is_empty() { - self.ui.auto_save_at = None; - return; - } let now = ctx.input(|i| i.time); - let at = *self.ui.auto_save_at.get_or_insert(now + AUTO_SAVE_SECS); - if now < at || s.docs.iter().any(|d| d.interaction.is_some() || d.text_preview.is_some()) { - // Due but busy: look again in a second. - repaint_in(ctx, if now < at { at - now } else { 1.0 }); - return; - } - let active = self.session.active; - let mut failed = None; - for i in due { - self.session.active = i; - if let Err(e) = self.session.execute("file.save", &json!({})) { - failed.get_or_insert(e.to_string()); + let mut failed = self.auto_save_done(); + // The browser has no files to save to. + let on = !cfg!(target_arch = "wasm32") && self.session.prefs.auto_save; + let saving = &self.ui.auto_saving; + let due = |d: &&figcraft_engine::DocState| { + d.path.is_some() && d.branch_of.is_none() && d.preview.is_none() && d.is_dirty() && !saving.iter().any(|(uid, _)| *uid == d.uid) + }; + let changes = if on { self.session.docs.iter().filter(due).map(|d| d.revision).reduce(u64::wrapping_add) } else { None }; + match changes { + None => self.ui.auto_save_at = None, + Some(changes) => { + let at = match self.ui.auto_save_at { + Some((at, c)) if c == changes && failed.is_none() => at, + _ => now + if failed.is_some() { AUTO_SAVE_RETRY_SECS } else { AUTO_SAVE_SECS }, + }; + self.ui.auto_save_at = Some((at, changes)); + if now < at || self.session.docs.iter().any(|d| d.interaction.is_some() || d.text_preview.is_some()) { + // Due but busy: look again in a second. + repaint_in(ctx, if now < at { at - now } else { 1.0 }); + } else { + self.ui.auto_save_at = None; + let files: Vec<_> = + self.session.docs.iter().filter(due).map(|d| (d.uid, figcraft_engine::cmd::file::FileSnapshot::of(d))).collect(); + for (uid, file) in files { + let encode = move || { + let bytes = file.encode(); + (file, bytes) + }; + match std::thread::Builder::new().name("figcraft-save".into()).spawn(encode) { + Ok(h) => self.ui.auto_saving.push((uid, h)), + Err(e) => { + failed.get_or_insert(e.to_string()); + self.ui.auto_save_at = Some((now + AUTO_SAVE_RETRY_SECS, changes)); + } + } + } + } } } - self.session.active = active; + if !self.ui.auto_saving.is_empty() { + // Look again shortly for the files being encoded. + repaint_in(ctx, 0.1); + } if let Some(e) = failed { - self.ui.auto_save_at = Some(now + AUTO_SAVE_RETRY_SECS); - repaint_in(ctx, AUTO_SAVE_RETRY_SECS); self.toast(i18n::trf("Couldn't save automatically: {error}", &[("error", &e)])); } } + /// Write the files whose auto-save encoding finished; the first error, if one failed. + fn auto_save_done(&mut self) -> Option { + if self.ui.auto_saving.is_empty() { + return None; + } + let mut failed = None; + let done: Vec<_> = self.ui.auto_saving.extract_if(.., |(_, h)| h.is_finished()).collect(); + for (_, h) in done { + let saved = match h.join() { + Ok((snapshot, Ok(bytes))) => snapshot.save(&mut self.session, &bytes).map(|_| ()).map_err(|e| e.to_string()), + Ok((_, Err(e))) => Err(e.to_string()), + Err(_) => Err("the file couldn't be encoded".to_string()), + }; + if let Err(e) = saved { + failed.get_or_insert(e); + } + } + failed + } + /// Draw the whole UI: the main window (the other windows draw themselves). pub fn ui(&mut self, ui: &mut egui::Ui) { #[cfg(not(target_arch = "wasm32"))] From 69de1ee36f2c0415bf8ef0729804dd9134939075 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:12:35 +0700 Subject: [PATCH 091/120] #253: a dialog's result goes to the file and window it was opened for; dialogs are parented to the main window --- apps/figcraft/src/main.rs | 4 +- apps/figcraft/src/services.rs | 46 ++++++----- crates/ui-egui/src/async_dialog_tests.rs | 58 ++++++++++++++ crates/ui-egui/src/lib.rs | 99 ++++++++++++++++++------ 4 files changed, 165 insertions(+), 42 deletions(-) diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 7b14d8e23..fed32e472 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -90,9 +90,9 @@ impl eframe::App for App { } } - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); - with_app(|fig| figcraft_ui_egui::windows::guard(fig, &ctx, |fig| fig.ui(ui))); + services::parented(frame, || with_app(|fig| figcraft_ui_egui::windows::guard(fig, &ctx, |fig| fig.ui(ui)))); } fn on_exit(&mut self) { diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index eb42c7810..c33426c7e 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -1,5 +1,6 @@ //! Desktop services: native file dialogs, clipboard, URLs, persisted preferences. +use std::cell::RefCell; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -198,30 +199,39 @@ fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> { f.write_all(bytes) } +thread_local! { + /// While the main window draws: a dialog parented to it, so a dialog opened from it stays in + /// front of it (a sheet on macOS). eframe gives other windows no handle: theirs have no parent. + static PARENT: RefCell> = const { RefCell::new(None) }; +} + +/// Run the main window's frame `f` with the dialogs it opens parented to `frame`'s window. +pub fn parented(frame: &eframe::Frame, f: impl FnOnce() -> R) -> R { + PARENT.with(|p| p.replace(Some(rfd::AsyncFileDialog::new().set_parent(frame)))); + let r = f(); + PARENT.with(|p| p.replace(None)); + r +} + +/// A dialog with `title`, parented to the main window while it draws. +fn dialog(title: &str) -> rfd::AsyncFileDialog { + PARENT.with(|p| p.borrow().clone()).unwrap_or_default().set_title(title) +} + +/// The dialog opens now; its future gives the path picked. +fn picked(pick: impl Future> + 'static) -> DialogFuture { + Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) }) +} + impl Services for Desktop { fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture { - let title = title.to_owned(); - let exts: Vec = exts.iter().map(|ext| (*ext).to_owned()).collect(); - Box::pin(async move { - rfd::AsyncFileDialog::new().set_title(title).add_filter("Supported files", &exts).pick_file().await.map(|file| file.path().to_path_buf()) - }) + picked(dialog(title).add_filter("Supported files", exts).pick_file()) } fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture { - let (title, name) = (title.to_owned(), name.to_owned()); - let exts: Vec = exts.iter().map(|ext| (*ext).to_owned()).collect(); - Box::pin(async move { - rfd::AsyncFileDialog::new() - .set_title(title) - .set_file_name(name) - .add_filter("File", &exts) - .save_file() - .await - .map(|file| file.path().to_path_buf()) - }) + picked(dialog(title).set_file_name(name).add_filter("File", exts).save_file()) } fn pick_folder(&mut self, title: &str) -> DialogFuture { - let title = title.to_owned(); - Box::pin(async move { rfd::AsyncFileDialog::new().set_title(title).pick_folder().await.map(|file| file.path().to_path_buf()) }) + picked(dialog(title).pick_folder()) } fn clipboard_set_text(&mut self, s: &str) { if let Some(c) = self.clipboard.as_mut() { diff --git a/crates/ui-egui/src/async_dialog_tests.rs b/crates/ui-egui/src/async_dialog_tests.rs index ec2c20f03..26808fe5d 100644 --- a/crates/ui-egui/src/async_dialog_tests.rs +++ b/crates/ui-egui/src/async_dialog_tests.rs @@ -32,3 +32,61 @@ fn open_dialog_finishes_after_the_async_picker_returns() { assert!(!app.ui.home); let _ = std::fs::remove_dir_all(dir); } + +/// Every picker answers `self.0`. +struct Picks(PathBuf); + +impl Services for Picks { + fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture { + Box::pin(std::future::ready(Some(self.0.clone()))) + } + fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> DialogFuture { + Box::pin(std::future::ready(Some(self.0.clone()))) + } +} + +/// Two files, A (the first) in front, and where the picker saves. +fn two_files(tag: &str) -> (FigApp, PathBuf, PathBuf) { + let dir = std::env::temp_dir().join(format!("figcraft-async-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("a.figcraft"); + let mut app = FigApp::new(Session::empty(), Box::new(Picks(path.clone()))); + for _ in 0..2 { + app.session.execute("file.new", &serde_json::json!({})).unwrap(); + } + app.session.active = 0; + (app, dir, path) +} + +#[test] +fn save_as_saves_the_file_it_was_opened_for_after_another_tab_comes_to_the_front() { + let (mut app, dir, path) = two_files("pin"); + app.save_as_dialog(); + // The user switches tabs while the dialog is open. + app.session.active = 1; + // Another window draws first: the result waits for the window holding the file. + app.windows.current = 7; + app.poll_dialog(&egui::Context::default()); + assert!(app.session.docs.iter().all(|d| d.path.is_none())); + app.windows.current = 0; + app.poll_dialog(&egui::Context::default()); + let p = path.to_string_lossy(); + assert_eq!(app.session.docs[0].path.as_deref(), Some(p.as_ref()), "A is saved where the user picked"); + assert_eq!(app.session.docs[1].path, None, "B is untouched"); + assert_eq!(app.session.active, 1, "the tab the user switched to stays in front"); + assert!(path.exists()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_dialog_whose_file_closed_meanwhile_does_nothing() { + let (mut app, dir, path) = two_files("closed"); + app.save_as_dialog(); + app.session.execute("file.close", &serde_json::json!({"index": 0, "force": true})).unwrap(); + app.poll_dialog(&egui::Context::default()); + assert!(app.session.docs.iter().all(|d| d.path.is_none()), "B isn't saved in A's place"); + assert!(!path.exists()); + assert!(app.pending_dialog.is_none()); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 941e57273..94c8d22df 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -231,14 +231,28 @@ pub trait Services { pub type DialogFuture = Pin>>>; type DialogDone = Box; +/// A native dialog in flight. The app keeps working while it is open, so its result goes to the +/// file and window it was opened for, not to whichever is in front when it closes. +struct PendingDialog { + future: DialogFuture, + /// The path picked, kept until the window that holds `doc` draws. + path: Option, + done: DialogDone, + /// The file it acts on (`None`: none, such as Open). + doc: Option, + /// The window it was opened from. + window: u64, +} + +/// Wakes the main window, which hands the result on to the window it belongs to. struct RepaintWaker(egui::Context); impl Wake for RepaintWaker { fn wake(self: Arc) { - self.0.request_repaint(); + self.0.request_repaint_of(egui::ViewportId::ROOT); } fn wake_by_ref(self: &Arc) { - self.0.request_repaint(); + self.0.request_repaint_of(egui::ViewportId::ROOT); } } @@ -554,7 +568,7 @@ pub struct FigApp { /// Commands waiting for a server ([`FigApp::run_background`]) and what to do with their results. waiting: Vec<(String, Done)>, /// Native dialogs complete asynchronously so their modal event loop never re-enters winit. - pending_dialog: Option<(DialogFuture, DialogDone)>, + pending_dialog: Option, /// The latest UI context schedules the first poll and wakes the app when a dialog completes. ui_context: Option, } @@ -867,12 +881,18 @@ impl FigApp { } pub fn open_dialog(&mut self) { - self.pick_open(i18n::tr("Open"), &["figcraft", "fig", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], |app, p| { - let ps = p.to_string_lossy().to_string(); - if app.run("file.open", json!({"path": ps})).is_some() { - app.ui.home = false; - } - }); + let exts = ["figcraft", "fig", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]; + // Opening needs no file in front: it goes ahead even if that file closed meanwhile. + self.start_dialog( + |services| services.pick_open(i18n::tr("Open"), &exts), + false, + |app, p| { + let ps = p.to_string_lossy().to_string(); + if app.run("file.open", json!({"path": ps})).is_some() { + app.ui.home = false; + } + }, + ); } pub fn import_dialog(&mut self, place: bool) { @@ -888,37 +908,71 @@ impl FigApp { }); } + /// Pick a file to open. `done` runs with the file in front now back in front (and not at all + /// if that file closed meanwhile). pub fn pick_open(&mut self, title: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { - self.start_dialog(|services| services.pick_open(title, exts), done); + self.start_dialog(|services| services.pick_open(title, exts), true, done); } + /// Pick where to save; `done` runs as [`FigApp::pick_open`]'s does. pub fn pick_save(&mut self, title: &str, name: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { - self.start_dialog(|services| services.pick_save(title, name, exts), done); + self.start_dialog(|services| services.pick_save(title, name, exts), true, done); } + /// Pick a folder; `done` runs as [`FigApp::pick_open`]'s does. pub fn pick_folder(&mut self, title: &str, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { - self.start_dialog(|services| services.pick_folder(title), done); + self.start_dialog(|services| services.pick_folder(title), true, done); } - fn start_dialog(&mut self, start: impl FnOnce(&mut dyn Services) -> DialogFuture, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + /// Open a native dialog from the window drawing now, for the file in front (`pin`). + fn start_dialog(&mut self, start: impl FnOnce(&mut dyn Services) -> DialogFuture, pin: bool, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { if self.pending_dialog.is_some() { return; } - self.pending_dialog = Some((start(self.services.as_mut()), Box::new(done))); + let doc = if pin { self.session.ds().ok().filter(|d| self.shows(d.uid)).map(|d| d.uid) } else { None }; + let future = start(self.services.as_mut()); + self.pending_dialog = Some(PendingDialog { future, path: None, done: Box::new(done), doc, window: self.windows.current }); if let Some(ctx) = &self.ui_context { ctx.request_repaint(); } } + /// Once the dialog has a path, its continuation runs in the window that holds its file, with + /// that file in front; then the tab in front before comes back (unless the dialog opened a file). fn poll_dialog(&mut self, ctx: &egui::Context) { - let Some((future, _)) = &mut self.pending_dialog else { return }; - let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone()))); - let mut task = TaskContext::from_waker(&waker); - let Poll::Ready(path) = future.as_mut().poll(&mut task) else { return }; - if let Some((_, done)) = self.pending_dialog.take() - && let Some(path) = path + let Some(d) = &mut self.pending_dialog else { return }; + if d.path.is_none() { + let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone()))); + let Poll::Ready(path) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return }; + d.path = path; + } + let (doc, origin, picked) = (d.doc, d.window, d.path.is_some()); + let index = doc.map(|u| self.session.docs.iter().position(|ds| ds.uid == u)); + // Cancelled, or its file closed meanwhile. + if !picked || index == Some(None) { + self.pending_dialog = None; + return; + } + let window = match doc { + Some(u) => self.windows.of(u), + None if self.windows.exists(origin) => origin, + None => 0, + }; + if window != self.windows.current { + ctx.request_repaint_of(windows::viewport(window)); + return; + } + let Some(PendingDialog { path: Some(path), done, .. }) = self.pending_dialog.take() else { return }; + let before = self.session.ds().ok().map(|d| d.uid); + if let Some(Some(i)) = index { + self.session.active = i; + } + done(self, path); + if doc.is_some() + && self.session.ds().ok().map(|d| d.uid) == doc + && let Some(i) = before.and_then(|u| self.session.docs.iter().position(|d| d.uid == u)) { - done(self, path); + self.session.active = i; } } @@ -1020,7 +1074,6 @@ impl FigApp { self.ui_context = Some(ctx.clone()); self.frame_stats.record(&ctx); let first = !self.fonts_installed; - self.poll_dialog(&ctx); self.logic(&ctx); self.autosave_tick(&ctx); if first { @@ -1062,6 +1115,8 @@ impl FigApp { // even if it lets go during the frame (Enter commits it), or as it began (egui drops the // focus on Escape before the frame, which cancels the field's edit and nothing else). let typing = std::mem::take(&mut self.field_focus) || a11y::field_focused(&ctx); + // A dialog that closed acts first, in the window it was opened from. + self.poll_dialog(&ctx); // Control requests run first so their results reflect this frame. control::drain(self, &ctx); windows::requests(self, &ctx); From b14a332405532e38cfa1ec224c2b72d5718af4f7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:14:19 +0700 Subject: [PATCH 092/120] #257: keep only Spanish in the must-be-complete list, so new UI strings don't require Russian too --- README.md | 2 +- crates/ui-egui/src/i18n_tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9fb4aa97c..aecdd2590 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust. - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. -- **Languages:** English, Russian (reviewed by VanTan), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). +- **Languages:** English, Russian (reviewed by a native speaker), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). - **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index c8c73202a..478da4e70 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -6,7 +6,7 @@ use std::collections::BTreeSet; use super::*; /// Languages that must translate every UI string and command label. -const COMPLETE: &[&str] = &["es", "ru"]; +const COMPLETE: &[&str] = &["es"]; /// Keys looked up from data rather than written as `tr("…")` in the code: the main menu's /// submenu names (the engine's menu catalog) and labels of engine and document enums. From adf96ca2e78226314026030b8fc31c06de32d5e0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:25:06 +0700 Subject: [PATCH 093/120] #363: a dragged text box keeps the drag's width and height A drag with the Text tool now makes a fixed-size box (the drag's size, at least one line tall) instead of a fixed width with an auto height. --- crates/engine/src/text_behaviour_tests.rs | 18 +++++++++------- crates/engine/src/tools.rs | 26 ++++++++++++----------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index 48d7582f0..84315f1fb 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -210,8 +210,8 @@ fn text_tool(from: (f64, f64), to: (f64, f64), chars: &str) -> (figcraft_geom::R } #[test] -fn a_drag_with_the_text_tool_makes_a_fixed_width_box() { - // #360: as wide as the drag, its top-left where the drag started, and its text wraps in it; a +fn a_drag_with_the_text_tool_makes_a_fixed_size_box() { + // #360: the drag's size, its top-left where the drag started, and its text wraps in it; a // click, or a drag within a few pixels, still makes auto-width text. use figcraft_doc::TextAutoResize; figcraft_text::scan_system_fonts(); @@ -219,11 +219,13 @@ fn a_drag_with_the_text_tool_makes_a_fixed_width_box() { let (line, auto) = text_tool((100.0, 100.0), (100.0, 100.0), long); assert_eq!(auto, TextAutoResize::WidthAndHeight); assert_eq!(text_tool((100.0, 100.0), (101.0, 101.0), long).1, TextAutoResize::WidthAndHeight, "a slip of the hand is a click"); - let (b, auto) = text_tool((100.0, 100.0), (180.0, 160.0), long); - assert_eq!(auto, TextAutoResize::Height); - assert_eq!((b.x0, b.y0, b.width()), (100.0, 100.0, 80.0)); - assert!(b.height() > line.height(), "the text wraps: {} vs one line {}", b.height(), line.height()); + let (b, auto) = text_tool((100.0, 100.0), (180.0, 400.0), long); + assert_eq!(auto, TextAutoResize::None); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (100.0, 100.0, 80.0, 300.0), "the text wraps in a box that keeps its size"); // Dragged up and to the left, the box is the same. - let (b2, _) = text_tool((180.0, 160.0), (100.0, 100.0), long); - assert_eq!((b2.x0, b2.y0, b2.width()), (100.0, 100.0, 80.0)); + let (b2, _) = text_tool((180.0, 400.0), (100.0, 100.0), long); + assert_eq!((b2.x0, b2.y0, b2.width(), b2.height()), (100.0, 100.0, 80.0, 300.0)); + // A flat drag still makes a box one line tall. + let (b3, _) = text_tool((100.0, 100.0), (180.0, 101.0), "A"); + assert_eq!((b3.width(), b3.height()), (80.0, line.height())); } diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 3efd487f2..733fc08c2 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -376,10 +376,12 @@ pub(crate) enum Gesture { TextSelect { id: NodeId, }, - /// A new text from the Text tool, still pressed: a drag makes it a box as wide as the drag. + /// A new text from the Text tool, still pressed: a drag makes it a box the size of the drag + /// (at least `line`, the height of its first line, tall). TextBox { id: NodeId, start: Point, + line: f64, }, PenDrag { anchor: Point, @@ -1405,10 +1407,10 @@ impl Session { self.tool.gesture = g; return self.draw_drag(ev, start, id, parent, &targets); } - Gesture::TextBox { id, start } => { - let (id, start) = (*id, *start); + Gesture::TextBox { id, start, line } => { + let (id, start, line) = (*id, *start, *line); self.tool.gesture = g; - return self.text_box_drag(ev, id, start); + return self.text_box_drag(ev, id, start, line); } Gesture::Arc { id, handle, arc } => { let (id, handle, arc) = (*id, *handle, *arc); @@ -1953,19 +1955,20 @@ impl Session { n.width = w.max(1.0); n.height = h; } + let line = n.height; d.insert(n, parent, None)?; ds.selection = vec![id]; textedit::begin(self, id, Some(0), true)?; - self.tool.gesture = Gesture::TextBox { id, start: ev.p() }; + self.tool.gesture = Gesture::TextBox { id, start: ev.p(), line }; if !self.prefs.keep_tool { self.tool.tool = ToolId::Move; } Ok(()) } - /// Drag out a new text (Figma's text box): past the drag slop it is as wide as the drag, its - /// top-left at the drag's, and its height follows its text. - fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point) -> Result<()> { + /// Drag out a new text box: past the drag slop it has the drag's size (at least one line + /// tall) and its top-left, and that size is fixed. + fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point, line: f64) -> Result<()> { if within_slop(ev, start) { return Ok(()); } @@ -1975,12 +1978,11 @@ impl Session { let n = d.get_mut(id)?; n.transform = pw.inverse() * Affine::translate((r.x0.round(), r.y0.round())); n.width = r.width().round().max(1.0); + n.height = r.height().round().max(line); n.child_layout.sizing_h = Sizing::Fixed; + n.child_layout.sizing_v = Sizing::Fixed; if let NodeKind::Text(t) = &mut n.kind { - t.auto_resize = TextAutoResize::Height; - } - if let Some((_, h)) = figcraft_text::auto_size(n) { - n.height = h; + t.auto_resize = TextAutoResize::None; } self.ds_mut()?.revision += 1; Ok(()) From 4aadfa0439bac3c2e9805bafe46442fa5843f7fa Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:28:43 +0700 Subject: [PATCH 094/120] README: list the Turkish catalog --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index aecdd2590..70873d6cc 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust. - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. -- **Languages:** English, Russian (reviewed by a native speaker), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). +- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). - **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). From e11a182afd13392292e04014921029222bdfbbb5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:30:05 +0700 Subject: [PATCH 095/120] #400: a nested batch keeps its own step references The outer batch passes a command.batch step on as written, so the inner steps' `$step` references point at the inner batch's steps. The docs say that `$step` is a reserved key in step params. --- crates/engine/src/batch_step_ref_tests.rs | 21 +++++++++++++++++++++ crates/engine/src/lib.rs | 6 ++++-- docs/control-protocol.md | 5 +++-- docs/mcp.md | 5 ++++- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/batch_step_ref_tests.rs b/crates/engine/src/batch_step_ref_tests.rs index 7474d5465..ac69118e8 100644 --- a/crates/engine/src/batch_step_ref_tests.rs +++ b/crates/engine/src/batch_step_ref_tests.rs @@ -89,3 +89,24 @@ fn text_that_looks_like_a_reference_is_left_alone() { let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone(); assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == "$step 0 {\"$step\": 0}")); } + +#[test] +fn a_nested_batch_refers_to_its_own_steps() { + let mut s = Session::new(); + let frame = |name: &str| json!({"command": "node.create", "params": {"type": "frame", "name": name, "width": 100, "height": 100}}); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + frame("Outer"), + {"command": "command.batch", "params": {"commands": [ + frame("Inner"), + {"command": "node.create", "params": {"type": "rectangle", "parent": {"$step": 0}, "width": 10, "height": 10}} + ]}} + ]}), + ) + .unwrap(); + let inner = &results[1]; + let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone(); + assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own first frame, not the outer one"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index eee9e17aa..1585aa38c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1124,12 +1124,14 @@ impl Session { /// Run several commands as one undo step. A param value `{"$step": n}` is replaced by the `id` /// in the result of the earlier step `n` (`{"$step": n, "field": "…"}` picks another field), - /// so a step can target a layer that an earlier step of the same batch created. + /// so a step can target a layer that an earlier step of the same batch created. Any object + /// with a `$step` key in a step's params is such a reference, except inside a nested + /// `command.batch` step: its references are to its own steps. pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result> { self.grouped(label, |s| { let mut results: Vec = Vec::with_capacity(cmds.len()); for (step, (id, p)) in cmds.iter().enumerate() { - let p = resolve_step_refs(p, &results, step, 0)?; + let p = if id == "command.batch" { p.clone() } else { resolve_step_refs(p, &results, step, 0)? }; results.push(s.execute(id, &p)?); } Ok(results) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3e0cf9ba3..134a66372 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -63,8 +63,9 @@ JSON or the gradient shorthand `{"type":"linearGradient","stops":["#hex","#hex"] ``` Inside `command.batch`, `{"$step": n}` in a step's params stands for the `id` in step `n`'s result -(zero-based; `{"$step": n, "field": "…"}` for another field), so one batch can create a frame and -fill it: +(zero-based; `{"$step": n, "field": "…"}` for another field; any object with a `$step` key is read +as one, and a nested `command.batch` step keeps its own references to its own steps), so one batch +can create a frame and fill it: ```json {"id":4,"method":"engine.execute","params":{"command":"command.batch","params":{"commands":[{"command":"node.create","params":{"type":"frame","width":200,"height":100}},{"command":"node.create","params":{"type":"rectangle","parent":{"$step":0},"width":20,"height":20}}]}}} diff --git a/docs/mcp.md b/docs/mcp.md index 862ade2df..07d84e609 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1532,7 +1532,10 @@ result. This builds a nested tree in one call and one undo step, without guessin A reference to the same or a later step, a negative or non-integer index, a missing `field` or any other key next to `$step` fails the whole batch, which is rolled back. Only an object whose keys are -`$step` (and optionally `field`) is a reference: text such as `"$step 0"` is left as written. +`$step` (and optionally `field`) is a reference: text such as `"$step 0"` is left as written. The +`$step` key is reserved in step params: an object holding it anywhere (in plugin data or a variable +value too) is read as a reference. A step that is itself a `command.batch` is passed on as written, +so the references in its steps are to its own steps, not the outer batch's. The end-to-end tests in `crates/mcp/tests/agent_tasks.rs` are worked examples of realistic tasks (auto layout, a card built in one batch with step references, components and overrides, gradients From 477f019737ee3aa9fcb4df3f6ba63a1d9d4c2659 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:34:23 +0700 Subject: [PATCH 096/120] #280: a drag released off the window ends where the pointer was last seen, not mid-canvas; the test follows #278 --- crates/ui-egui/src/canvas.rs | 35 ++++++++++--------- crates/ui-egui/src/canvas_drag_tests.rs | 46 +++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 31dbb2d84..348f077db 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -371,16 +371,12 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { } } let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else { - if app.canvas.dragging && ctx.input(|i| i.pointer.button_released(egui::PointerButton::Primary)) { + // A drag released off the canvas, or off the window, ends where the pointer was last seen. + if app.canvas.dragging && !ctx.input(|i| i.pointer.primary_down()) { app.canvas.dragging = false; - let pos = hover_pos.unwrap_or(app.canvas.rect.center()); - let ev = pointer_event(app, PointerKind::Up, pos, &mods); - app.canvas.pressure = None; - app.canvas.last_pointer = Some(Point::new(ev.x, ev.y)); - if let Err(e) = app.session.pointer(&ev) { - app.toast(e.to_string()); + if let Some(at) = hover_pos.or_else(|| app.canvas.last_pointer.map(|p| app.to_screen(p.x, p.y))) { + send(app, PointerKind::Up, at, &mods); } - app.handle_ui_requests(); } // Leaving the canvas: nothing is hovered there any more. if !app.canvas.dragging && std::mem::take(&mut app.canvas.hovering) { @@ -493,19 +489,24 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { app.canvas.pressure = force; } if let Some(k) = kind { - let ev = pointer_event(app, k, pos, &mods); - if k == PointerKind::Up { - app.canvas.pressure = None; - } - app.canvas.last_pointer = Some(Point::new(ev.x, ev.y)); - if let Err(e) = app.session.pointer(&ev) { - app.toast(e.to_string()); - } - app.handle_ui_requests(); + send(app, k, pos, &mods); } true } +/// Pointer event `kind` at screen point `pos`, to the tools. +fn send(app: &mut FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers) { + let ev = pointer_event(app, kind, pos, m); + if kind == PointerKind::Up { + app.canvas.pressure = None; + } + app.canvas.last_pointer = Some(Point::new(ev.x, ev.y)); + if let Err(e) = app.session.pointer(&ev) { + app.toast(e.to_string()); + } + app.handle_ui_requests(); +} + /// Right-click at screen `pos`: select what's under the pointer (`deep`: the nested layer), then /// open the context menu there. pub fn open_context_menu(app: &mut FigApp, pos: Pos2, deep: bool) { diff --git a/crates/ui-egui/src/canvas_drag_tests.rs b/crates/ui-egui/src/canvas_drag_tests.rs index 112ba0e11..9f3b38397 100644 --- a/crates/ui-egui/src/canvas_drag_tests.rs +++ b/crates/ui-egui/src/canvas_drag_tests.rs @@ -75,3 +75,49 @@ fn releasing_a_canvas_drag_outside_finishes_the_interaction() { assert!(u.app.session.ds().unwrap().interaction.is_none(), "the document interaction is committed"); } + +fn key(u: &mut Ui, key: egui::Key) { + let modifiers = egui::Modifiers::NONE; + u.frame(vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ]); +} + +#[test] +fn a_drag_released_off_the_window_ends_where_the_pointer_was_last_seen_and_later_edits_stay() { + // #278: a drag left open made a later text edit roll back the changes made since. + let mut s = Session::new(); + let id = |v: serde_json::Value| figcraft_engine::params::id_of(&v["id"]).unwrap(); + let kept = id(s.execute("shape.rectangle", &json!({"x": 40, "y": 400, "width": 80, "height": 40})).unwrap()); + let gone = id(s.execute("shape.rectangle", &json!({"x": 160, "y": 400, "width": 80, "height": 40})).unwrap()); + let text = id(s.execute("text.create", &json!({"text": "A", "x": 40, "y": 500})).unwrap()); + s.execute("tool.rectangle", &json!({})).unwrap(); + let mut u = Ui::new(s); + // A rectangle drawn from the middle of the canvas, released off the window, the pointer gone. + let start = u.app.canvas.rect.center(); + u.button(start, true); + for i in 1..=6 { + u.frame(vec![Event::PointerMoved(start + vec2(10.0, 6.0) * i as f32)]); + } + let release = + Event::PointerButton { pos: egui::pos2(-50.0, -50.0), button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + u.frame(vec![release, Event::PointerGone]); + let ds = u.app.session.ds().unwrap(); + assert!(ds.interaction.is_none(), "the drawing is committed"); + let (drawn, zoom) = (ds.selection[0], ds.view.zoom); + let b = ds.doc.world_bounds(drawn).unwrap(); + // Not the 100 x 100 shape of a click, as an end in the middle of the canvas would make. + assert!((b.width() * zoom - 60.0).abs() < 1.5 && (b.height() * zoom - 36.0).abs() < 1.5, "{b:?} at zoom {zoom}"); + // #278's steps: a move in the panel, a delete, then a text edit. + u.app.run("node.set", json!({"ids": [kept], "props": {"x": 650}})); + u.app.run("edit.delete", json!({"ids": [gone]})); + u.app.run("select.set", json!({"ids": [text]})); + key(&mut u, egui::Key::Enter); + u.frame(vec![Event::Text("B".into())]); + key(&mut u, egui::Key::Escape); + let doc = u.app.session.doc().unwrap(); + assert_eq!(doc.world_bounds(kept).unwrap().x0, 650.0, "the move stays"); + assert!(!doc.contains(gone), "the delete stays"); + assert_eq!(doc.node(text).unwrap().kind.text().unwrap().characters, "B"); +} From 8bcb0f78ddc1d922723cef291a388383633bf59c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:37:52 +0700 Subject: [PATCH 097/120] #338: drop the start/end feat marker comments --- apps/figcraft-server/src/store.rs | 2 -- crates/collab-net/src/host.rs | 6 +----- crates/collab-net/src/hub.rs | 18 ------------------ crates/collab-net/src/link.rs | 2 -- crates/collab-net/src/loopback_tests.rs | 2 -- crates/collab/src/codec_tests.rs | 2 -- crates/collab/src/message.rs | 2 -- crates/engine/src/live.rs | 6 ------ crates/engine/src/live_tests.rs | 6 ------ crates/ui-egui/src/live.rs | 6 ------ docs/collab-protocol.md | 2 -- docs/mcp.md | 2 -- 12 files changed, 1 insertion(+), 55 deletions(-) diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 9f97ee351..4886bc888 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -1489,10 +1489,8 @@ impl Backend for Store { Admin::Blocked => AdminReply::Blocked(self.blocked()).into(), Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()), Admin::Owned { files } => self.owned(&files).into(), - // start feat: remove-from-session // The hub removes people itself (it knows who is connected); it never asks the store. Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()), - // end feat: remove-from-session Admin::Remove { file } => { if !self.files.contains_key(&file) { return failed("this file isn't on the server (it may have been removed)".into()); diff --git a/crates/collab-net/src/host.rs b/crates/collab-net/src/host.rs index a198ab5cd..24be22d3e 100644 --- a/crates/collab-net/src/host.rs +++ b/crates/collab-net/src/host.rs @@ -11,12 +11,10 @@ use figcraft_collab::{Outgoing, Server, same_secret}; use figcraft_doc::Document; use figcraft_engine::live::{Event, PEOPLE}; +pub use crate::hub::REMOVED; use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name, to_local}; use crate::inbox::Inbox; pub use crate::join::ENDED; -// start feat: remove-from-session -pub use crate::hub::REMOVED; -// end feat: remove-from-session use crate::net::NetLimits; /// What a hosted session needs. @@ -132,13 +130,11 @@ impl Host { self.hosting.send(msg); } - // start feat: remove-from-session /// Remove client `id` from the session: they are disconnected and can't join again from /// their address while it runs. The host can't remove themselves. pub fn remove(&self, id: u32) { self.hosting.remove(id); } - // end feat: remove-from-session /// Cut every connection as a network failure would (no close frame). #[cfg(test)] diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 699107541..cdd069157 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -413,10 +413,8 @@ enum HubIn { }, Local(Presence), LocalMsg(ClientMsg), - // start feat: remove-from-session /// The person hosting removes client `id` from their file (app sessions). Remove(u32), - // end feat: remove-from-session /// Cut every connection without a word (tests: a network failure). #[cfg(test)] Cut, @@ -563,13 +561,11 @@ impl Hosting { let _ = self.hub.send(HubIn::LocalMsg(msg)); } - // start feat: remove-from-session /// The hosting person removes client `id` from their file (app sessions; see /// [`HubIn::Remove`]). pub(crate) fn remove(&self, id: u32) { let _ = self.hub.send(HubIn::Remove(id)); } - // end feat: remove-from-session /// Cut every connection as a network failure would (no close frame). #[cfg(test)] @@ -1128,7 +1124,6 @@ fn unsupported(protocol: u32) -> String { /// sharing): the session's end for them. pub const STOPPED: &str = "Sharing was stopped: the link you joined with no longer works. Your copy stays open"; -// start feat: remove-from-session /// What someone removed from a file is told: the session's end for them (they keep their copy; /// nothing retries). pub const REMOVED: &str = "You were removed from this session. Your copy stays open"; @@ -1138,7 +1133,6 @@ const BARRED: &str = "you were removed from this session"; /// The most addresses one file keeps barred. const MAX_BARRED: usize = 256; -// end feat: remove-from-session /// What everyone in a file is told when its changes couldn't be saved (they reconnect). const UNSAVED: &str = "The server couldn't save the latest changes; reconnecting"; @@ -1153,10 +1147,8 @@ struct Hub { local: Option, /// What the servers said, sent once the backend made what changed durable. outbox: Vec<(String, Outgoing)>, - // start feat: remove-from-session /// Addresses ([`ip_key`]) removed from each file: they can't join it again while the hub runs. barred: HashMap>, - // end feat: remove-from-session } impl Hub { @@ -1244,7 +1236,6 @@ impl Hub { self.client_message(&file, id, msg, None); } } - // start feat: remove-from-session HubIn::Remove(id) => { if let Some(file) = self.local.clone() && let Err(why) = self.remove_peer(&file, id) @@ -1252,7 +1243,6 @@ impl Hub { log::info!("live session: couldn't remove client {id}: {why}"); } } - // end feat: remove-from-session #[cfg(test)] HubIn::Cut => { for c in self.conns.values() { @@ -1277,7 +1267,6 @@ impl Hub { self.client_message(&file, id, msg, Some(bytes)); } Some(Kind::Hosting { owns }) => match msg { - // start feat: remove-from-session ClientMsg::Admin(Admin::RemovePeer { file, client }) => { let reply = if owns.as_deref() == Some(file.as_str()) { self.removed_reply(&file, client) @@ -1286,7 +1275,6 @@ impl Hub { }; self.answer(conn, &ServerMsg::Admin(Box::new(reply))); } - // end feat: remove-from-session ClientMsg::Admin(req) => { let owns = owns.clone(); let rooms = &self.rooms; @@ -1298,12 +1286,10 @@ impl Hub { _ => self.kick(&[conn], "only publishing and managing your own files are spoken on this connection"), }, Some(Kind::Managing) => match msg { - // start feat: remove-from-session ClientMsg::Admin(Admin::RemovePeer { file, client }) => { let reply = self.removed_reply(&file, client); self.answer(conn, &ServerMsg::Admin(Box::new(reply))); } - // end feat: remove-from-session ClientMsg::Admin(req) => self.admin(conn, req), ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong), _ => self.kick(&[conn], "only requests to manage files are spoken on this connection"), @@ -1366,12 +1352,10 @@ impl Hub { Ok(a) => a, Err(why) => return refuse(&c.out, &why), }; - // start feat: remove-from-session if self.barred.get(&hello.file).is_some_and(|b| b.contains(&c.ip)) { log::info!("event=refused ip={} reason=removed", c.ip); return refuse(&c.out, BARRED); } - // end feat: remove-from-session let hosting = reads_hosting(&hello.client_version); // The backend checked the secret: the sessions don't keep it. hello.token.clear(); @@ -1429,7 +1413,6 @@ impl Hub { } } - // start feat: remove-from-session /// Remove client `id` from `file`: they are told the session is over for them and /// disconnected, and their address can't join the file again while the hub runs. The person /// hosting can't be removed. @@ -1457,7 +1440,6 @@ impl Hub { Err(reason) => AdminReply::Failed { reason }, } } - // end feat: remove-from-session /// Take in connection `c` as `kind` and send it `first`. fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) { diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index 49e085ed3..a84395aa8 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -243,7 +243,6 @@ impl LiveLink for NetLink { } } - // start feat: remove-from-session fn remove(&mut self, session: SessionId, client: u32) -> Result<(), String> { match self.sessions.get(session) { Some(Active::Host(h)) if h.me().client_id == client => Err("you can't remove yourself: stop the session instead".into()), @@ -255,7 +254,6 @@ impl LiveLink for NetLink { None => Err("this session is over".into()), } } - // end feat: remove-from-session fn leave(&mut self, session: SessionId) { if let Some(active) = self.sessions.remove(session) { diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index 65cbef36c..32c86bd0c 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -612,7 +612,6 @@ fn until_presence_or_pong(r: &mut Raw) -> Option { } } -// start feat: remove-from-session #[test] fn the_host_removes_someone_and_they_cant_come_back() { let (mut host, mut hp, invite) = start(); @@ -643,4 +642,3 @@ fn the_host_removes_someone_and_they_cant_come_back() { host.stop(); assert_eq!(host.connections(), 0); } -// end feat: remove-from-session diff --git a/crates/collab/src/codec_tests.rs b/crates/collab/src/codec_tests.rs index 08f15d226..9f5d12fb9 100644 --- a/crates/collab/src/codec_tests.rs +++ b/crates/collab/src/codec_tests.rs @@ -75,9 +75,7 @@ fn samples() -> (Vec, Vec) { ClientMsg::Admin(Admin::Block { ip: "203.0.113.9".into() }), ClientMsg::Admin(Admin::Unblock { ip: "2001:db8::".into() }), ClientMsg::Admin(Admin::Blocked), - // start feat: remove-from-session ClientMsg::Admin(Admin::RemovePeer { file: "f".into(), client: 7 }), - // end feat: remove-from-session ClientMsg::Report { reason: "Spam ☕".into() }, ]; let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap(); diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 1dc22bece..0463ccd70 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -274,12 +274,10 @@ pub enum Admin { /// these `(file id, owner token)` pairs own, at most [`MAX_OWNED`] of them: /// [`AdminReply::Owned`]. Servers older than this request can't read it. Owned { files: Vec<(String, String)> }, - // start feat: remove-from-session /// Remove client `client` from `file`'s session: they are disconnected and their address /// can't join the file again while the server runs. With the server key, or on an open /// hosting connection with `file`'s owner token. Servers older than this request can't read it. RemovePeer { file: String, client: u32 }, - // end feat: remove-from-session } /// The most files one [`Admin::Owned`] asks about. diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index e621fd365..4001b1563 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -517,13 +517,11 @@ pub trait LiveLink: Send { fn background(&mut self, _job: u64, _req: NetRequest) -> bool { false } - // start feat: remove-from-session /// Remove client `client` from session `session`, which this link hosts: they are /// disconnected and can't join again from their address while it runs. Returns at once. fn remove(&mut self, _session: SessionId, _client: u32) -> std::result::Result<(), String> { Err("removing people needs the desktop app or the MCP server".into()) } - // end feat: remove-from-session /// End session `session` (stop hosting or leave); the others go on. Nothing for a session /// that is over already. fn leave(&mut self, session: SessionId); @@ -1896,7 +1894,6 @@ fn follow(s: &mut Session, p: &Value) -> Result { Ok(json!({"follow": s.live().follow})) } -// start feat: remove-from-session /// Remove someone from the open file's session: in a session this app hosts, the hub drops them; /// in a file of yours on a server, the server does (with the file's owner token). They are told /// the session is over for them, keep their copy, and can't join again from their address while @@ -1949,7 +1946,6 @@ pub fn can_remove_people(s: &Session) -> bool { None => false, } } -// end feat: remove-from-session fn cursor(s: &mut Session, p: &Value) -> Result { let at = match (num(p, "x"), num(p, "y")) { @@ -2059,7 +2055,6 @@ pub fn specs() -> Vec { |s, _| Ok(s.live_summary()) ), spec!("collab.follow", "Follow", "{client? (a peer's id; omit to stop following)} → {follow}", false, follow), - // start feat: remove-from-session spec!( "collab.removePerson", "Remove from session", @@ -2067,7 +2062,6 @@ pub fn specs() -> Vec { false, remove_person ), - // end feat: remove-from-session spec!( "collab.cursor", "Show cursor at", diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs index f2d8cfb8c..cacc4f378 100644 --- a/crates/engine/src/live_tests.rs +++ b/crates/engine/src/live_tests.rs @@ -28,10 +28,8 @@ pub(crate) struct Script { /// The session each presence in `sent` and each message in `msgs` was for. pub(crate) sent_to: Vec, pub(crate) msgs_to: Vec, - // start feat: remove-from-session /// The people removed: `(session, client)`. pub(crate) removed: Vec<(u64, u32)>, - // end feat: remove-from-session } pub(crate) struct Mock(pub(crate) Arc>); @@ -66,12 +64,10 @@ impl LiveLink for Mock { s.published.push(opts); Ok(()) } - // start feat: remove-from-session fn remove(&mut self, session: u64, client: u32) -> Result<(), String> { self.0.lock().unwrap().removed.push((session, client)); Ok(()) } - // end feat: remove-from-session fn leave(&mut self, session: u64) { let mut s = self.0.lock().unwrap(); s.left += 1; @@ -324,7 +320,6 @@ fn the_host_ending_resets_everything() { assert_eq!(s.message.as_deref(), Some("The host ended the session")); } -// start feat: remove-from-session #[test] fn the_host_removes_people_and_guests_cant() { let (mut s, script) = session(); @@ -361,7 +356,6 @@ fn the_host_removes_people_and_guests_cant() { assert!(e.contains("only the person hosting"), "{e}"); assert!(script.lock().unwrap().removed.is_empty()); } -// end feat: remove-from-session #[test] fn collab_commands_survive_junk() { diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 03bfd3ce2..5cd5f7239 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -460,10 +460,8 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { let peers: Vec<(u32, String, Color32)> = live.peers.values().map(|p| (p.client_id, p.name.clone(), color(&p.color))).collect(); let follow = live.follow; let (in_session, spotlighting) = (live.status == Status::Live, live.spotlighting); - // start feat: remove-from-session let can_remove = figcraft_engine::live::can_remove_people(&app.session); let mut remove = None; - // end feat: remove-from-session // Room for the avatars after yours and the chevron. let fit = (((area.width() - 2.0 * R - 18.0) / STEP).floor() as usize).max(1); let (show, more) = if peers.len() > fit { (fit - 1, peers.len() - (fit - 1)) } else { (peers.len(), 0) }; @@ -501,7 +499,6 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { if resp.clicked() { clicked = Some(*id); } - // start feat: remove-from-session if can_remove { crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, &trf("Remove {name} from session", &[("name", name)]), "") { @@ -510,7 +507,6 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { } }); } - // end feat: remove-from-session } if more > 0 { x += STEP; @@ -554,12 +550,10 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { if spotlight { app.run("collab.spotlight", json!({"on": !spotlighting})); } - // start feat: remove-from-session // A server is asked without the window waiting for it. if let Some(id) = remove { app.run_background(&ui.ctx().clone(), "collab.removePerson", json!({"client": id}), Box::new(|_, _, _| {})); } - // end feat: remove-from-session } /// The Share button's popover: the live session (start, invite link, stop or leave) and export. diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 21396651a..38ef96ec1 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -386,7 +386,6 @@ resuming with its offline edits, and leaving the session going back to snapshot outlines in the person's colour, and avatars (initials) at the top of the right panel beside Share; click an avatar to **follow** that person's viewport (`collab.follow {client}`), click again, press Escape or pan/zoom yourself to stop. The chevron lists everyone. - - **Removing someone.** Right-click their avatar ▸ "Remove from session" (`collab.removePerson {client}`), when you host the session or own the file on a server. The hub closes their connection with "You were removed from this session" (close code 1001: the @@ -395,7 +394,6 @@ resuming with its offline edits, and leaving the session going back to snapshot a server the owner asks on an open hosting connection with the file's owner token (`Admin::RemovePeer {file, client}`; the operator may too, with the server key). Addresses are the only identity a session has: someone behind the same address (one NAT) is barred too. - - **Text carets.** While someone types in a text layer of the shared document on your page, their caret and selected characters show on it in their colour (the text layout's own caret and selection geometry, so they sit at the right glyphs of your copy), with their name in a tag above diff --git a/docs/mcp.md b/docs/mcp.md index af49937a4..0e147bb0e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1090,13 +1090,11 @@ only: a host stops hosting it, a guest leaves it. pointer. Your selection, page and view go out by themselves when they change. - `collab.follow {client}` follows that person's viewport; `collab.follow {}` stops (so does any pan or zoom of your own, or Escape). - - `collab.removePerson {client}` removes that person from the session: they are told the session is over for them (they keep their copy and don't reconnect), and their address can't join the file again while the session (or the server) runs. Only the person hosting, or the owner of a file on a server (with its owner token), can; a server older than this request says it can't. Returns `{removed, name}`. - - `collab.cursorChat {text}` shows `text` next to your cursor for everyone in the session (put the cursor somewhere first with `collab.cursor`): at most 100 characters, control characters become spaces, `""` closes it. Others see it fade 5 seconds after its last change, so send it again to From b53967b423f7de63b66016afa312984221f25a02 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:38:34 +0700 Subject: [PATCH 098/120] #313: hidden-window control requests run inside the frame guard --- crates/ui-egui/src/control.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 28f216715..db61cbc46 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -156,7 +156,9 @@ pub fn drain_hidden(app: &mut FigApp, ctx: &Context) { app.control_queue.push(r); continue; } - let res = handle(app, ctx, &r.method, &r.params); + // Outside a frame, so outside the frame's guard: a panic still ends in recovery copies. + let mut res = Err("internal error".to_string()); + crate::windows::guard(app, ctx, |app| res = handle(app, ctx, &r.method, &r.params)); let _ = r.reply.send(ControlReply { id: r.id, result: res }); } } From aeeb1a7cdeae25dfa59b2e4aaabf06140ebfd4a2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:41:10 +0700 Subject: [PATCH 099/120] #338: forget a file's barred addresses when its session closes The hub kept the addresses removed from every file it ever had open. The entry now goes when the file is closed (everyone left) or removed from the server, so a long-running public server doesn't keep collecting them. --- crates/collab-net/src/barred_tests.rs | 53 +++++++++++++++++++++++++++ crates/collab-net/src/hub.rs | 13 +++++-- crates/collab/src/message.rs | 2 +- docs/mcp.md | 2 +- 4 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 crates/collab-net/src/barred_tests.rs diff --git a/crates/collab-net/src/barred_tests.rs b/crates/collab-net/src/barred_tests.rs new file mode 100644 index 000000000..c9355a5fa --- /dev/null +++ b/crates/collab-net/src/barred_tests.rs @@ -0,0 +1,53 @@ +//! Addresses removed from a file are kept only while its session lasts, so a long-running server +//! doesn't collect them for every file it ever had open. + +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr}; + +use figcraft_collab::{Limits, Server}; +use figcraft_doc::Document; + +use super::{Admission, Backend, Closing, Hub, InFlight, Room}; + +/// A backend that closes a file as soon as everyone left it. +struct Closes; + +impl Backend for Closes { + fn admit(&mut self, _file: &str, _token: &str) -> Result { + Err("no".into()) + } + + fn open(&mut self, _file: &str) -> Result { + Err("no".into()) + } + + fn idle(&mut self, _file: &str, _server: &mut Server) -> bool { + true + } + + fn closing(&self) -> Closing { + Closing::Restarting + } +} + +#[test] +fn a_closed_file_forgets_whom_it_barred() { + let mut doc = Document::new("File"); + doc.repair(); + let mut hub = Hub { + in_flight: InFlight(Default::default()), + backend: Closes, + rooms: HashMap::from([("f".to_string(), Room::new(Server::new(doc, "", Limits::default())))]), + conns: HashMap::new(), + local: None, + outbox: Vec::new(), + barred: HashMap::new(), + }; + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 7)); + hub.barred.entry("f".into()).or_default().insert(ip); + hub.barred.entry("other".into()).or_default().insert(ip); + hub.close_if_empty("f"); + assert!(!hub.rooms.contains_key("f"), "everyone left: the file closed"); + assert!(!hub.barred.contains_key("f"), "and its barred addresses went with it"); + assert!(hub.barred.contains_key("other"), "another file's stay"); +} diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index cdd069157..5cf3e5191 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -1147,7 +1147,8 @@ struct Hub { local: Option, /// What the servers said, sent once the backend made what changed durable. outbox: Vec<(String, Outgoing)>, - /// Addresses ([`ip_key`]) removed from each file: they can't join it again while the hub runs. + /// Addresses ([`ip_key`]) removed from each open file: they can't join it again until its + /// session closes (the file is closed or removed; the entry goes with it). barred: HashMap>, } @@ -1414,8 +1415,8 @@ impl Hub { } /// Remove client `id` from `file`: they are told the session is over for them and - /// disconnected, and their address can't join the file again while the hub runs. The person - /// hosting can't be removed. + /// disconnected, and their address can't join the file again while its session lasts. The + /// person hosting can't be removed. fn remove_peer(&mut self, file: &str, id: u32) -> Result<(), String> { let room = self.rooms.get(file).ok_or("nobody is in this file")?; if room.local.as_ref().is_some_and(|(local, _)| *local == id) { @@ -1558,6 +1559,7 @@ impl Hub { .collect(); // Closed without saving: the file is gone. self.rooms.remove(&file); + self.barred.remove(&file); self.kick(&users, "this file was removed from the server"); } if let Some(ip) = handled.blocked { @@ -1635,6 +1637,7 @@ impl Hub { let Some(room) = self.rooms.get_mut(file).filter(|r| r.empty()) else { return }; if self.backend.idle(file, &mut room.server) { self.rooms.remove(file); + self.barred.remove(file); } } @@ -1740,3 +1743,7 @@ pub(crate) fn to_local(inbox: &Inbox, local: u32, msg: &ServerMsg) { #[cfg(test)] #[path = "queue_tests.rs"] mod queue_tests; + +#[cfg(test)] +#[path = "barred_tests.rs"] +mod barred_tests; diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 0463ccd70..d035042b2 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -275,7 +275,7 @@ pub enum Admin { /// [`AdminReply::Owned`]. Servers older than this request can't read it. Owned { files: Vec<(String, String)> }, /// Remove client `client` from `file`'s session: they are disconnected and their address - /// can't join the file again while the server runs. With the server key, or on an open + /// can't join the file again while its session lasts. With the server key, or on an open /// hosting connection with `file`'s owner token. Servers older than this request can't read it. RemovePeer { file: String, client: u32 }, } diff --git a/docs/mcp.md b/docs/mcp.md index 0e147bb0e..8459987da 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1092,7 +1092,7 @@ only: a host stops hosting it, a guest leaves it. pan or zoom of your own, or Escape). - `collab.removePerson {client}` removes that person from the session: they are told the session is over for them (they keep their copy and don't reconnect), and their address can't join the - file again while the session (or the server) runs. Only the person hosting, or the owner of a + file again while the session runs. Only the person hosting, or the owner of a file on a server (with its owner token), can; a server older than this request says it can't. Returns `{removed, name}`. - `collab.cursorChat {text}` shows `text` next to your cursor for everyone in the session (put the From 25beadea65867bbaa6b006318d1d20c1839ba25d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:44:29 +0700 Subject: [PATCH 100/120] #281: say the asset reference lookup is original code, read from the schema the file embeds --- crates/format/src/fig/map.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 98db5ef4a..e0642f32a 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -309,6 +309,7 @@ impl<'a> Mapper<'a> { /// else the file node its asset `key` and `version` names (a collection from a library is /// referenced as the asset, and the file's own node for it says the same `key`). `None` when /// the reference has neither or the asset names nothing (or ambiguously) in the file. + /// Original code for this importer: the field names are those of the schema each file embeds. fn resource_id(&self, v: &Kv) -> Option { if let Some(g) = v.get("guid").and_then(guid).filter(|g| *g != NO_STYLE) { return Some(g); From 3d112c4b8cd1bb86973c71e166d726a03237a12c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:47:52 +0700 Subject: [PATCH 101/120] #300: a colour PNG strike's size is checked before its pixels are allocated --- crates/text/src/color.rs | 12 ++++++++---- crates/text/src/color_tests.rs | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/text/src/color.rs b/crates/text/src/color.rs index 16e0c7597..ca927a949 100644 --- a/crates/text/src/color.rs +++ b/crates/text/src/color.rs @@ -29,6 +29,8 @@ const MAX_RASTER_BYTES: usize = 32 * 1024 * 1024; const MAX_RASTER_ENTRIES: usize = 8192; /// COLR layer lists kept per face (24 bytes a layer, so a count is enough). const MAX_LAYERS: usize = 4096; +/// Widest and tallest PNG strike decoded (strikes are at most a few hundred pixels). +const MAX_PNG_SIDE: u32 = 4096; /// Largest colour raster per em (device pixels). Past this the renderer scales one up instead. pub const MAX_PX_PER_EM: f64 = 512.0; @@ -223,13 +225,15 @@ fn decode_png(png: &[u8]) -> Option<(Vec, u32, u32)> { // Expand palettes, `tRNS` and sub-byte samples to 8-bit RGBA. decoder.set_transformations(png::Transformations::normalize_to_color8() | png::Transformations::ALPHA); let mut reader = decoder.read_info().ok()?; - let mut buf = vec![0u8; reader.output_buffer_size()?]; - let info = reader.next_frame(&mut buf).ok()?; - if info.width == 0 || info.height == 0 || info.width > 4096 || info.height > 4096 { + // The header's size is checked before anything is allocated for it: a font can claim any size. + let (w, h) = reader.info().size(); + if w == 0 || h == 0 || w > MAX_PNG_SIDE || h > MAX_PNG_SIDE { return None; } + let mut buf = vec![0u8; reader.output_buffer_size()?]; + let info = reader.next_frame(&mut buf).ok()?; let (width, height) = (info.width, info.height); - let data = &buf[..info.buffer_size()]; + let data = buf.get(..info.buffer_size())?; let rgba = match info.color_type { png::ColorType::Rgba => data.to_vec(), png::ColorType::Rgb => data.as_chunks::<3>().0.iter().flat_map(|p| [p[0], p[1], p[2], 255]).collect(), diff --git a/crates/text/src/color_tests.rs b/crates/text/src/color_tests.rs index d98f8d760..929d31122 100644 --- a/crates/text/src/color_tests.rs +++ b/crates/text/src/color_tests.rs @@ -86,6 +86,22 @@ fn png_strikes_decode_to_premultiplied_rgba() { ); } +#[test] +fn a_png_strike_claiming_a_huge_size_is_refused_before_anything_is_allocated() { + // PNG_STRAIGHT with its header saying 1 048 576 × 1 048 576 pixels (4 TiB as RGBA). + let mut png = PNG_STRAIGHT.to_vec(); + assert_eq!(crc32(&png[12..29]).to_be_bytes(), png[29..33], "the patched header passes its checksum"); + png[16..24].copy_from_slice(&[0, 0x10, 0, 0, 0, 0x10, 0, 0]); + let crc = crc32(&png[12..29]); + png[29..33].copy_from_slice(&crc.to_be_bytes()); + assert!(decode_png(&png).is_none()); +} + +/// The CRC-32 a PNG chunk ends with. +fn crc32(bytes: &[u8]) -> u32 { + !bytes.iter().fold(!0u32, |c, b| (0..8).fold(c ^ u32::from(*b), |c, _| if c & 1 == 1 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 })) +} + #[test] fn raw_bgra_strikes_are_already_premultiplied() { // BitmapData::Bgra is BGRA with premultiplied channels (the spec says so), so decoding only From c230a97ccaf8a4b18d1c1b4acab4a9d35f86ebb0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:50:55 +0700 Subject: [PATCH 102/120] #326: decode at most the image crate's default 512 MiB per image; a full image cache drops its least recently used images, not all of them --- crates/render/src/image_budget_tests.rs | 14 +++++++++ crates/render/src/images.rs | 39 +++++++++++++++++-------- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/crates/render/src/image_budget_tests.rs b/crates/render/src/image_budget_tests.rs index ae654e09b..6228efc56 100644 --- a/crates/render/src/image_budget_tests.rs +++ b/crates/render/src/image_budget_tests.rs @@ -86,6 +86,20 @@ fn the_image_cache_keeps_within_its_byte_budget() { } } +#[test] +fn a_full_image_cache_drops_the_least_recently_used_image_only() { + // 1000 bytes hold two 400-byte pixmaps: the third drops the one used longest ago. + let mut c = images::PixmapCache::default(); + c.insert(key("a"), pixmap(10), 1000); + c.insert(key("b"), pixmap(10), 1000); + assert!(c.get(&key("a")).is_some()); + c.insert(key("c"), pixmap(10), 1000); + assert!(c.get(&key("a")).is_some(), "used since b: kept"); + assert!(c.get(&key("b")).is_none(), "used longest ago: dropped"); + assert!(c.get(&key("c")).is_some()); + assert_eq!(c.bytes(), 800); +} + #[test] fn an_image_larger_than_the_budget_is_held_alone() { let mut c = images::PixmapCache::default(); diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 4892e508c..f4f9c5d94 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -17,8 +17,9 @@ pub(crate) type Key = (String, [i32; 7], ColorProfile); /// The largest image side decoded. const MAX_SIDE: u32 = 16_384; -/// The most bytes one image's decoded pixels take (a 16k × 16k 8-bit RGBA image). -const MAX_DECODED_BYTES: u64 = 1 << 30; +/// The most bytes one image's decoding may allocate: the image crate's own default limit (a +/// larger image is refused from its header). +const MAX_DECODED_BYTES: u64 = 512 << 20; /// The most bytes of pixels each decoded-image cache holds (one image larger than this is held /// alone). const MAX_CACHED_BYTES: u64 = 512 << 20; @@ -26,31 +27,45 @@ const MAX_CACHED_BYTES: u64 = 512 << 20; const MAX_CACHED_IMAGES: usize = 256; /// Decoded pixmaps by image, filters and profile, holding at most a byte budget of pixels: adding -/// one past the budget (or past [`MAX_CACHED_IMAGES`]) empties the cache first. +/// one past the budget (or past [`MAX_CACHED_IMAGES`]) drops the least recently used first, so +/// the images on screen stay while they fit. #[derive(Default)] pub(crate) struct PixmapCache { - map: HashMap>, + /// Each pixmap and when it was last used. + map: HashMap, u64)>, bytes: u64, + tick: u64, } impl PixmapCache { - pub(crate) fn get(&self, key: &Key) -> Option> { - self.map.get(key).cloned() + pub(crate) fn get(&mut self, key: &Key) -> Option> { + self.tick += 1; + let tick = self.tick; + self.map.get_mut(key).map(|(p, used)| { + *used = tick; + p.clone() + }) } /// Add `p` under `key`, keeping the cache within `max_bytes` (or holding `p` alone). pub(crate) fn insert(&mut self, key: Key, p: Arc, max_bytes: u64) { let size = p.data().len() as u64; - if self.bytes.saturating_add(size) > max_bytes || self.map.len() >= MAX_CACHED_IMAGES { - self.map.clear(); - self.bytes = 0; - } - if let Some(old) = self.map.insert(key, p) { - self.bytes = self.bytes.saturating_sub(old.data().len() as u64); + self.remove(&key); + while self.bytes.saturating_add(size) > max_bytes || self.map.len() >= MAX_CACHED_IMAGES { + let Some(oldest) = self.map.iter().min_by_key(|(_, (_, used))| *used).map(|(k, _)| k.clone()) else { break }; + self.remove(&oldest); } + self.tick += 1; + self.map.insert(key, (p, self.tick)); self.bytes = self.bytes.saturating_add(size); } + fn remove(&mut self, key: &Key) { + if let Some((p, _)) = self.map.remove(key) { + self.bytes = self.bytes.saturating_sub(p.data().len() as u64); + } + } + /// The bytes of pixels held. #[cfg(test)] pub(crate) fn bytes(&self) -> u64 { From a95f95ae377cca33f23e316bf484dd3537641e7e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:54:50 +0700 Subject: [PATCH 103/120] #361: a replacement that only changes the case replaces every occurrence; the text's chars are collected once --- crates/engine/src/cmd/edit.rs | 33 +++++++++++++++------------------ crates/engine/src/tests.rs | 10 ++++++++++ 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index dba53f0a9..664d1ec35 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1009,9 +1009,14 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { let doc = s.doc_mut()?; let mut hits = Vec::new(); let needle: Vec = find.chars().collect(); + let r_chars: Vec = replace.as_deref().map(|r| r.chars().collect()).unwrap_or_default(); for i in doc.subtree(page) { let Some(n) = doc.node(i) else { continue }; - let text_hit = matches!(&n.kind, NodeKind::Text(t) if find_ci(&t.characters.chars().collect::>(), &needle, 0).is_some()); + let text: Option> = match &n.kind { + NodeKind::Text(t) => Some(t.characters.chars().collect()), + _ => None, + }; + let text_hit = text.as_deref().is_some_and(|c| find_ci(c, &needle, 0).is_some()); let name_hit = names && find_ci(&n.name.chars().collect::>(), &needle, 0).is_some(); if !(text_hit || name_hit) || n.is_page() { continue; @@ -1031,24 +1036,16 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { out.extend(chars.get(at..).unwrap_or_default()); n.name = out; } - if let NodeKind::Text(t) = &mut n.kind { - // Replace occurrence by occurrence so styles shift correctly. - // The search resumes after each inserted replacement, so a replacement that - // contains `find` neither loops forever nor stops the other occurrences. - let mut guard = 0; + if let (NodeKind::Text(t), Some(mut chars)) = (&mut n.kind, text) { + // Replace occurrence by occurrence so styles shift correctly. The search resumes + // after each inserted replacement, so a replacement that matches `find` (in any + // case) neither loops forever nor stops the other occurrences. let mut cursor = 0; - let len = find.chars().count(); - let r_len = r.chars().count(); - loop { - let from = t.characters.char_indices().nth(cursor).map_or(t.characters.len(), |(b, _)| b); - let Some(pos) = t.characters.get(from..).and_then(|tail| tail.find(find.as_str())) else { break }; - let start = cursor + t.characters.get(from..from + pos).map_or(0, |x| x.chars().count()); - t.replace_range(start, start + len, r); - cursor = start + r_len; - guard += 1; - if guard > 10_000 { - break; - } + for _ in 0..10_000 { + let Some(start) = find_ci(&chars, &needle, cursor) else { break }; + t.replace_range(start, start + needle.len(), r); + chars.splice(start..start + needle.len(), r_chars.iter().copied()); + cursor = start + r_chars.len(); } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0590514a4..dbf432d41 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -611,3 +611,13 @@ fn find_replace_matches_and_replaces_case_insensitively() { assert_eq!(chars(&s, u), "İ bye bye"); assert_eq!(s.doc().unwrap().node(f).unwrap().name, "bye box"); } + +#[test] +fn find_replace_changing_only_the_case_replaces_every_occurrence() { + let mut s = sess(); + let t = id(&s.execute("text.create", &json!({"text": "figcraft and FIGCRAFT, figcraft", "x": 0, "y": 0})).unwrap()); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 80, "width": 50, "height": 50, "name": "figcraft / figcraft"})).unwrap()); + s.execute("edit.findReplace", &json!({"find": "figcraft", "replace": "FigCraft", "layerNames": true})).unwrap(); + assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); + assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); +} From 50f12376179a34ba91c4fb3c01bcb65a265ddac8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:52:54 +0700 Subject: [PATCH 104/120] #272: refuse page drops released outside the visible pages list A page drag released over the Layers below could still reorder against a row scrolled out of view, and a layer dropped there could move to that page. The drop now counts only inside the list's visible area. The guard is ported from #366 by Skyfuller1303 (@Skyfuller1303). --- crates/ui-egui/src/layers.rs | 8 +++++++- crates/ui-egui/src/layers_behaviour_tests.rs | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index fd51c67ef..97203f94d 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -217,7 +217,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let mut reorder = crate::list_drag::ListDrag::in_order(ui, "pages"); // A long list scrolls in its own area, so the layers keep their room below it. let max_h = (ui.available_height() * 0.4).max(3.0 * ROW); - let moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { + let mut moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag()); reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); @@ -279,6 +279,12 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } reorder.finish(ui) }); + // A drop outside the list's visible area (and the row gap at its edges) isn't on a page row, + // even one scrolled out of view. + if !ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| moved.inner_rect.expand2(vec2(0.0, 3.0)).contains(p)) { + moved.inner = None; + move_to = None; + } if let Some((from, to)) = moved.inner && let Some((id, ..)) = pages.get(from) { diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index b4bb1c13c..43a8b76ea 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -271,6 +271,26 @@ fn a_long_pages_list_scrolls_and_leaves_room_for_the_layers() { assert!(top > 30, "the list scrolled past its first pages (top row: page {top})"); } +#[test] +fn a_page_dropped_below_the_pages_list_stays_where_it_was() { + let mut s = Session::new(); + let first = s.page().unwrap(); + let rect = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + let second = id(s.execute("page.add", &json!({})).unwrap()); + for _ in 0..58 { + s.execute("page.add", &json!({})).unwrap(); + } + let pages = s.doc().unwrap().pages.clone(); + s.execute("page.select", &json!({"id": first})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + let (row, layer) = (page_row(&mut u, second, first), u.row_at(rect)); + // Released over the layers, where pages scrolled out of view would be: no page row is there. + u.drag_to(row, layer); + u.button(layer, false); + assert_eq!(u.app.session.doc().unwrap().pages, pages, "the pages keep their order"); +} + /// A double click at `p`, with the screen reader tree checked on every frame: the platform adapter /// (UI Automation on Windows) aborts the app when the focused node isn't in the tree. fn double_click_checking_focus(u: &mut Ui, p: Pos2) { From e100c165fba1ff6955a7701b5adbef541653581b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:02:40 +0700 Subject: [PATCH 105/120] #346: FreeBSD's desktop entry matches the window class there (the binary's name) --- apps/figcraft/src/main.rs | 2 ++ packaging/freebsd/package.sh | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 73f820512..513056406 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -177,6 +177,8 @@ fn main() -> eframe::Result { control_server::start(p, queue.clone(), ctx_slot.clone()); } let icon = services::window_icon(); + // The app id is the Wayland app_id and the X11 window class on Linux: the desktop entry's name + // and StartupWMClass (FreeBSD's package matches the binary's name instead). let mut viewport = egui::ViewportBuilder::default() .with_title("FigCraft") .with_app_id("ai.storyteller.figcraft") diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index 693167656..bb9a77994 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -76,8 +76,11 @@ for b in "${BINS[@]}"; do install -m 755 "$BIN/$b" "$STAGE/bin/" strip "$STAGE/bin/$b" 2>/dev/null || true done -# The desktop entry, MIME type, metainfo and icons are the freedesktop files Linux ships. -install -m 644 "$LINUX/$APP_ID.desktop" "$STAGE/share/applications/$APP_ID.desktop" +# The desktop entry, MIME type, metainfo and icons are the freedesktop files Linux ships. The +# window's X11 class is the binary's name here (egui-winit names the window after the app id on +# Linux only), so the entry matches that. +sed -e "s/^StartupWMClass=.*/StartupWMClass=figcraft/" "$LINUX/$APP_ID.desktop" >"$STAGE/share/applications/$APP_ID.desktop" +chmod 644 "$STAGE/share/applications/$APP_ID.desktop" install -m 644 "$LINUX/$APP_ID.mime.xml" "$STAGE/share/mime/packages/$APP_ID.xml" sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$FIGCRAFT_BUILD_DATE/g" \ "$LINUX/$APP_ID.metainfo.xml.in" >"$STAGE/share/metainfo/$APP_ID.metainfo.xml" From dd84097b6d3611f6aeefbf01f5156f68e8f71021 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:04:16 +0700 Subject: [PATCH 106/120] #368: revoke a download's object URL after a delay Revoking it right after the click can cancel the download in some browsers; it now goes 40 s later on a timer. --- apps/figcraft-web/src/web.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 43ec2cf2f..8cadb0138 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -131,7 +131,8 @@ impl Services for WebServices { } fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> { - let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; let body = document.body().ok_or("no body")?; let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &{ @@ -146,5 +147,14 @@ fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> { body.append_child(&a)?; a.click(); a.remove(); - web_sys::Url::revoke_object_url(&url) + // Revoked later, not at once: some browsers cancel a download whose URL goes before it starts. + let revoke = wasm_bindgen::closure::Closure::once_into_js(move || { + let _ = web_sys::Url::revoke_object_url(&url); + }); + // Without the timer the URL just stays until the page closes. + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), REVOKE_DOWNLOAD_MS); + Ok(()) } + +/// How long a download's object URL is kept (milliseconds). +const REVOKE_DOWNLOAD_MS: i32 = 40_000; From 42c8e762a0d2af4f95d8ba8c0227f138c0c48a04 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:06:11 +0700 Subject: [PATCH 107/120] #469: Czech for the alignment hint added since (draft) --- crates/ui-egui/locales/cs.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index f54e8aded..aba0bec72 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -13,6 +13,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, commity: {commits}, +{added} / −{deleted} řádků (Δ {delta}), +{bin_added} / −{bin_deleted} binárních souborů, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Kolekce tohoto souboru s těmito režimy a proměnnými: přepište jejich hodnoty pro jednotlivé režimy a přidejte vlastní proměnné" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Samostatná vrstva se zarovná ke svému rámci: vyberte více vrstev nebo jednu v rámci" "A responsive product page: hero, features, figures, a quote and a footer" = "Responzivní produktová stránka: úvodní blok, funkce, čísla, citace a zápatí" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Pružina se pohybuje až do ustálení: délku určuje její hmotnost, tuhost a tlumení" "A title, an agenda, a big figure, a chart and a closing slide" = "Titulní snímek, program, výrazné číslo, graf a závěrečný snímek" From 62ed3452d6c32eef9ec63ef648ab2e65cd8c0592 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:07:26 +0700 Subject: [PATCH 108/120] #352: name the font list's height once The list's max height and the arrow keys' scroll offset now share one LIST_HEIGHT const. --- crates/ui-egui/src/typography.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index c76aeecde..0d9d3b8be 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -1065,6 +1065,8 @@ pub(crate) fn parse_letter_spacing(s: &str, cur: Option) -> Optio const RECENT_KEY: &str = "figcraft_recent_fonts"; const ROW: f32 = 26.0; +/// The font list's tallest (it scrolls past that). +const LIST_HEIGHT: f32 = 380.0; fn recent_families(ctx: &egui::Context) -> Vec { ctx.data_mut(|d| d.get_persisted::>(Id::new(RECENT_KEY))).unwrap_or_default() @@ -1162,7 +1164,7 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) // Scrolled just enough to show it. let seen: f32 = ui.data(|d| d.get_temp(hi_id.with("offset"))).unwrap_or(0.0); let top = i as f32 * stride; - offset = Some(seen.max(top + stride - 380.0).min(top)); + offset = Some(seen.max(top + stride - LIST_HEIGHT).min(top)); } } match hi { @@ -1180,7 +1182,7 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) } let mut budget = 4; let mut picked = None; - let mut area = egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]); + let mut area = egui::ScrollArea::vertical().max_height(LIST_HEIGHT).auto_shrink([false, true]); if let Some(y) = offset { area = area.vertical_scroll_offset(y); } From b2d5dfecc13f16d2432a8cde28d366f1f451108f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:08:37 +0700 Subject: [PATCH 109/120] #477: Indonesian for the alignment hint added since (draft) --- crates/ui-egui/locales/id.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 77ecc8066..5de7634d4 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -17,6 +17,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commit, +{added} / −{deleted} baris (Δ {delta}), +{bin_added} / −{bin_deleted} aset biner, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Koleksi file ini dengan mode dan variabel tersebut: timpa nilainya per mode dan tambahkan variabel sendiri" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Satu layer saja diratakan ke frame-nya: pilih lebih banyak layer, atau satu layer di dalam frame" "A responsive product page: hero, features, figures, a quote and a footer" = "Halaman produk responsif: hero, fitur, angka, kutipan, dan footer" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Spring berjalan hingga stabil: massa, kekakuan, dan redamannya menentukan durasinya" "A title, an agenda, a big figure, a chart and a closing slide" = "Judul, agenda, angka besar, bagan, dan slide penutup" From 8f58c39bd740901bc70aab798d2badf76fb81d7c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:12:26 +0700 Subject: [PATCH 110/120] #352: keep the highlighted font in view in a shorter list Where the window has less room the font list is shorter than its tallest, and stepping down left the highlighted row cut off at the bottom. The scroll now uses the list's height as last drawn. --- crates/ui-egui/src/typography.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 0d9d3b8be..35f6df769 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -1161,10 +1161,11 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) } } if let Some(i) = hi { - // Scrolled just enough to show it. - let seen: f32 = ui.data(|d| d.get_temp(hi_id.with("offset"))).unwrap_or(0.0); + // Scrolled just enough to show it (the list is shorter than its tallest where the + // window has less room). + let (seen, height): (f32, f32) = ui.data(|d| d.get_temp(hi_id.with("view"))).unwrap_or((0.0, LIST_HEIGHT)); let top = i as f32 * stride; - offset = Some(seen.max(top + stride - LIST_HEIGHT).min(top)); + offset = Some(seen.max(top + stride - height).min(top)); } } match hi { @@ -1231,7 +1232,7 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) } } }); - ui.data_mut(|d| d.insert_temp(hi_id.with("offset"), shown.state.offset.y)); + ui.data_mut(|d| d.insert_temp(hi_id.with("view"), (shown.state.offset.y, shown.inner_rect.height()))); FontPick { highlighted: hi.and_then(family), picked } } From 9894ee0e6542ce4fe9fd492338c229920b336ce1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:14:46 +0700 Subject: [PATCH 111/120] #308: pressing Design again keeps the pen path going Design is already the mode while the pen draws, so re-pressing it leaves nothing: only the Pencil goes back to Move there, as before. Leaving for Prototype or Inspect still ends the path. --- crates/ui-egui/src/pen_mode_tests.rs | 12 +++++++++--- crates/ui-egui/src/toolbar.rs | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/pen_mode_tests.rs b/crates/ui-egui/src/pen_mode_tests.rs index 31af1c663..d29d637ce 100644 --- a/crates/ui-egui/src/pen_mode_tests.rs +++ b/crates/ui-egui/src/pen_mode_tests.rs @@ -80,9 +80,7 @@ fn the_mode_switcher_ends_the_pen_path_too() { press(&mut esc, Key::Escape, Modifiers::NONE); layers(&esc) }; - // Design is already the mode while the pen draws: pressing it still leaves the pen, as it leaves - // the pencil of Draw. - for (label, tab) in [("Prototype", RightTab::Prototype), ("Inspect", RightTab::Inspect), ("Design", RightTab::Design)] { + for (label, tab) in [("Prototype", RightTab::Prototype), ("Inspect", RightTab::Inspect)] { let mut u = drawing(); let at = mode_button(&mut u, label); click(&mut u, at); @@ -91,6 +89,14 @@ fn the_mode_switcher_ends_the_pen_path_too() { assert_eq!(u.app.session.tool.tool, ToolId::Move, "{label}: back to Move"); assert_eq!(layers(&u), finished, "{label}: the path is kept as Escape keeps it"); } + // Design is already the mode while the pen draws: pressing it again leaves nothing, so the + // path goes on. + let mut u = drawing(); + let at = mode_button(&mut u, "Design"); + click(&mut u, at); + assert_eq!(u.app.ui.right_tab, RightTab::Design); + assert!(u.app.session.tool.pen.is_some(), "Design: the path goes on"); + assert_eq!(u.app.session.tool.tool, ToolId::Pen, "Design: still the pen"); } #[test] diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 3e4a224e1..a921cc293 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -174,7 +174,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { } 1 => { app.ui.right_tab = crate::RightTab::Design; - if matches!(app.session.tool.tool, ToolId::Pencil | ToolId::Pen) { + if app.session.tool.tool == ToolId::Pencil { app.run("tool.set", json!({"tool": "move"})); } } From 6291e436050449f5dc1757b36a77bb2a1ddd528f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:20:32 +0700 Subject: [PATCH 112/120] #322: use widgets::note for the vector edit hints The wrapped hint label duplicated widgets::note (a wrapped secondary note inside the panel's side padding); the two hints use it, and the copy and the extra spacing after them go. --- crates/ui-egui/src/vector_panel.rs | 14 +++----------- crates/ui-egui/src/widgets.rs | 6 ------ 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/vector_panel.rs b/crates/ui-egui/src/vector_panel.rs index 5a327ecc1..e965da350 100644 --- a/crates/ui-egui/src/vector_panel.rs +++ b/crates/ui-egui/src/vector_panel.rs @@ -12,7 +12,7 @@ use crate::FigApp; use crate::i18n::tr; use crate::icons::Icon; use crate::right::{CAP_ART_W, cap_art, join_label, menu_item, menu_item_with, menu_popup, row}; -use crate::widgets::{self, FieldEdit, Lead, PAD}; +use crate::widgets::{self, FieldEdit, Lead}; /// Run a point command from a field: typed values are one step, scrubs one step when released. fn point_field(app: &mut FigApp, cmd: &str, edit: Option, params: impl Fn(f64) -> Value) { @@ -38,11 +38,7 @@ fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { let sel = ve.width_point.and_then(|i| Some((i, *stroke?.profile()?.points.get(i)?))); let Some((i, p)) = sel else { let t = crate::theme::Tokens::get(ui.ctx()); - ui.horizontal(|ui| { - ui.add_space(PAD); - widgets::label_wrapped(ui, tr("Drag the stroke to add a width point"), t.text_secondary); - }); - ui.add_space(8.0); + widgets::note(ui, tr("Drag the stroke to add a width point"), t.text_secondary); return; }; let weight = stroke.map_or(1.0, |s| s.weight); @@ -85,11 +81,7 @@ pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { widgets::section_header(ui, if pts.len() > 1 { tr("Points") } else { tr("Point") }, &[], false); if pts.is_empty() { let t = crate::theme::Tokens::get(ui.ctx()); - ui.horizontal(|ui| { - ui.add_space(PAD); - widgets::label_wrapped(ui, tr("Select points to edit their position, radius and mirroring"), t.text_secondary); - }); - ui.add_space(8.0); + widgets::note(ui, tr("Select points to edit their position, radius and mirroring"), t.text_secondary); return; } let col = widgets::col_width(ui.available_width()); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index fe9e29cb3..db696a1ef 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -949,12 +949,6 @@ pub fn label(ui: &mut Ui, text: &str, color: Color32) { ui.label(egui::RichText::new(text).font(regular(11.0)).color(color)); } -/// Text that wraps at the width it is given, so a long hint never widens its panel (a wider -/// panel would move the canvas under the pointer). -pub fn label_wrapped(ui: &mut Ui, text: &str, color: Color32) { - ui.add(egui::Label::new(egui::RichText::new(text).font(regular(11.0)).color(color)).wrap()); -} - /// Text shown in `rect` that turns into an editor on click (or when its editor has focus, e.g. /// after a double-click rename). Returns the new text on Enter / focus loss. pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egui::FontId) -> Option { From a89031eab2ca2c9387bc5f8faf304fd58c569983 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:21:37 +0700 Subject: [PATCH 113/120] #300: emoji take a colour face before the symbol fonts, which drew them in one colour on Windows --- crates/text/src/color_tests.rs | 11 +++++++++++ crates/text/src/layout.rs | 18 +++++++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/text/src/color_tests.rs b/crates/text/src/color_tests.rs index 929d31122..fa92298bc 100644 --- a/crates/text/src/color_tests.rs +++ b/crates/text/src/color_tests.rs @@ -183,6 +183,17 @@ fn colour_emoji_carry_their_own_pixels() { } } +#[test] +fn emoji_in_plain_text_draw_from_a_colour_face() { + // A symbol font among the fallbacks (Windows has one) draws U+1F600 in one colour: the emoji + // face comes first for it. + if emoji_face().is_none() { + return; + } + let l = layout(&TextData::new("Hi \u{1F600}"), None, None); + assert!(l.glyphs.iter().any(|g| g.color(1.0).is_some()), "the emoji has colour ink"); +} + #[test] fn glyphs_place_their_ink_at_a_scale() { let Some(face) = emoji_face() else { return }; diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 9e4df34c0..dcd1eed2f 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -329,11 +329,10 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) faces.push(dbw.find(&s.font_family, &s.font_style).or_else(|| dbw.default_face())); } // Colour emoji are their own, large faces (Apple Color Emoji is ~180 MB): they are read only - // for a text whose characters nothing above covers. + // for a text with emoji, or with characters nothing above covers. let covered = |c: char| c == '\n' || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c)); - if chars.iter().any(|c| !covered(*c)) { - fallbacks.extend(dbw.emoji_fallbacks()); - } + let emoji = if chars.iter().any(|c| is_emoji(*c) || !covered(*c)) { dbw.emoji_fallbacks() } else { Vec::new() }; + fallbacks.extend(emoji.iter().cloned()); drop(dbw); // OpenType features the user toggled (global for the node). @@ -364,7 +363,9 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) if c == '\n' || primary.has_char(c) { return primary.clone(); } - fallbacks.iter().find(|f| f.has_char(c)).cloned().unwrap_or_else(|| primary.clone()) + // Emoji take a colour face before the symbol fonts, which draw many of them in one colour. + let colour = if is_emoji(c) { emoji.iter().find(|f| f.has_char(c)) } else { None }; + colour.or_else(|| fallbacks.iter().find(|f| f.has_char(c))).cloned().unwrap_or_else(|| primary.clone()) }; let first = chars.get(seg_start).copied().unwrap_or(' '); let face = face_for(first); @@ -988,6 +989,13 @@ fn sanitize_size(s: f64) -> f64 { if s.is_finite() { s.clamp(0.5, 5000.0) } else { 12.0 } } +/// A character drawn as a colour emoji by default: flags and the pictograph blocks. +/// shortcut: the older symbols (U+2600–U+27BF) keep their text look even before U+FE0F; read the +/// emoji presentation data if they should turn colour too. +fn is_emoji(c: char) -> bool { + matches!(c, '\u{1F1E6}'..='\u{1F1FF}' | '\u{1F300}'..='\u{1FAFF}') +} + /// One run of chars shaped with a single face and style. struct Segment<'a> { start: usize, From 4dc7c1b59c955fdece07f326ee61df9c33b2a628 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:25:05 +0700 Subject: [PATCH 114/120] #317: compute the backdrop once under a casting layer With drop shadows the backdrop was read twice: before the content and again after the shadows. The first read is only what backdrop effects inside the content see, so it now runs only when the layer has such descendants, and the content is copied for the shadow caster only when it gets a layer blur, noise or texture. The output is unchanged. --- crates/render/src/draw.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index af73aeebb..45b485003 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -429,7 +429,10 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep // the backdrop is read again once they lie under it (a shadow showing behind is seen through // it). Until then, the layer's content sees the backdrop read first under it (#255). let casts = back_shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::DropShadow(_))); - let first_back = read_backdrop(pix); + // Kept apart, the first read is only what backdrop effects inside the content see: without + // any, the backdrop (blur or glass, the costly part) is computed once, after the shadows. + let nested = || ctx.doc.subtree(id).iter().skip(1).any(|c| ctx.doc.node(*c).is_some_and(backdrop::has_effects)); + let first_back = if !casts || nested() { read_backdrop(pix) } else { None }; if !casts && let Some(b) = &first_back { layer.draw_pixmap(0, 0, b.as_ref(), &PixmapPaint::default(), Transform::identity(), None); } @@ -473,8 +476,10 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep // Drop shadows go under the layer, in effect-list order. { // Kept apart, the backdrop isn't in the layer yet: the shadows are cast from the content - // with the layer effects it will get. - let content = casts.then(|| { + // with the layer effects it will get (a copy only when it gets some). + let layer_fx = + n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::LayerBlur(_) | EffectKind::Noise(_) | EffectKind::Texture(_))); + let content = (casts && layer_fx).then(|| { let mut c = layer.clone(); layer_effects(ctx, &mut c, n, id, lxf); c From d25e302a5779f4c1939699ab588056ec44f1a4f3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:35:02 +0700 Subject: [PATCH 115/120] README: list the Czech and Indonesian drafts --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 70873d6cc..0910bf9e7 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust. - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. -- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). +- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). - **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). From 3d7191f4d3f9f2fc33c20563d662dd30b46d7971 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:35:24 +0700 Subject: [PATCH 116/120] people.toml: one entry per person after the merges --- contributors/people.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 4dc63b49a..e744e31c2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -24,9 +24,6 @@ display_name = "VanTan" real_name = "Tyler Jennings" display_name = "tylerjenningsw" -[people.VanTan666] -display_name = "VanTan" - [people.Silvertree2010] display_name = "Netter" sync_github_name = false From 51549f9a606939e774467ee4062de4e26f75d685 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:36:00 +0700 Subject: [PATCH 117/120] #306: say why the window can't open in the interface language The message now comes from the UI crate (dialogs::window_failed) through tr() and trf(), with entries in every catalog, and the app picks the language from the preferences before the event loop starts, so the box reads in it even though no frame was ever drawn. The commands to type stay as written. --- apps/figcraft/src/main.rs | 3 +++ apps/figcraft/src/startup.rs | 21 ++---------------- apps/figcraft/src/startup_tests.rs | 2 +- crates/ui-egui/locales/de.toml | 5 +++++ crates/ui-egui/locales/en.toml | 5 +++++ crates/ui-egui/locales/es.toml | 5 +++++ crates/ui-egui/locales/fr.toml | 5 +++++ crates/ui-egui/locales/ja.toml | 5 +++++ crates/ui-egui/locales/ko.toml | 5 +++++ crates/ui-egui/locales/pt-BR.toml | 5 +++++ crates/ui-egui/locales/zh-Hans.toml | 5 +++++ crates/ui-egui/src/dialogs.rs | 33 +++++++++++++++++++++++++++++ 12 files changed, 79 insertions(+), 20 deletions(-) diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 1babfd5b0..5debb538d 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -169,6 +169,9 @@ fn main() -> eframe::Result { if session.docs.is_empty() && !first_run { let _ = session.execute("file.new", &json!({})); } + // The interface language from the first frame on, and for the message when the window can't + // open (no frame then). + figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref()); let mut fig = FigApp::new(session, Box::new(svc)); fig.ui.home = false; fig.note_recovered(recovered.copies); diff --git a/apps/figcraft/src/startup.rs b/apps/figcraft/src/startup.rs index 8a50d7a5a..a25821f3f 100644 --- a/apps/figcraft/src/startup.rs +++ b/apps/figcraft/src/startup.rs @@ -4,28 +4,11 @@ //! graphics adapter, a surface or device the driver refuses) went to a standard error nobody sees //! and the app quit without a word (#261). Now it is logged and shown in a message box. -/// Where a report goes. -const ISSUES: &str = "https://github.com/storytold/figcraft/issues"; - -/// The text shown when the window couldn't open with `error`. `backend_set`: `WGPU_BACKEND` named -/// the graphics API, so the hint is to unset it rather than to try another one. -pub fn message(error: &str, backend_set: bool) -> String { - let hint = if backend_set { - "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." - } else if cfg!(windows) { - // Direct3D 12 and OpenGL are both offered (`graphics.rs`), but wgpu keeps the Direct3D 12 - // adapter it picked even when its device then fails, so naming OpenGL alone can still help. - "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:\n\nset WGPU_BACKEND=gl\nfigcraft.exe" - } else { - "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." - }; - format!("FigCraft couldn't open its window: {error}\n\n{hint}\n\nIf it keeps happening, please report it at {ISSUES} with this message.") -} - /// Log the failure and, unless `quiet` (started for an agent with `--control`: a modal box would /// keep the caller waiting, and it reads standard error), show it in a message box. pub fn failed(error: &eframe::Error, quiet: bool) { - let text = message(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some()); + // In the interface language: the text and its catalog entries are the UI crate's. + let text = figcraft_ui_egui::dialogs::window_failed(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some()); // Standard error even when RUST_LOG turned errors off. if log::log_enabled!(log::Level::Error) { log::error!("{text}"); diff --git a/apps/figcraft/src/startup_tests.rs b/apps/figcraft/src/startup_tests.rs index 6818ae777..0c92bd19b 100644 --- a/apps/figcraft/src/startup_tests.rs +++ b/apps/figcraft/src/startup_tests.rs @@ -1,7 +1,7 @@ //! The message shown when the window can't open: it carries the error, a next step and where to //! report it, instead of the app quitting silently (#261). -use crate::startup::message; +use figcraft_ui_egui::dialogs::window_failed as message; const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}"; diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index affda97dd..5987ec70a 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -457,6 +457,7 @@ status = "draft — needs native review" "Fade" = "Verblassen" "False" = "Falsch" "Fetching from the server…" = "Wird vom Server abgerufen…" +"FigCraft couldn't open its window: {error}" = "FigCraft konnte sein Fenster nicht öffnen: {error}" "File" = "Datei" "File name" = "Dateiname" "File published" = "Datei veröffentlicht" @@ -568,6 +569,7 @@ status = "draft — needs native review" "Hue" = "Farbton" "Hug" = "Umschließen" "Hug contents" = "Inhalt umschließen" +"If it keeps happening, please report it at {link} with this message." = "Wenn es weiterhin passiert, melden Sie es bitte mit dieser Meldung unter {link}." "If" = "Wenn" "If {condition}" = "Wenn {condition}" "Ignore auto layout (absolute position)" = "Auto-Layout ignorieren (absolute Position)" @@ -1323,6 +1325,8 @@ status = "draft — needs native review" "Update plugin" = "Plugin aktualisieren" "Updated {components} and {styles}" = "{components} und {styles} aktualisiert" "Updated {name}" = "{name} aktualisiert" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Oft hilft es, den Grafiktreiber zu aktualisieren. Um nur OpenGL zu verwenden, starten Sie FigCraft in der Eingabeaufforderung mit:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Oft hilft es, den Grafiktreiber zu aktualisieren. Mit WGPU_BACKEND (vulkan, gl, metal) lässt sich eine andere Grafik-API ausprobieren." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Lädt diese Datei auf einen FigCraft-Server hoch und hält diesen Tab mit ihm synchron. Sie erhalten einen Link zum Bearbeiten und einen zum Ansehen." "Uppercase" = "Großbuchstaben" "Use a fixed value" = "Festen Wert verwenden" @@ -1364,6 +1368,7 @@ status = "draft — needs native review" "Visibility" = "Sichtbarkeit" "Visible" = "Sichtbar" "Vivid" = "Lebhaft" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND ist gesetzt und legt die Grafik-API fest; ohne diese Variable wählt FigCraft selbst." "Warm" = "Warm" "Watch" = "Smartwatch" "Wavy" = "Gewellt" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 08ac64ba3..21df8bfeb 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -475,6 +475,7 @@ name = "English" "False" = "False" "Feb" = "Feb" "Fetching from the server…" = "Fetching from the server…" +"FigCraft couldn't open its window: {error}" = "FigCraft couldn't open its window: {error}" "File" = "File" "File name" = "File name" "File not found" = "File not found" @@ -594,6 +595,7 @@ name = "English" "Hug contents" = "Hug contents" "Icon components on a 24 px grid with keylines and a size preview" = "Icon components on a 24 px grid with keylines and a size preview" "Icon set starter" = "Icon set starter" +"If it keeps happening, please report it at {link} with this message." = "If it keeps happening, please report it at {link} with this message." "If" = "If" "If {condition}" = "If {condition}" "Ignore auto layout (absolute position)" = "Ignore auto layout (absolute position)" @@ -1389,6 +1391,8 @@ name = "English" "Update from selection" = "Update from selection" "Updated {components} and {styles}" = "Updated {components} and {styles}" "Updated {name}" = "Updated {name}" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." "Uppercase" = "Uppercase" "Use a fixed value" = "Use a fixed value" @@ -1431,6 +1435,7 @@ name = "English" "Visibility" = "Visibility" "Visible" = "Visible" "Vivid" = "Vivid" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." "Warm" = "Warm" "Watch" = "Watch" "Wavy" = "Wavy" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 3200e8913..af5c6a15b 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -476,6 +476,7 @@ status = "draft — needs native review" "False" = "Falso" "Feb" = "feb" "Fetching from the server…" = "Obteniendo del servidor…" +"FigCraft couldn't open its window: {error}" = "FigCraft no pudo abrir su ventana: {error}" "File" = "Archivo" "File name" = "Nombre del archivo" "File not found" = "No se encuentra el archivo" @@ -595,6 +596,7 @@ status = "draft — needs native review" "Hug contents" = "Ajustar al contenido" "Icon components on a 24 px grid with keylines and a size preview" = "Componentes de icono en una cuadrícula de 24 px con líneas guía y una vista de tamaños" "Icon set starter" = "Base de conjunto de iconos" +"If it keeps happening, please report it at {link} with this message." = "Si sigue pasando, infórmalo en {link} con este mensaje." "If" = "Si" "If {condition}" = "Si {condition}" "Ignore auto layout (absolute position)" = "Ignorar diseño automático (posición absoluta)" @@ -1390,6 +1392,8 @@ status = "draft — needs native review" "Update from selection" = "Actualizar desde la selección" "Updated {components} and {styles}" = "Actualizados {components} y {styles}" "Updated {name}" = "{name} actualizado" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Actualizar el controlador gráfico suele ayudar. Para usar solo OpenGL, inicia FigCraft desde el Símbolo del sistema con:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Actualizar el controlador gráfico suele ayudar. WGPU_BACKEND (vulkan, gl, metal) indica otra API gráfica que probar." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Sube este archivo a un servidor FigCraft y mantiene esta pestaña sincronizada con él. Obtienes un enlace para editar y otro para ver." "Uppercase" = "Mayúsculas" "Use a fixed value" = "Usar un valor fijo" @@ -1432,6 +1436,7 @@ status = "draft — needs native review" "Visibility" = "Visibilidad" "Visible" = "Visible" "Vivid" = "Vívido" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND está definida e indica la API gráfica que se usa; si la quitas, FigCraft elige por sí mismo." "Warm" = "Cálido" "Watch" = "Reloj" "Wavy" = "Ondulado" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 69798340f..1f5544c22 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -457,6 +457,7 @@ status = "draft — needs native review" "Fade" = "Fondu" "False" = "Faux" "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}" "File" = "Fichier" "File name" = "Nom du fichier" "File published" = "Fichier publié" @@ -568,6 +569,7 @@ status = "draft — needs native review" "Hue" = "Teinte" "Hug" = "Ajuster" "Hug contents" = "Ajuster au contenu" +"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}" "Ignore auto layout (absolute position)" = "Ignorer la mise en page auto (position absolue)" @@ -1323,6 +1325,8 @@ status = "draft — needs native review" "Update plugin" = "Mettre à jour le plugin" "Updated {components} and {styles}" = "{components} et {styles} mis à jour" "Updated {name}" = "{name} mis à jour" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Mettre à jour le pilote graphique aide souvent. Pour n’utiliser qu’OpenGL, lancez FigCraft depuis l’invite de commandes avec :" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Mettre à jour le pilote graphique aide souvent. WGPU_BACKEND (vulkan, gl, metal) désigne une autre API graphique à essayer." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Envoie ce fichier sur un serveur FigCraft et garde cet onglet synchronisé avec lui. Vous obtenez un lien de modification et un lien de consultation." "Uppercase" = "Majuscules" "Use a fixed value" = "Utiliser une valeur fixe" @@ -1364,6 +1368,7 @@ status = "draft — needs native review" "Visibility" = "Visibilité" "Visible" = "Visible" "Vivid" = "Vif" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND est définie et impose l’API graphique ; sans elle, FigCraft choisit lui-même." "Warm" = "Chaud" "Watch" = "Montre" "Wavy" = "Ondulé" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index fb81b042c..9174a65a8 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -478,6 +478,7 @@ status = "draft — needs native review" "False" = "false" "Feb" = "2月" "Fetching from the server…" = "サーバーから取得中…" +"FigCraft couldn't open its window: {error}" = "FigCraft のウィンドウを開けなかった:{error}" "File" = "ファイル" "File name" = "ファイル名" "File not found" = "ファイルが見つからない" @@ -597,6 +598,7 @@ status = "draft — needs native review" "Hug contents" = "内容に合わせる" "Icon components on a 24 px grid with keylines and a size preview" = "24 px グリッドのアイコンコンポーネント、キーライン、サイズのプレビュー" "Icon set starter" = "アイコンセットの雛形" +"If it keeps happening, please report it at {link} with this message." = "繰り返し起きる場合は、このメッセージを添えて {link} に報告。" "If" = "条件" "If {condition}" = "条件:{condition}" "Ignore auto layout (absolute position)" = "オートレイアウトを無視(絶対位置)" @@ -1392,6 +1394,8 @@ status = "draft — needs native review" "Update plugin" = "プラグインを更新" "Updated {components} and {styles}" = "{components}と{styles}を更新済み" "Updated {name}" = "{name} を更新済み" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "グラフィックスドライバーの更新で解決することが多い。OpenGL だけを使うには、コマンドプロンプトから次のように FigCraft を起動:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "グラフィックスドライバーの更新で解決することが多い。WGPU_BACKEND(vulkan、gl、metal)で別のグラフィックス API を試せる。" "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "このファイルを FigCraft サーバーにアップロードし、このタブを同期した状態に保つ。編集用リンクと閲覧用リンクが発行される。" "Uppercase" = "大文字" "Use a fixed value" = "固定値を使用" @@ -1434,6 +1438,7 @@ status = "draft — needs native review" "Visibility" = "表示" "Visible" = "表示" "Vivid" = "ビビッド" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND が設定されていて、使うグラフィックス API を指定している。解除すると FigCraft が選ぶ。" "Warm" = "ウォーム" "Watch" = "ウォッチ" "Wavy" = "波線" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index aed3529a5..e672efe70 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -458,6 +458,7 @@ status = "draft — needs native review" "Fade" = "페이드" "False" = "거짓" "Fetching from the server…" = "서버에서 가져오는 중…" +"FigCraft couldn't open its window: {error}" = "FigCraft 창을 열 수 없음: {error}" "File" = "파일" "File name" = "파일 이름" "File published" = "파일 게시됨" @@ -569,6 +570,7 @@ status = "draft — needs native review" "Hue" = "색조" "Hug" = "맞춤" "Hug contents" = "내용에 맞춤" +"If it keeps happening, please report it at {link} with this message." = "계속 발생하면 이 메시지와 함께 {link}에 알려 주세요." "If" = "조건" "If {condition}" = "조건: {condition}" "Ignore auto layout (absolute position)" = "자동 레이아웃 무시(절대 위치)" @@ -1324,6 +1326,8 @@ status = "draft — needs native review" "Update plugin" = "플러그인 업데이트" "Updated {components} and {styles}" = "{components} 및 {styles} 업데이트됨" "Updated {name}" = "{name} 업데이트됨" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "그래픽 드라이버를 업데이트하면 해결되는 경우가 많습니다. OpenGL만 사용하려면 명령 프롬프트에서 다음과 같이 FigCraft를 시작하세요:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "그래픽 드라이버를 업데이트하면 해결되는 경우가 많습니다. WGPU_BACKEND(vulkan, gl, metal)로 다른 그래픽 API를 시도할 수 있습니다." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "이 파일을 FigCraft 서버에 업로드하고 이 탭을 동기화된 상태로 유지함. 편집 링크와 보기 링크가 생성됨." "Uppercase" = "대문자" "Use a fixed value" = "고정 값 사용" @@ -1365,6 +1369,7 @@ status = "draft — needs native review" "Visibility" = "표시 여부" "Visible" = "표시됨" "Vivid" = "선명하게" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND가 설정되어 사용할 그래픽 API를 지정하고 있습니다. 설정을 해제하면 FigCraft가 선택합니다." "Warm" = "따뜻하게" "Watch" = "시계" "Wavy" = "물결선" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 975002363..f49beb30a 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -456,6 +456,7 @@ status = "draft — needs native review" "Fade" = "Esmaecer" "False" = "Falso" "Fetching from the server…" = "Buscando no servidor…" +"FigCraft couldn't open its window: {error}" = "O FigCraft não conseguiu abrir a janela: {error}" "File" = "Arquivo" "File name" = "Nome do arquivo" "File published" = "Arquivo publicado" @@ -567,6 +568,7 @@ status = "draft — needs native review" "Hue" = "Matiz" "Hug" = "Ajustar" "Hug contents" = "Ajustar ao conteúdo" +"If it keeps happening, please report it at {link} with this message." = "Se continuar acontecendo, informe em {link} com esta mensagem." "If" = "Se" "If {condition}" = "Se {condition}" "Ignore auto layout (absolute position)" = "Ignorar layout automático (posição absoluta)" @@ -1322,6 +1324,8 @@ status = "draft — needs native review" "Update plugin" = "Atualizar plugin" "Updated {components} and {styles}" = "{components} e {styles} atualizados" "Updated {name}" = "{name} atualizado" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Atualizar o driver de vídeo costuma ajudar. Para usar só o OpenGL, abra o FigCraft pelo Prompt de Comando com:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Atualizar o driver de vídeo costuma ajudar. WGPU_BACKEND (vulkan, gl, metal) indica outra API gráfica para tentar." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Envia este arquivo para um servidor FigCraft e mantém esta aba sincronizada com ele. Você recebe um link para editar e outro para visualizar." "Uppercase" = "Maiúsculas" "Use a fixed value" = "Usar um valor fixo" @@ -1363,6 +1367,7 @@ status = "draft — needs native review" "Visibility" = "Visibilidade" "Visible" = "Visível" "Vivid" = "Vívido" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND está definida e indica a API gráfica a usar; sem ela, o FigCraft escolhe sozinho." "Warm" = "Quente" "Watch" = "Relógio" "Wavy" = "Ondulado" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index d1887735e..70a05c5f7 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -459,6 +459,7 @@ status = "draft — needs native review" "Fade" = "褪色" "False" = "假" "Fetching from the server…" = "正在从服务器获取…" +"FigCraft couldn't open its window: {error}" = "FigCraft 无法打开窗口:{error}" "File" = "文件" "File name" = "文件名" "File published" = "文件已发布" @@ -570,6 +571,7 @@ status = "draft — needs native review" "Hue" = "色相" "Hug" = "适应" "Hug contents" = "适应内容" +"If it keeps happening, please report it at {link} with this message." = "如果问题持续出现,请附上此消息到 {link} 报告。" "If" = "如果" "If {condition}" = "如果 {condition}" "Ignore auto layout (absolute position)" = "忽略自动布局(绝对定位)" @@ -1325,6 +1327,8 @@ status = "draft — needs native review" "Update plugin" = "更新插件" "Updated {components} and {styles}" = "已更新 {components} 和 {styles}" "Updated {name}" = "已更新 {name}" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "更新显卡驱动通常会有帮助。若只想使用 OpenGL,请在命令提示符中这样启动 FigCraft:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "更新显卡驱动通常会有帮助。可用 WGPU_BACKEND(vulkan、gl、metal)指定另一个图形 API 来尝试。" "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "将此文件上传到 FigCraft 服务器,并使此标签页与之保持同步。你会获得一个编辑链接和一个查看链接。" "Uppercase" = "大写" "Use a fixed value" = "使用固定值" @@ -1366,6 +1370,7 @@ status = "draft — needs native review" "Visibility" = "可见性" "Visible" = "可见" "Vivid" = "鲜艳" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "已设置 WGPU_BACKEND,它指定了要使用的图形 API;取消设置后由 FigCraft 自行选择。" "Warm" = "暖调" "Watch" = "手表" "Wavy" = "波浪线" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 221ee6361..5f51a6100 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -15,6 +15,39 @@ use crate::theme::{ACCENT, Tokens, regular, semibold}; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Where a report goes. +const ISSUES: &str = "https://github.com/storytold/figcraft/issues"; + +/// What the desktop app says when its window couldn't open with `error` (no frame was drawn: the +/// language is the one [`i18n::set`] picked from the preferences). `backend_set`: `WGPU_BACKEND` +/// named the graphics API, so the hint is to unset it rather than to try another one. +pub fn window_failed(error: &str, backend_set: bool) -> String { + let hint = if backend_set { + tr("WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose.").to_string() + } else if cfg!(windows) { + // Direct3D 12 and OpenGL are both offered, but wgpu keeps the Direct3D 12 adapter it picked + // even when its device then fails, so naming OpenGL alone can still help. + format!( + "{} + +set WGPU_BACKEND=gl +figcraft.exe", + tr("Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:") + ) + } else { + tr("Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try.").to_string() + }; + format!( + "{} + +{hint} + +{}", + trf("FigCraft couldn't open its window: {error}", &[("error", &error)]), + trf("If it keeps happening, please report it at {link} with this message.", &[("link", &ISSUES)]) + ) +} + /// A dialog window, centred. pub(crate) fn window<'a>(title: &'a str, ctx: &Context) -> egui::Window<'a> { floating(title, ctx).anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0)) From 1d5c9ae0105252db695b8d47816f3febafeca8e7 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 11:15:32 +0200 Subject: [PATCH 118/120] fix(ui): scroll a long pages list in its own area so the layers stay in view (fixes #258) --- crates/ui-egui/src/layers.rs | 117 ++++++++++--------- crates/ui-egui/src/layers_behaviour_tests.rs | 44 +++++++ 2 files changed, 105 insertions(+), 56 deletions(-) diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 927e3ae47..fd51c67ef 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -215,66 +215,71 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let mut move_to: Option<(NodeId, String)> = None; // Pages are dragged up and down the list to reorder them. let mut reorder = crate::list_drag::ListDrag::in_order(ui, "pages"); - for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag()); - reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); - a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur); - let r = r.shrink2(vec2(8.0, 0.0)); - a11y::ring_around(ui, r, &resp); - let renaming = app.ui.rename_page.as_ref().is_some_and(|(i, _)| *i == id); - if divider && !renaming { - // A divider is a line between pages: it opens nothing and takes no layers. - ui.painter().line_segment([pos2(r.min.x + 10.0, r.center().y), pos2(r.max.x - 10.0, r.center().y)], Stroke::new(1.0, t.border)); - } else if resp.hovered() || id == cur { - ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); - } - if dragging.is_some() - && id != cur - && !divider - // The gap between rows counts too. - && pointer.is_some_and(|p| resp.interact_rect.expand2(vec2(0.0, 3.0)).contains(p)) - { - ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside); - if ui.input(|i| i.pointer.any_released()) { - move_to = Some((id, name.clone())); + // A long list scrolls in its own area, so the layers keep their room below it. + let max_h = (ui.available_height() * 0.4).max(3.0 * ROW); + let moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { + for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag()); + reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); + a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur); + let r = r.shrink2(vec2(8.0, 0.0)); + a11y::ring_around(ui, r, &resp); + let renaming = app.ui.rename_page.as_ref().is_some_and(|(i, _)| *i == id); + if divider && !renaming { + // A divider is a line between pages: it opens nothing and takes no layers. + ui.painter().line_segment([pos2(r.min.x + 10.0, r.center().y), pos2(r.max.x - 10.0, r.center().y)], Stroke::new(1.0, t.border)); + } else if resp.hovered() || id == cur { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } - } - if renaming { - let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0)); - let start = ui.data_mut(|d| d.remove_temp::(page_rename_start_id())).unwrap_or(false); - let font = if id == cur { semibold(11.0) } else { regular(11.0) }; - if let (Some(new), _) = crate::widgets::rename_field(ui, Id::new(("pagename", id)), &name, tr, font, start) { - rename_done = Some((id, new)); + if dragging.is_some() + && id != cur + && !divider + // The gap between rows counts too. + && pointer.is_some_and(|p| resp.interact_rect.expand2(vec2(0.0, 3.0)).contains(p)) + { + ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside); + if ui.input(|i| i.pointer.any_released()) { + move_to = Some((id, name.clone())); + } } - } else if !divider { - let font = if id == cur { semibold(11.0) } else { regular(11.0) }; - crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 10.0, r.center().y), &name, font, t.text, r.width() - 14.0); - } - if resp.clicked() && !divider { - select = Some(id); - } - if resp.double_clicked() { - app.ui.rename_page = Some((id, name.clone())); - // The field takes the focus next frame, once it's drawn: focusing it now would put a - // node that isn't there yet in the screen reader tree, which AccessKit aborts on. - ui.data_mut(|d| d.insert_temp(page_rename_start_id(), true)); - } - crate::right::context_popup(&resp).show(|ui| { - if ui.button(tr("Rename page")).clicked() { + if renaming { + let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0)); + let start = ui.data_mut(|d| d.remove_temp::(page_rename_start_id())).unwrap_or(false); + let font = if id == cur { semibold(11.0) } else { regular(11.0) }; + if let (Some(new), _) = crate::widgets::rename_field(ui, Id::new(("pagename", id)), &name, tr, font, start) { + rename_done = Some((id, new)); + } + } else if !divider { + let font = if id == cur { semibold(11.0) } else { regular(11.0) }; + crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 10.0, r.center().y), &name, font, t.text, r.width() - 14.0); + } + if resp.clicked() && !divider { + select = Some(id); + } + if resp.double_clicked() { app.ui.rename_page = Some((id, name.clone())); - ui.close(); + // The field takes the focus next frame, once it's drawn: focusing it now would put a + // node that isn't there yet in the screen reader tree, which AccessKit aborts on. + ui.data_mut(|d| d.insert_temp(page_rename_start_id(), true)); } - if ui.button(tr("Duplicate page")).clicked() { - app.run("page.duplicate", json!({"id": id})); - ui.close(); - } - if ui.button(tr("Delete page")).clicked() { - app.run("page.delete", json!({"id": id})); - ui.close(); - } - }); - } - if let Some((from, to)) = reorder.finish(ui) + crate::right::context_popup(&resp).show(|ui| { + if ui.button(tr("Rename page")).clicked() { + app.ui.rename_page = Some((id, name.clone())); + ui.close(); + } + if ui.button(tr("Duplicate page")).clicked() { + app.run("page.duplicate", json!({"id": id})); + ui.close(); + } + if ui.button(tr("Delete page")).clicked() { + app.run("page.delete", json!({"id": id})); + ui.close(); + } + }); + } + reorder.finish(ui) + }); + if let Some((from, to)) = moved.inner && let Some((id, ..)) = pages.get(from) { app.run("page.move", json!({"id": id, "index": to})); diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index 155f0d6d7..1d214aad0 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -267,3 +267,47 @@ fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree() assert_eq!(u.app.ui.rename_page.as_ref().map(|r| r.0), Some(p1)); assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); } + +/// The page opened by a click on each spot from just under the pages header down to `to`. +fn pages_down_to(u: &mut Ui, to: f32) -> Vec { + let mut opened = Vec::new(); + for y in (190..to as i32).step_by(8) { + click(u, Pos2::new(150.0, y as f32), Modifiers::NONE); + opened.push(u.app.session.page().unwrap()); + } + opened +} + +#[test] +fn a_long_pages_list_scrolls_and_leaves_room_for_the_layers() { + let mut s = Session::new(); + let first = s.page().unwrap(); + let rect = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + for _ in 0..59 { + s.execute("page.add", &json!({})).unwrap(); + } + let pages = s.doc().unwrap().pages.clone(); + let last = *pages.last().unwrap(); + s.execute("page.select", &json!({"id": first})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + // The layers list stays on screen under 60 pages. + let layer = u.row_at(rect); + // The wheel over the pages list scrolls it to its last page. + let wheel = Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -400.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }; + for _ in 0..10 { + u.frame(vec![Event::PointerMoved(Pos2::new(150.0, 200.0)), wheel.clone()]); + } + for _ in 0..30 { + u.frame(vec![]); + } + let opened = pages_down_to(&mut u, layer.y - 60.0); + assert!(opened.contains(&last), "the last page can be reached"); + let top = pages.iter().position(|p| Some(p) == opened.first()).unwrap(); + assert!(top > 30, "the list scrolled past its first pages (top row: page {top})"); +} From ff3981de731e8ea0f84383d2e98e760a8ac21ff5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:59:24 +0700 Subject: [PATCH 119/120] #300: emoji take a colour face before a style face without colour glyphs (DejaVu Sans on Linux drew them in one colour); the scale test allows faces wider than the em --- crates/text/src/color_tests.rs | 7 ++++--- crates/text/src/layout.rs | 12 +++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/text/src/color_tests.rs b/crates/text/src/color_tests.rs index fa92298bc..9ccf0b9c7 100644 --- a/crates/text/src/color_tests.rs +++ b/crates/text/src/color_tests.rs @@ -185,8 +185,8 @@ fn colour_emoji_carry_their_own_pixels() { #[test] fn emoji_in_plain_text_draw_from_a_colour_face() { - // A symbol font among the fallbacks (Windows has one) draws U+1F600 in one colour: the emoji - // face comes first for it. + // The style's own face (DejaVu Sans on Linux) or a symbol font among the fallbacks (Windows has + // one) draws U+1F600 in one colour: the emoji face comes first for it. if emoji_face().is_none() { return; } @@ -207,7 +207,8 @@ fn glyphs_place_their_ink_at_a_scale() { assert!(g.color(2.0).is_some(), "the emoji glyph has ink"); return; }; - assert!(bitmap.width >= 32 && bitmap.width <= 65, "{} px at 2:1", bitmap.width); + // Some emoji faces draw wider than the em (Noto Color Emoji's strikes are about 1.25 em). + assert!(bitmap.width >= 32 && bitmap.width <= 96, "{} px at 2:1", bitmap.width); let corner = place * figcraft_geom::Point::new(0.0, 0.0); assert!(corner.x >= g.x - g.size && corner.x <= g.x + g.size, "x {} vs pen {}", corner.x, g.x); assert!(corner.y >= g.y - g.size && corner.y <= g.y + g.size, "y {} vs baseline {}", corner.y, g.y); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index dcd1eed2f..836fe6a72 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -360,12 +360,18 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let mut seg_start = *start; while seg_start < *end { let face_for = |c: char| -> Arc { + // Emoji take a colour face before a face without colour glyphs (the style's own or a + // symbol font), which would draw them in one colour. + if is_emoji(c) + && !primary.has_color() + && let Some(f) = emoji.iter().find(|f| f.has_char(c)) + { + return f.clone(); + } if c == '\n' || primary.has_char(c) { return primary.clone(); } - // Emoji take a colour face before the symbol fonts, which draw many of them in one colour. - let colour = if is_emoji(c) { emoji.iter().find(|f| f.has_char(c)) } else { None }; - colour.or_else(|| fallbacks.iter().find(|f| f.has_char(c))).cloned().unwrap_or_else(|| primary.clone()) + fallbacks.iter().find(|f| f.has_char(c)).cloned().unwrap_or_else(|| primary.clone()) }; let first = chars.get(seg_start).copied().unwrap_or(' '); let face = face_for(first); From 8c731ddedcf64e7b8e962212c0085130392885f8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 20:22:37 +0700 Subject: [PATCH 120/120] ci: the FreeBSD VM installs DejaVu, so text tests measure with a real font as on Ubuntu --- .github/workflows/freebsd.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index feb661324..dcd0192f0 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -43,8 +43,9 @@ jobs: mem: 12288 # Nothing to bring back; copying the target dir out of the VM fails on large builds. copyback: false + # dejavu: the text tests need one real font, as the Ubuntu runner has. prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 + pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 dejavu curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal run: | set -e