Merge main into #1873

This commit is contained in:
bflatastic
2026-11-07 12:15:09 +07:00
33 changed files with 748 additions and 146 deletions
Generated
+13 -1
View File
@@ -1157,10 +1157,21 @@ dependencies = [
"zip 9.1.1",
]
[[package]]
name = "craft-web-api"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"js-sys",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"wasm-bindgen",
"web-sys",
@@ -2130,6 +2141,7 @@ name = "figcraft-web"
version = "0.15.0"
dependencies = [
"base64 0.22.1",
"craft-web-api",
"craft-web-host",
"eframe",
"egui",
+1 -1
View File
@@ -104,7 +104,7 @@ native speakers' reviews of the nine language drafts need people.
| Traditional Chinese (Taiwan) | zh-Hant | 2,647 / 2,647 entries (100%, counted 2026-11-03) | draft (needs native review) |
| Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) |
| Hindi | hi | 0% | none |
| Arabic | ar | 2,508 / 2,508 entries (100%, counted 2026-10-28) | draft; native review and panel mirroring pending |
| Arabic | ar | 2,508 / 2,508 entries (100%, counted 2026-10-28) | draft; the window mirrors (panels swap sides); native review and mirrored panel rows pending |
| French | fr | 95% | draft (needs native review) |
| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) |
| Indonesian | id | 99% | draft (needs native review) |
+40 -7
View File
@@ -10,6 +10,12 @@ use serde_json::{Value, json};
use crate::{CliError, CliResult};
/// A document's or plugin's text as a terminal should show it: control characters escaped, and
/// everything else (quotes, backslashes in paths) left as it is.
fn plain(text: &str) -> String {
text.chars().map(|c| if c.is_control() { c.escape_default().to_string() } else { c.to_string() }).collect()
}
pub fn load(path: &str) -> CliResult<Document> {
let (doc, notes) = load_noted(path)?;
if ext(path) == "penpot" {
@@ -199,26 +205,33 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
return Ok(ExitCode::SUCCESS);
}
for n in &notes {
eprintln!("note: {n}");
eprintln!("note: {}", plain(&n.to_string()));
}
println!(
"{} — {} ({} nodes, {} styles, {} variables, {} images)",
path,
doc.name,
plain(path),
plain(&doc.name),
doc.node_count(),
doc.styles.len(),
doc.variables.variables.len(),
doc.images.len()
);
for p in &pages {
println!("page {} \"{}\": {} nodes, {} top-level", p["id"], p["name"].as_str().unwrap_or(""), p["nodes"], p["topLevel"]);
println!("page {} \"{}\": {} nodes, {} top-level", p["id"], plain(p["name"].as_str().unwrap_or("")), p["nodes"], p["topLevel"]);
for f in p["frames"].as_array().into_iter().flatten() {
println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]);
println!(
" {} {} \"{}\" {}×{}",
f["id"],
f["type"].as_str().unwrap_or(""),
plain(f["name"].as_str().unwrap_or("")),
f["width"],
f["height"]
);
}
}
for m in &missing {
let layers = if m.layers == 1 { "text layer" } else { "text layers" };
println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family, m.layers);
println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", plain(&m.family), m.layers);
}
Ok(ExitCode::SUCCESS)
}
@@ -438,7 +451,8 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
let cmd = steps.get(failed_at).map_or("", |step| step.command.as_str());
let i = failed_at;
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true}));
eprintln!("error: step {i} ({cmd}) failed: {e}");
// Plugin-supplied text: escape terminal controls and line breaks.
eprintln!("error: step {i} ({}) failed: {}", plain(cmd), plain(&e.to_string()));
eprintln!("the script's document changes were rolled back");
return Ok(ExitCode::from(1));
}
@@ -491,6 +505,25 @@ pub fn connect_publish(file: &str, mappings: &str, dry_run: bool) -> CliResult<E
mod tests {
use super::*;
/// `info` and a script's failure print what a file and its steps name, controls escaped.
#[test]
fn info_and_a_failed_script_print_what_they_were_given() {
let dir = std::env::temp_dir().join(format!("figcraft-cli-plain-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (svg, script) = (dir.join("a.svg"), dir.join("s.json"));
std::fs::write(&svg, r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>"#).unwrap();
std::fs::write(&script, r#"[{"command":"no.such\u001b"}]"#).unwrap();
assert_eq!(info(svg.to_str().unwrap(), false).unwrap(), ExitCode::SUCCESS);
assert_eq!(run_script(script.to_str().unwrap(), None, None).unwrap(), ExitCode::from(1));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn plain_escapes_controls_and_leaves_quotes_and_paths() {
assert_eq!(plain("a\u{1b}[31m\nb"), r"a\u{1b}[31m\nb");
assert_eq!(plain(r"Bob's C:\files"), r"Bob's C:\files");
}
#[test]
fn script_formats() {
assert_eq!(parse_script(r#"[{"command":"a"},{"command":"b","params":{"x":1}}]"#).unwrap().len(), 2);
+3 -1
View File
@@ -20,7 +20,9 @@ figcraft-ui-egui = { workspace = true }
figcraft-collab-net = { workspace = true }
figcraft-text = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
# The page command API (storytold/craft-libs#413): the request plumbing behind window.figcraft.
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
base64 = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
+103 -63
View File
@@ -1,36 +1,54 @@
//! `window.figcraft`: the control channel for the page, as promises.
//! `window.figcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) plus FigCraft's
//! own file functions.
//!
//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent
//! driving FigCraft in a browser tab (or an `<iframe>` in another app) uses the same commands as
//! the CLI and the MCP server:
//! The crate installs `request`, `execute`, `commands`, `docInspect`, `uiInspect` and `screenshot`
//! over the same JSON methods as the desktop control server (`docs/control-protocol.md`), as
//! promises with a timeout, so an agent driving FigCraft in a browser tab (or an `<iframe>` in
//! another app) uses the same commands as the CLI and the MCP server:
//!
//! ```js
//! await figcraft.execute("node.create", {kind: "frame", name: "Home"}) // engine command → its result
//! await figcraft.execute("node.create", {type: "frame", name: "Home"}) // engine command → its result
//! await figcraft.request("document.inspect") // control method → its result
//! await figcraft.commands() // command list
//! const {data} = await figcraft.screenshot() // the canvas as PNG (base64)
//! ```
//!
//! FigCraft adds, on the same object:
//!
//! ```js
//! await figcraft.openFile("home.fig", bytes) // `file.open` with the bytes
//! figcraft.captureDownloads(true) // saves stay in the page…
//! figcraft.files(); await figcraft.readFile("home.figcraft") // …to be read back
//! await figcraft.inspect() // `document.inspect`
//! ```
//!
//! Results resolve with the method's `result`; errors reject with an `Error`.
use std::cell::{Cell, RefCell};
use std::future::Future;
use std::pin::Pin;
use std::rc::Rc;
use std::sync::mpsc::{Receiver, TryRecvError};
use base64::Engine as _;
use figcraft_ui_egui::{ControlReply, ControlRequest};
use craft_web_api::methods::Methods;
use craft_web_api::page::{PageApi, Request};
use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate};
use serde_json::{Value, json};
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::{JsFuture, future_to_promise};
use crate::saved_files::SavedFiles;
type Waiting = (Receiver<ControlReply>, js_sys::Function, js_sys::Function);
/// The app id in `window.<app>`.
const APP: &str = "figcraft";
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
const POLL_MS: i32 = 16;
thread_local! {
static QUEUE: RefCell<Vec<ControlRequest>> = const { RefCell::new(Vec::new()) };
static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
static NEXT_ID: Cell<u64> = const { Cell::new(0) };
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
static CAPTURE: Cell<bool> = const { Cell::new(false) };
@@ -59,88 +77,112 @@ fn to_js(v: &Value) -> JsValue {
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
}
fn from_js(v: &JsValue) -> Value {
if v.is_undefined() || v.is_null() {
return json!({});
}
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
/// A queued control request whose reply has not arrived. Dropping it before the reply (the page's
/// timeout fired) cancels the request if the app has not started it: the page was told it failed,
/// so the app must not run it late.
struct Waiting {
reply: Receiver<ControlReply>,
gate: RequestGate,
answered: bool,
}
/// Settle the promises of answered requests (called every frame).
pub fn poll_replies() {
WAITING.with(|w| {
w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() {
Ok(ControlReply { result: Ok(v), .. }) => {
let _ = resolve.call1(&JsValue::NULL, &to_js(&v));
false
impl Waiting {
/// The reply as the protocol's object once the app has answered or gone away; `None` while the
/// request is still waiting.
fn poll(&mut self) -> Option<Value> {
match self.reply.try_recv() {
Ok(ControlReply { result, .. }) => {
self.answered = true;
Some(match result {
Ok(value) => json!({"ok": true, "result": value}),
Err(error) => json!({"ok": false, "error": error}),
})
}
Ok(ControlReply { result: Err(e), .. }) => {
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&e));
false
}
Err(TryRecvError::Empty) => true,
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => {
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("request dropped"));
false
self.answered = true;
Some(json!({"ok": false, "error": "request dropped"}))
}
});
});
}
}
}
fn request(method: &str, params: Value) -> js_sys::Promise {
let method = method.to_string();
let p = js_sys::Promise::new(&mut |resolve, reject| {
let (reply, rx) = std::sync::mpsc::channel();
let id = NEXT_ID.with(|n| {
n.set(n.get() + 1);
n.get()
});
let req = ControlRequest { id: json!(id), method: method.clone(), params: params.clone(), reply, state: Default::default() };
QUEUE.with(|q| q.borrow_mut().push(req));
WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject)));
impl Drop for Waiting {
fn drop(&mut self) {
if !self.answered {
self.gate.cancel_if_open();
}
}
}
/// Queue a control request for the next frame and wait for the app's reply, as the protocol's
/// `{ok, result}` / `{ok: false, error}` object.
fn send(method: &str, params: Value) -> Pin<Box<dyn Future<Output = Value>>> {
let (reply, rx) = std::sync::mpsc::channel();
let id = NEXT_ID.with(|n| {
n.set(n.get() + 1);
n.get()
});
let gate = RequestGate::new();
QUEUE.with(|q| q.borrow_mut().push(ControlRequest { id: json!(id), method: method.to_string(), params, reply, state: gate.clone() }));
CTX.with(|c| {
if let Some(ctx) = c.borrow().as_ref() {
ctx.request_repaint();
}
});
p
let mut waiting = Waiting { reply: rx, gate, answered: false };
Box::pin(async move {
loop {
if let Some(reply) = waiting.poll() {
return reply;
}
sleep(POLL_MS).await;
}
})
}
async fn sleep(ms: i32) {
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(window) = web_sys::window() {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
}
});
let _ = JsFuture::from(promise).await;
}
/// A reply as a promise outcome: the `result`, or an `Error` carrying the `error`.
fn outcome(reply: &Value) -> Result<JsValue, JsValue> {
if reply.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(to_js(reply.get("result").unwrap_or(&Value::Null)))
} else {
Err(js_sys::Error::new(reply.get("error").and_then(Value::as_str).unwrap_or("error")).into())
}
}
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
}
/// Install `window.figcraft`.
/// Install `window.figcraft`: the crate's functions, then FigCraft's own.
pub fn install() -> Result<(), JsValue> {
let obj = js_sys::Object::new();
let request: Request = Rc::new(|method, params| send(&method, params));
Rc::new(PageApi::new(APP, Methods::default(), request)).install()?;
let window = web_sys::window().ok_or("no window")?;
let obj: js_sys::Object = js_sys::Reflect::get(&window, &APP.into())?.unchecked_into();
set_fn(
&obj,
"request",
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|method: String, params: JsValue| request(&method, from_js(&params)))
"inspect",
Closure::<dyn Fn() -> js_sys::Promise>::new(|| future_to_promise(async { outcome(&send("document.inspect", json!({})).await) }))
.into_js_value(),
)?;
set_fn(
&obj,
"execute",
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|command: String, params: JsValue| {
request("engine.execute", json!({"command": command, "params": from_js(&params)}))
})
.into_js_value(),
)?;
set_fn(&obj, "commands", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("engine.commands", json!({}))).into_js_value())?;
set_fn(&obj, "inspect", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("document.inspect", json!({}))).into_js_value())?;
set_fn(
&obj,
"screenshot",
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|params: JsValue| request("ui.screenshot", from_js(&params))).into_js_value(),
)?;
set_fn(
&obj,
"openFile",
Closure::<dyn Fn(String, js_sys::Uint8Array) -> js_sys::Promise>::new(|name: String, bytes: js_sys::Uint8Array| {
let data = base64::engine::general_purpose::STANDARD.encode(bytes.to_vec());
request("engine.execute", json!({"command": "file.open", "params": {"name": name, "data": data}}))
future_to_promise(async move {
outcome(&send("engine.execute", json!({"command": "file.open", "params": {"name": name, "data": data}})).await)
})
})
.into_js_value(),
)?;
@@ -166,7 +208,5 @@ pub fn install() -> Result<(), JsValue> {
})
.into_js_value(),
)?;
let w = web_sys::window().ok_or("no window")?;
js_sys::Reflect::set(&w, &"figcraft".into(), &obj)?;
Ok(())
}
-1
View File
@@ -225,7 +225,6 @@ impl eframe::App for WebShell {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.fig.control_queue.extend(crate::api::take_requests());
self.fig.ui(ui);
crate::api::poll_replies();
// The fonts on this computer that text asked for this frame are read; once one is in,
// auto-sizing text measured in a fallback fits it.
crate::local_fonts::load_wanted(ui.ctx());
+5 -1
View File
@@ -297,7 +297,11 @@ fn decl(key: &str, value: &str) -> Vec<(String, String)> {
("transform-origin", "top left" | "0 0") => kw("origin-top-left"),
// `font-(--x)` would read as a weight; the hint makes an un-namespaced variable a family.
("font-family", v) if var_name(v).is_some() => one(util("font", "font", v, "family-name:")),
("font-family", v) => one(format!("font-{}", arb(&format!("'{}'", v.trim_matches('"'))))),
("font-family", v) => {
// Quotes, backslashes and brackets would end the JSX attribute or the arbitrary value.
let family: String = v.chars().filter(|c| !matches!(c, '"' | '\\' | '[' | ']' | '\'')).collect();
one(format!("font-{}", arb(&format!("'{family}'"))))
}
("font-size", v) => one(util("text", "text", v, "length:")),
("font-weight", v) => one(font_weight(v)),
("font-style", "italic") => kw("italic"),
+13
View File
@@ -340,3 +340,16 @@ fn per_corner_radii_with_a_variable_first_keep_every_corner() {
classes("border-radius: var(--radius-a, 4px) 8px 8px 8px;\nborder-radius: var(--radius-card, 4px);\n").into_iter().map(|(_, c)| c).collect();
assert_eq!(got, ["rounded-[var(--radius-a,_4px)_8px_8px_8px]", "rounded-card"]);
}
#[test]
fn font_family_with_quote_and_bracket_stays_a_valid_class_token() {
let mut s = Session::new();
let t = idv(&ex(&mut s, "text.create", json!({"text": "hi", "x": 0, "y": 0, "props": {"fontFamily": "A\"B]C"}})));
let out = code(&mut s, t, json!({}));
let rest = out.split("className=\"").nth(1).unwrap_or_default();
let (attr, after) = rest.split_once('"').unwrap_or_default();
let family = attr.split(' ').find(|c| c.starts_with("font-[")).unwrap_or_default();
let inner = family.strip_prefix("font-[").and_then(|f| f.strip_suffix(']')).unwrap_or("\\");
assert!(after.starts_with('>') && !attr.contains('\\'), "{out}");
assert!(!inner.is_empty() && !inner.contains(['\\', '"', ']']), "{out}");
}
+2 -1
View File
@@ -298,8 +298,9 @@ mod tests {
};
assert_eq!(from_network(&many_lines, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer"));
// Past the cap the builders hold their sizes to (`figcraft_geom::MAX_COORD`, inclusive).
let huge = Network {
vertices: vec![vertex(0.0, 0.0), vertex(1e9, 0.0)],
vertices: vec![vertex(0.0, 0.0), vertex(2e9, 0.0)],
segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))],
..Network::default()
};
+27 -15
View File
@@ -18,6 +18,18 @@ pub use transform::*;
/// Corner radii in clockwise order from the top-left: `[tl, tr, br, bl]`.
pub type Radii = [f64; 4];
/// The largest magnitude a shape builder takes for a size, radius or angle. kurbo subdivides an
/// arc in proportion to the sixth root of its radius, so an uncapped radius means an unbounded
/// number of segments: 1e36 is about a million cubics and 1e60 never returns. Sizes come from
/// files and commands, so they are capped here (the sanity bound `measure::finite_path` uses).
pub const MAX_COORD: f64 = 1e9;
/// `v` as a usable size, radius or angle: NaN and infinities are 0 and the magnitude is capped at
/// [`MAX_COORD`].
fn bounded(v: f64) -> f64 {
if v.is_finite() { v.clamp(-MAX_COORD, MAX_COORD) } else { 0.0 }
}
/// The tolerance an arc of radius `r` is turned into cubics with: a hundredth of a unit, or a
/// millionth of the radius once that is more. kurbo's cubic count grows with radius ÷ tolerance,
/// so a fixed tolerance let a radius of 1e300 ask for more cubics than memory holds.
@@ -84,15 +96,15 @@ fn finite(v: f64) -> f64 {
/// Radii are clamped so adjacent corners never overlap; when smoothing doesn't fit, it is reduced
/// first, then the radius.
pub fn rounded_rect(width: f64, height: f64, radii: Radii, smoothing: f64) -> BezPath {
let w = finite(width).max(0.0);
let h = finite(height).max(0.0);
let w = bounded(width).max(0.0);
let h = bounded(height).max(0.0);
let mut path = BezPath::new();
if w <= 0.0 && h <= 0.0 {
return path;
}
let s = finite(smoothing).clamp(0.0, 1.0);
let s = bounded(smoothing).clamp(0.0, 1.0);
let max_r = w.min(h) / 2.0;
let radii = radii.map(|r| finite(r).clamp(0.0, max_r));
let radii = radii.map(|r| bounded(r).clamp(0.0, max_r));
if radii.iter().all(|r| *r <= 0.0) {
path.move_to((0.0, 0.0));
path.line_to((w, 0.0));
@@ -196,7 +208,7 @@ fn append_corner(path: &mut BezPath, corner: Point, din: Vec2, dout: Vec2, prof:
/// first vertex at the top centre (Figma's polygon).
pub fn polygon_points(width: f64, height: f64, count: u32) -> Vec<Point> {
let n = count.clamp(3, 120);
let (rx, ry) = (finite(width) / 2.0, finite(height) / 2.0);
let (rx, ry) = (bounded(width) / 2.0, bounded(height) / 2.0);
(0..n)
.map(|i| {
let t = -FRAC_PI_2 + TAU * f64::from(i) / f64::from(n);
@@ -208,8 +220,8 @@ pub fn polygon_points(width: f64, height: f64, count: u32) -> Vec<Point> {
/// The vertices of a star with `count` points and `inner_ratio` (0..1) inner radius.
pub fn star_points(width: f64, height: f64, count: u32, inner_ratio: f64) -> Vec<Point> {
let n = count.clamp(3, 120);
let k = finite(inner_ratio).clamp(0.001, 1.0);
let (rx, ry) = (finite(width) / 2.0, finite(height) / 2.0);
let k = bounded(inner_ratio).clamp(0.001, 1.0);
let (rx, ry) = (bounded(width) / 2.0, bounded(height) / 2.0);
(0..n * 2)
.map(|i| {
let t = -FRAC_PI_2 + PI * f64::from(i) / f64::from(n);
@@ -226,7 +238,7 @@ pub fn rounded_polygon(points: &[Point], radius: f64) -> BezPath {
if n < 2 {
return path;
}
let radius = finite(radius).max(0.0);
let radius = bounded(radius).max(0.0);
if radius <= 0.0 || n < 3 {
for (i, p) in points.iter().enumerate() {
if i == 0 {
@@ -292,8 +304,8 @@ pub fn rounded_polygon(points: &[Point], radius: f64) -> BezPath {
/// [`rounded_rect`], when a smoothed corner doesn't fit in half of its shorter edge, smoothing is
/// reduced first, then the radius. Without smoothing this is [`rounded_polygon`].
pub fn smoothed_polygon(points: &[Point], radius: f64, smoothing: f64) -> BezPath {
let s = finite(smoothing).clamp(0.0, 1.0);
let radius = finite(radius).max(0.0);
let s = bounded(smoothing).clamp(0.0, 1.0);
let radius = bounded(radius).max(0.0);
let n = points.len();
if s <= 0.0 || radius <= 0.0 || n < 3 {
return rounded_polygon(points, radius);
@@ -444,17 +456,17 @@ fn line_or_move(path: &mut BezPath, p: Point) {
/// Whether an ellipse of this `sweep` (radians) goes all the way round: a whole ellipse or ring,
/// without the sharp corners where an arc's ends meet its sides.
pub fn full_sweep(sweep: f64) -> bool {
finite(sweep).abs() >= TAU - 1e-9
bounded(sweep).abs() >= TAU - 1e-9
}
/// An ellipse in the `width`×`height` box; with arc data, a pie/arc/ring: `start` and `sweep` in
/// radians (clockwise from 3 o'clock, y-down) and `inner` radius ratio 0..1.
pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> BezPath {
let (rx, ry) = (finite(width).max(0.0) / 2.0, finite(height).max(0.0) / 2.0);
let (rx, ry) = (bounded(width).max(0.0) / 2.0, bounded(height).max(0.0) / 2.0);
let c = Point::new(rx, ry);
let sweep = finite(sweep).clamp(-TAU, TAU);
let start = finite(start);
let inner = finite(inner).clamp(0.0, 1.0);
let sweep = bounded(sweep).clamp(-TAU, TAU);
let start = bounded(start);
let inner = bounded(inner).clamp(0.0, 1.0);
let full = full_sweep(sweep);
let tol = arc_tolerance(rx.max(ry));
let mut path = BezPath::new();
+10 -3
View File
@@ -8,9 +8,16 @@ pub const FLATTEN_TOL: f64 = 0.05;
/// The most flattened points a path is measured with; longer paths are refused.
pub const MAX_SAMPLES: usize = 40_000;
/// Every coordinate is finite and of a sane magnitude.
/// How far out a measured coordinate may lie. The shape builders hold their sizes, radii and
/// angles to [`crate::MAX_COORD`], so a path built at the cap has its points there and the
/// control points of its curves a little past them (the cubics approximating an arc reach past
/// the arc's box by up to about a tenth of the radius). Half as much again keeps every built path
/// measurable and still refuses the absurd.
pub const MAX_MEASURED_COORD: f64 = crate::MAX_COORD * 1.5;
/// Every coordinate is finite and of a sane magnitude (within [`MAX_MEASURED_COORD`]).
pub fn finite_path(path: &BezPath) -> bool {
let ok = |p: &Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9;
let ok = |p: &Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= MAX_MEASURED_COORD && p.y.abs() <= MAX_MEASURED_COORD;
path.elements().iter().all(|el| match el {
PathEl::MoveTo(p) | PathEl::LineTo(p) => ok(p),
PathEl::QuadTo(a, b) => ok(a) && ok(b),
@@ -66,7 +73,7 @@ impl Polyline {
/// The nearest point to `p`: (t by length, distance).
pub fn nearest(&self, p: Point) -> Option<(f64, f64)> {
let len = self.length();
if len <= 1e-9 {
if len <= 1e-9 || !(p.x.is_finite() && p.y.is_finite()) {
return None;
}
let mut best: Option<(f64, f64)> = None;
+89
View File
@@ -101,6 +101,95 @@ fn smoothed_corner_draws_the_polygon_corner() {
}
}
/// The measurement's sanity bound is the builders' cap, inclusive: a path built at `MAX_COORD`
/// has coordinates exactly there and still measures; one past it doesn't.
#[test]
fn paths_at_the_cap_still_measure() {
use crate::measure::{finite_path, polylines};
let at_cap = rounded_rect(MAX_COORD, MAX_COORD, [0.0; 4], 0.0);
let b = at_cap.bounding_box();
assert_eq!((b.x1, b.y1), (MAX_COORD, MAX_COORD), "corners exactly at the cap");
assert!(finite_path(&at_cap));
assert!(polylines(&at_cap).is_some(), "a rectangle at the cap measures");
let mut line = BezPath::new();
line.move_to(Point::new(-MAX_COORD, 0.0));
line.line_to(Point::new(MAX_COORD, -MAX_COORD));
assert!(polylines(&line).is_some(), "a line between the cap's corners measures");
// A curve at the cap: the cubics approximating the arc have control points past the cap, and
// the path still measures.
let arc = ellipse(MAX_COORD, 1.0, -0.15, 0.2, 0.0);
let reach = arc
.elements()
.iter()
.flat_map(|el| match el {
kurbo::PathEl::CurveTo(a, b, _) => vec![a.x, b.x],
_ => vec![],
})
.fold(0.0, f64::max);
assert!(reach > MAX_COORD, "a control point lies past the cap: {reach}");
assert!(finite_path(&arc));
assert!(polylines(&arc).is_some(), "an arc at the cap measures");
let mut past = BezPath::new();
past.move_to(Point::new(0.0, 0.0));
past.line_to(Point::new(2.0 * MAX_COORD, 0.0));
assert!(!finite_path(&past));
assert!(polylines(&past).is_none(), "a coordinate well past the cap is refused");
}
#[test]
fn absurd_sizes_build_bounded_paths() {
// kurbo subdivides an arc in proportion to the sixth root of its radius, so without a cap the
// segment count grows without bound with the input: a 1e36 ellipse was about a million
// elements and 1e60 never returned. Sizes are untrusted (files, commands), so they are capped.
// At the cap itself (1e9) an arc is still a few dozen segments.
for mag in [MAX_COORD, 1e36, 1e300, f64::MAX] {
let paths = [
("ellipse", ellipse(mag, mag, 0.0, PI, 0.0)),
("ring", ellipse(mag, mag, 0.0, TAU, 0.5)),
("rounded_rect", rounded_rect(mag, mag, [mag; 4], 0.0)),
("smoothed rect", rounded_rect(mag, mag, [mag; 4], 0.6)),
("smoothed_polygon", smoothed_polygon(&polygon_points(mag, mag, 5), mag, 0.6)),
];
for (name, p) in paths {
assert!(p.elements().len() < 1_000, "{name} at {mag:e}: {} elements", p.elements().len());
let b = p.bounding_box();
assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= 1.01 * MAX_COORD), "{name} at {mag:e}: {b:?}");
}
// Points are the caller's to keep sane; a huge radius is still capped.
let p = smoothed_polygon(&[Point::new(0.0, 0.0), Point::new(mag, 0.0), Point::new(mag, mag)], mag, 0.6);
assert!(p.elements().len() < 1_000, "huge points at {mag:e}: {} elements", p.elements().len());
}
// NaN, infinities and negative sizes build nothing or a bounded path with a finite box.
for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1e36, -1.0] {
let paths = [
("ellipse", ellipse(v, v, v, v, v)),
("rounded_rect", rounded_rect(v, v, [v; 4], v)),
("smoothed_polygon", smoothed_polygon(&polygon_points(100.0, 100.0, 5), v, v)),
];
for (name, p) in paths {
assert!(p.elements().len() < 1_000, "{name} at {v}: {} elements", p.elements().len());
let b = p.bounding_box();
assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|x| x.is_finite()), "{name} at {v}: {b:?}");
}
}
// Sizes a document can hold are untouched.
let p = rounded_rect(1e6, 1e6, [1e5; 4], 0.6);
let b = p.bounding_box();
assert!((b.width() - 1e6).abs() < 1e-3 && (b.height() - 1e6).abs() < 1e-3, "{b:?}");
}
#[test]
fn nearest_refuses_a_non_finite_point() {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((10.0, 0.0));
let lines = measure::polylines(&p).unwrap();
assert_eq!(lines[0].nearest(Point::new(f64::NAN, 0.0)), None);
assert_eq!(lines[0].nearest(Point::new(0.0, f64::INFINITY)), None);
let (t, d) = lines[0].nearest(Point::new(5.0, 3.0)).unwrap();
assert!((t - 0.5).abs() < 1e-9 && (d - 3.0).abs() < 1e-9);
}
#[test]
fn polygon_first_vertex_top_center() {
let pts = polygon_points(100.0, 100.0, 3);
+30 -7
View File
@@ -80,6 +80,13 @@ impl FigApp {
if self.language.as_ref() != Some(&self.session.prefs.language) {
i18n::set(&self.session.prefs.language, self.services.system_language().as_deref());
self.language = Some(self.session.prefs.language.clone());
self.ui.mirrored = i18n::right_to_left();
}
// egui's own chrome follows: scroll bars on the left, submenus opening to the left,
// checkboxes with their label on the left.
if ctx.global_style().right_to_left != self.ui.mirrored {
let mirrored = self.ui.mirrored;
ctx.all_styles_mut(|s| s.right_to_left = mirrored);
}
// Screen-reader and bidi language of the UI (fork E17).
ctx.set_language(i18n::current().map(|l| l.code.to_string()));
@@ -289,8 +296,12 @@ impl FigApp {
self.ui.narrow = narrow;
// A narrow window shows the right panel when its button asks for it, a board's too.
let right_open = (self.right_panel_shown(hidden) && !narrow && !right_minimized) || mobile == Some(MobilePanel::Right);
// A right-to-left UI language mirrors the window: the "left" rail and panel go on the
// right, the "right" (Design) panel on the left. Their ids and widths stay as they are.
let mirrored = self.ui.mirrored;
let side = |start: bool, id: &'static str| if start != mirrored { egui::Panel::left(id) } else { egui::Panel::right(id) };
if !hidden && !minimized && (!narrow || mobile == Some(MobilePanel::Left)) {
egui::Panel::left("left_rail")
side(true, "left_rail")
.exact_size(layers::RAIL_WIDTH)
.resizable(false)
.frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border)))
@@ -309,11 +320,18 @@ impl FigApp {
};
let rows = ui.available_rect_before_wrap().y_range();
let mut edges: Vec<(&str, f32, f32, f32)> = Vec::new();
// The x of the edge facing the canvas of a panel `w` wide on the window's start (left)
// side, or its end side, and which way a drag to the right sizes it.
let edge = |ui: &egui::Ui, start: bool, w: f32| {
let room = ui.available_rect_before_wrap();
if start != mirrored { (room.min.x + w, 1.0) } else { (room.max.x - w, -1.0) }
};
if left_open {
if !narrow {
edges.push(("left", ui.available_rect_before_wrap().min.x + left_w, left_w, 1.0));
let (x, grows) = edge(ui, true, left_w);
edges.push(("left", x, left_w, grows));
}
egui::Panel::left("left_panel")
side(true, "left_panel")
.resizable(false)
.exact_size(left_w)
.frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border)))
@@ -330,9 +348,10 @@ impl FigApp {
self.ui.right_panel_rect = None;
if right_open {
if !narrow {
edges.push(("right", ui.available_rect_before_wrap().max.x - right_w, right_w, -1.0));
let (x, grows) = edge(ui, false, right_w);
edges.push(("right", x, right_w, grows));
}
egui::Panel::right("right_panel")
side(false, "right_panel")
.resizable(false)
.exact_size(right_w)
// No stroke: a frame's stroke is part of its margin, which would take 1 pt off
@@ -361,10 +380,14 @@ impl FigApp {
}
});
if narrow {
let x = if mobile == Some(MobilePanel::Left) { ctx.content_rect().max.x - MOBILE_STRIP + 4.0 } else { ctx.content_rect().min.x + 4.0 };
// In the strip beside the open panel; with none open, on the Design panel's side.
let at_right = (mobile == Some(MobilePanel::Left)) != mirrored;
let x = if at_right { ctx.content_rect().max.x - MOBILE_STRIP + 4.0 } else { ctx.content_rect().min.x + 4.0 };
self.mobile_buttons(&ctx, egui::pos2(x, rows.min + 8.0));
} else if minimized {
self.expand_button(&ctx, egui::pos2(ctx.content_rect().min.x + 8.0, rows.min + 8.0));
// Where the navigation bar was: on the right in a mirrored window.
let x = if mirrored { ctx.content_rect().max.x - 40.0 } else { ctx.content_rect().min.x + 8.0 };
self.expand_button(&ctx, egui::pos2(x, rows.min + 8.0));
}
// The panels' edges, over the canvas and the panels (drawn after them: they take the press).
for (name, x, shown, grows) in edges {
+2 -1
View File
@@ -55,7 +55,8 @@ impl FigApp {
/// Show command `id`'s error.
pub(crate) fn failed(&mut self, id: &str, e: &figcraft_engine::EngineError) {
let msg = e.to_string();
log::warn!("{id}: {msg}");
// A plugin's failure text may carry CR or terminal controls: log it escaped.
log::warn!("{id}: {}", msg.escape_debug());
self.show_error(msg);
}
+12
View File
@@ -60,6 +60,18 @@ pub fn current() -> Option<&'static Lang> {
LANGS.get(CURRENT.load(Ordering::Relaxed))
}
/// Whether the language the UI shows is written right to left, so the window mirrors.
pub fn right_to_left() -> bool {
current().is_some_and(|l| writes_right_to_left(l.code))
}
/// Whether language `code` ("ar", "he-IL"…) is written right to left: Arabic, Hebrew, Persian,
/// Urdu, Pashto, Sindhi, Kurdish (Sorani), Uyghur, Dhivehi and Yiddish.
fn writes_right_to_left(code: &str) -> bool {
let base = code.split(['-', '_']).next().unwrap_or("");
["ar", "he", "fa", "ur", "ps", "sd", "ckb", "ug", "dv", "yi"].iter().any(|rtl| rtl.eq_ignore_ascii_case(base))
}
/// Show the language of `pref` (a language code, or "system" for `system`, the OS or browser
/// languages, most preferred first and comma-separated). Unknown languages show English.
pub fn set(pref: &str, system: Option<&str>) {
+14 -1
View File
@@ -4,7 +4,7 @@
use std::collections::BTreeSet;
use super::{Forms, LANGS, Lang, Plural, Text, chain, counted, fill, find, lookup, plural, plural_one, resolve};
use super::{Forms, LANGS, Lang, Plural, Text, chain, counted, fill, find, lookup, plural, plural_one, resolve, writes_right_to_left};
/// 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.
@@ -550,6 +550,19 @@ fn arabic_tags_and_count_neutral_forms() {
}
}
#[test]
fn right_to_left_languages_mirror_the_window() {
for code in ["ar", "ar-SA", "AR", "he", "he-IL", "fa", "ur", "ckb", "yi"] {
assert!(writes_right_to_left(code), "{code}");
}
for code in ["en", "", "es-419", "zh-Hans", "tr", "ru", "arn", "hey"] {
assert!(!writes_right_to_left(code), "{code}");
}
// Every catalog that mirrors is one of them, and the Arabic one does.
assert!(LANGS.iter().any(|l| l.code == "ar" && writes_right_to_left(l.code)));
assert!(!writes_right_to_left(LANGS[0].code), "English reads left to right");
}
#[test]
fn the_built_in_arabic_face_draws_the_arabic_catalog() {
if !figcraft_text::CRAFT_FONTS.iter().any(|f| f.family == "Noto Sans Arabic" && f.style == "Regular") {
@@ -0,0 +1,131 @@
//! Component rows in the Layers panel (#1903), drawn headless in both themes: a component's name
//! reads at WCAG AA (4.5:1) on the panel and on its selected row, and the rows inside a selected
//! component take a tone of its row, not the plain selection's green.
use egui::{Color32, Pos2, Shape};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
use crate::theme::Tokens;
fn id(v: serde_json::Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn color(x: Color32) -> figcraft_color::Color {
figcraft_color::Color::new(f32::from(x.r()) / 255.0, f32::from(x.g()) / 255.0, f32::from(x.b()) / 255.0, 1.0)
}
/// The Layers panel with the component "Button" (holding the rectangle "Label") selected and
/// expanded, in the `dark` or light theme: the frame's shapes and the theme's tokens.
fn drawn(dark: bool) -> (Vec<Shape>, Tokens) {
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 40, "props": {"name": "Button"}})).unwrap());
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20, "parent": f, "props": {"name": "Label"}})).unwrap();
let c = id(s.execute("component.create", &json!({"ids": [f]})).unwrap());
s.execute(if dark { "view.themeDark" } else { "view.themeLight" }, &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [c]})).unwrap();
let mut u = Ui::new(s);
u.app.ui.expanded.insert(c);
u.frame(vec![]);
let shapes = crate::live_tests::flat(&u.frame_output(vec![]).shapes);
(shapes, Tokens::get(&u.ctx))
}
/// The colour the name `s` is painted in.
fn name_colour(shapes: &[Shape], s: &str) -> Color32 {
shapes
.iter()
.find_map(|sh| match sh {
Shape::Text(t) if t.galley.text() == s => Some(t.fallback_color),
_ => None,
})
.unwrap_or_else(|| panic!("no name {s:?} painted"))
}
/// Where the name `s` is painted (its top left).
fn name_pos(shapes: &[Shape], s: &str) -> Pos2 {
shapes
.iter()
.find_map(|sh| match sh {
Shape::Text(t) if t.galley.text() == s => Some(t.pos),
_ => None,
})
.unwrap_or_else(|| panic!("no name {s:?} painted"))
}
/// Whether a rectangle filled in `c` is painted under `p`.
fn rect_under(shapes: &[Shape], c: Color32, p: Pos2) -> bool {
shapes.iter().any(|s| matches!(s, Shape::Rect(r) if r.fill == c && r.rect.contains(p)))
}
#[test]
fn component_names_read_at_aa_on_the_panel_and_on_their_selected_row() {
for dark in [false, true] {
let (shapes, t) = drawn(dark);
assert_eq!(t.dark, dark);
let col = name_colour(&shapes, "Button");
assert_eq!(col, t.component, "the theme's component tint (dark {dark})");
for (what, bg) in [("the panel", t.panel), ("the selected component row", t.component_row), ("a hovered row", t.hover)] {
let ratio = color(col).contrast(color(bg));
assert!(ratio >= 4.5, "{ratio:.2}:1 on {what} (dark {dark})");
}
// The layer inside it is a plain layer: the theme's text colour.
assert_eq!(name_colour(&shapes, "Label"), t.text);
}
}
#[test]
fn rows_inside_a_selected_component_take_a_tone_of_its_row() {
for dark in [false, true] {
let (shapes, t) = drawn(dark);
let button = name_pos(&shapes, "Button");
let label = name_pos(&shapes, "Label");
// Checked under each name, by row: the pages list paints the current page in the plain
// selection's `selected_row` too.
assert!(rect_under(&shapes, t.component_row, button), "the component's own row (dark {dark})");
assert!(rect_under(&shapes, t.component_row_child, label), "the row inside it (dark {dark})");
assert!(!rect_under(&shapes, t.selected_row, button), "not the plain selection's row (dark {dark})");
assert!(!rect_under(&shapes, t.selected_row_child, label), "not the plain selection's child tone (dark {dark})");
}
}
/// The Layers panel with the component "Button" hidden, and selected or not.
fn drawn_hidden(dark: bool, selected: bool) -> (Vec<Shape>, Tokens) {
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 40, "props": {"name": "Button"}})).unwrap());
let c = id(s.execute("component.create", &json!({"ids": [f]})).unwrap());
s.execute(if dark { "view.themeDark" } else { "view.themeLight" }, &json!({})).unwrap();
s.execute("node.set", &json!({"id": c, "props": {"visible": false}})).unwrap();
s.execute("select.set", &json!({"ids": if selected { vec![c] } else { vec![] }})).unwrap();
let mut u = Ui::new(s);
u.frame(vec![]);
let shapes = crate::live_tests::flat(&u.frame_output(vec![]).shapes);
(shapes, Tokens::get(&u.ctx))
}
/// `fg` (premultiplied, as egui paints it) composited over the opaque `bg`.
fn over(fg: Color32, bg: Color32) -> Color32 {
let k = 1.0 - f32::from(fg.a()) / 255.0;
let mix = |f: u8, b: u8| (f32::from(f) + f32::from(b) * k).round().min(255.0) as u8;
Color32::from_rgb(mix(fg.r(), bg.r()), mix(fg.g(), bg.g()), mix(fg.b(), bg.b()))
}
#[test]
fn a_hidden_component_name_keeps_the_dim_text_floor_of_3_to_1() {
for dark in [false, true] {
for (selected, rows) in [(false, 0), (true, 1)] {
let (shapes, t) = drawn_hidden(dark, selected);
assert_eq!(t.dark, dark);
let col = name_colour(&shapes, "Button");
let all = [("the panel", t.panel), ("a hovered row", t.hover), ("the selected component row", t.component_row)];
// Unselected, the row shows the panel (or the hover); selected, its own tinted row.
for (what, bg) in &all[..2 + rows] {
let ratio = color(over(col, *bg)).contrast(color(*bg));
assert!(ratio >= 3.0, "hidden component name {col:?} is {ratio:.2}:1 on {what} (dark {dark}, selected {selected})");
}
}
}
}
+8
View File
@@ -138,6 +138,8 @@ mod inspect_fields_tests;
#[cfg(test)]
mod layers_behaviour_tests;
#[cfg(test)]
mod layers_component_rows_tests;
#[cfg(test)]
mod layers_drag_tests;
#[cfg(test)]
mod layers_keyboard_tests;
@@ -178,6 +180,8 @@ mod menu_contrast_tests;
#[cfg(test)]
mod menu_input_tests;
#[cfg(test)]
mod mirror_tests;
#[cfg(test)]
mod missing_fonts_tests;
#[cfg(test)]
mod online_tests;
@@ -559,6 +563,9 @@ pub struct UiState {
pub mobile_panel: Option<MobilePanel>,
/// The window was narrow (one side panel at a time) at the last frame.
pub narrow: bool,
/// The window is mirrored for a right-to-left UI language: the icon rail and the layers panel
/// on the right, the Design panel on the left, popovers beside it on its right.
pub mirrored: bool,
pub comments: comments::CommentUi,
pub versions: versions::VersionUi,
/// The branch review dialog, while open.
@@ -709,6 +716,7 @@ impl Default for UiState {
left_collapsed: false,
mobile_panel: None,
narrow: false,
mirrored: false,
comments: comments::CommentUi::default(),
versions: versions::VersionUi::default(),
branch_review: None,
+106
View File
@@ -0,0 +1,106 @@
//! A right-to-left UI language mirrors the window, driven through egui headless: the icon rail
//! and the layers panel go on the right, the Design panel on the left, their edges still size them
//! (now dragged the other way) and the widths are the same preferences; egui's own chrome (scroll
//! bars, submenus) mirrors with it, and an English window is as it was.
use egui::pos2;
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
use crate::{PANEL_DEFAULT, layers};
const WINDOW_W: f32 = 1440.0;
fn ui(mirrored: bool) -> Ui {
let mut s = Session::new();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap();
let mut u = Ui::new(s);
u.app.ui.mirrored = mirrored;
u.frame(vec![]);
u.frame(vec![]);
u
}
/// The canvas's left and right x.
fn canvas(u: &Ui) -> (f32, f32) {
(u.app.canvas.rect.min.x, u.app.canvas.rect.max.x)
}
/// Press on `from`, move to `to` in steps and let go.
fn drag(u: &mut Ui, from: egui::Pos2, to: egui::Pos2) {
u.drag_to(from, to);
u.button(to, false);
u.frame(vec![]);
}
#[test]
fn the_design_panel_is_on_the_left_and_the_layers_on_the_right() {
let u = ui(true);
let (left, right) = canvas(&u);
assert_eq!(left, PANEL_DEFAULT, "the Design panel from the window's left edge");
assert_eq!(right, WINDOW_W - layers::RAIL_WIDTH - PANEL_DEFAULT, "the layers panel and the rail on the right");
// egui keeps a point between a side panel and the canvas, on the canvas side.
let design = u.app.ui.right_panel_rect.expect("the Design panel drew");
assert_eq!((design.min.x, design.max.x), (0.0, PANEL_DEFAULT - 1.0));
assert!(u.ctx.global_style().right_to_left, "egui's chrome mirrors too");
}
#[test]
fn an_english_window_is_as_it_was() {
let u = ui(false);
let (left, right) = canvas(&u);
assert_eq!((left, right), (layers::RAIL_WIDTH + PANEL_DEFAULT, WINDOW_W - PANEL_DEFAULT));
let design = u.app.ui.right_panel_rect.expect("the Design panel drew");
assert_eq!((design.min.x, design.max.x), (WINDOW_W - PANEL_DEFAULT + 1.0, WINDOW_W));
assert!(!u.ctx.global_style().right_to_left);
}
#[test]
fn mirrored_edges_size_their_panels_dragged_the_other_way() {
let mut u = ui(true);
let (left, right) = canvas(&u);
// The layers panel's edge (the canvas's right), 60 to the left: the layers panel 60 wider.
drag(&mut u, pos2(right, 400.0), pos2(right - 60.0, 400.0));
assert_eq!((u.app.ui.left_width, u.app.ui.right_width), (PANEL_DEFAULT + 60.0, PANEL_DEFAULT));
assert_eq!(canvas(&u), (left, right - 60.0));
// The Design panel's edge (the canvas's left), 40 to the right: the Design panel 40 wider.
drag(&mut u, pos2(left, 400.0), pos2(left + 40.0, 400.0));
assert_eq!((u.app.ui.left_width, u.app.ui.right_width), (PANEL_DEFAULT + 60.0, PANEL_DEFAULT + 40.0));
assert_eq!(canvas(&u), (left + 40.0, right - 60.0));
// The same preferences as an English window's.
assert_eq!(u.app.session.prefs.panel_widths, (PANEL_DEFAULT + 60.0, PANEL_DEFAULT + 40.0));
}
#[test]
fn closing_the_layers_panel_gives_the_canvas_the_right_side() {
let mut u = ui(true);
u.app.ui.close_left_panel();
u.frame(vec![]);
assert_eq!(canvas(&u), (PANEL_DEFAULT, WINDOW_W - layers::RAIL_WIDTH), "only the rail is left on the right");
}
#[test]
fn switching_back_unmirrors_the_window_and_egui() {
let mut u = ui(true);
u.app.ui.mirrored = false;
u.frame(vec![]);
u.frame(vec![]);
assert_eq!(canvas(&u), (layers::RAIL_WIDTH + PANEL_DEFAULT, WINDOW_W - PANEL_DEFAULT));
assert!(!u.ctx.global_style().right_to_left);
}
#[test]
fn a_minimized_window_has_its_expand_button_where_the_rail_was() {
use egui::accesskit::Role;
for mirrored in [true, false] {
let mut u = ui(mirrored);
u.app.run("select.set", json!({"ids": []}));
u.app.run("view.minimize", json!({"on": true}));
u.frame(vec![]);
let expand = crate::panel_width_tests::control(&mut u, Role::Button, "Expand the interface").expect("the Expand button");
// 32 wide, 8 from the window's edge.
let x = if mirrored { WINDOW_W - 24.0 } else { 24.0 };
assert_eq!(expand.x, x, "mirrored: {mirrored}");
}
}
+1 -1
View File
@@ -211,7 +211,7 @@ fn minimize_ui_hides_the_panels_and_keeps_their_widths() {
}
/// The middle of the accessible control with `role` and `label`, if one shows.
fn control(u: &mut Ui, role: egui::accesskit::Role, label: &str) -> Option<Pos2> {
pub(crate) fn control(u: &mut Ui, role: egui::accesskit::Role, label: &str) -> Option<Pos2> {
u.ctx.enable_accesskit();
u.frame(vec![]);
let out = u.frame_output(vec![]);
+25 -14
View File
@@ -10,7 +10,7 @@ use serde_json::json;
use crate::a11y::{self, Region, RowInfo, Tip};
use crate::i18n::{tr, trf, trn};
use crate::icons::{self, Icon};
use crate::theme::{ACCENT, COMPONENT, Tokens, regular, semibold};
use crate::theme::{ACCENT, Tokens, regular, semibold};
use crate::{FigApp, LeftTab};
pub const ROW: f32 = 32.0;
@@ -799,14 +799,22 @@ fn drag_autoscroll(ui: &mut Ui, dragging: bool) {
}
}
/// A row's icon or name colour: the component tint (faded when hidden), else `normal` or `faded`.
fn row_color(component: bool, shown: bool, normal: Color32, faded: Color32) -> Color32 {
if component {
COMPONENT.gamma_multiply(if shown { 1.0 } else { 0.5 })
} else if shown {
normal
} else {
/// Whether a layer is a component, a variant set or an instance: its rows take the component tint.
fn is_component(kind: &NodeKind) -> bool {
matches!(kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. })
}
/// A row's icon or name colour: the theme's component tint when shown (it reads at AA on the
/// panel and on the selected row, where the canvas purple doesn't, #1903), else `normal` or, when
/// hidden, `faded` (halving the tint's alpha would drop it to about 2.2:1, under the 3:1 floor
/// for dim text).
fn row_color(t: &Tokens, component: bool, shown: bool, normal: Color32, faded: Color32) -> Color32 {
if !shown {
faded
} else if component {
t.component
} else {
normal
}
}
@@ -1042,13 +1050,14 @@ fn hierarchy_bg(roots: &[Option<NodeId>], i: usize, mut rr: Rect, gap: f32) -> O
}
/// A row's background: selected, inside a selected layer, or outlined when hovered.
/// `state` is [selected, in_selected, component].
/// `state` is [selected, in_selected, component]: a selected component's row takes the component
/// tint, not the plain selection's green; the rows inside it are tinted by the caller (#1903).
fn paint_row_bg(ui: &Ui, t: &Tokens, rr: Rect, state: [bool; 3], hovered: bool) {
let [selected, in_selected, is_comp] = state;
if selected {
ui.painter().rect_filled(rr, CornerRadius::same(5), if is_comp { t.component_row } else { t.selected_row });
} else if !in_selected && hovered {
ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(1.0, if is_comp { COMPONENT } else { ACCENT }), egui::StrokeKind::Inside);
ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(1.0, if is_comp { t.component } else { ACCENT }), egui::StrokeKind::Inside);
}
}
@@ -1183,12 +1192,14 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
locked: n.locked,
};
a11y::tree_item(&resp, if n.name.is_empty() { tr(n.kind.label()) } else { &n.name }, &info);
let is_comp = matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. });
let is_comp = is_component(&n.kind);
let rr = r.shrink2(vec2(8.0, 0.0));
let bg = hierarchy_bg(&roots, i - roots_start, rr, pitch - ROW);
let in_selected = !selected && bg.is_some();
if let Some((rect, corners)) = bg {
ui.painter().rect_filled(rect, corners, t.selected_row_child);
// Inside a selected component, variant set or instance the rows take its tint.
let in_comp = roots.get(i - roots_start).copied().flatten().and_then(|r| doc.node(r)).is_some_and(|r| is_component(&r.kind));
ui.painter().rect_filled(rect, corners, if in_comp { t.component_row_child } else { t.selected_row_child });
}
a11y::ring_around(ui, rr, &resp);
// Over the row, its eye and padlock included (they sit on top of it).
@@ -1215,7 +1226,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
}
let icon = node_icon(&doc, row.id);
let shown = n.visible && !row.dimmed;
let icon_col = row_color(is_comp || icon == Icon::Slot, shown, t.icon, t.text_tertiary);
let icon_col = row_color(&t, is_comp || icon == Icon::Slot, shown, t.icon, t.text_tertiary);
icons::paint(ui.painter(), Rect::from_center_size(pos2(x0 + 22.0, r.center().y), vec2(14.0, 14.0)), icon, icon_col);
let name_x = x0 + 36.0;
if app.ui.rename.is_some_and(|rn| rn.id == row.id) {
@@ -1223,7 +1234,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
rename_done = Some((row.id, new));
}
} else {
let col = row_color(is_comp, shown, t.text, t.text_tertiary);
let col = row_color(&t, is_comp, shown, t.text, t.text_tertiary);
let font = name_font(&doc, page, n);
let name = row_name(n).to_string();
// Room left of the lock and eye buttons (the row is wide enough for the whole
+9 -6
View File
@@ -639,30 +639,33 @@ fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, nod
&& n == id
&& let Some(it) = list.get(k)
{
let panel_left = ui.clip_rect().min.x;
let panel = ui.clip_rect().x_range();
let y = anchor.map(|a| a.min.y).unwrap_or(ui.clip_rect().min.y + 80.0);
editor_popover(app, ui.ctx(), InteractionPopover { doc, page, node: id, index: k, interaction: it, anchor: pos2(panel_left - 8.0, y) });
editor_popover(app, ui.ctx(), InteractionPopover { doc, page, node: id, index: k, interaction: it, panel, y });
} else if open.is_some_and(|(n, _)| n == id) {
open_editor(ui.ctx(), None);
}
}
/// The interaction editor, floating left of the panel (`at` is its top-right corner).
/// The interaction editor, floating beside the panel (`panel`, its x range) on the canvas side,
/// its top at `y`.
struct InteractionPopover<'a> {
doc: &'a Document,
page: NodeId,
node: NodeId,
index: usize,
interaction: &'a Interaction,
anchor: Pos2,
panel: egui::Rangef,
y: f32,
}
fn editor_popover(app: &mut FigApp, ctx: &egui::Context, popover: InteractionPopover<'_>) {
let InteractionPopover { doc, page, node, index: k, interaction: it, anchor: at } = popover;
let InteractionPopover { doc, page, node, index: k, interaction: it, panel, y } = popover;
let t = Tokens::get(ctx);
let width = 280.0;
let screen = ctx.content_rect();
let pos = pos2(at.x - width, at.y.min(screen.max.y - 420.0).max(screen.min.y + 48.0));
let x = crate::popover::beside_panel(panel, width, 8.0, app.ui.mirrored);
let pos = pos2(x, y.min(screen.max.y - 420.0).max(screen.min.y + 48.0));
let mut close = false;
egui::Area::new(Id::unique("proto_editor_area")).fixed_pos(pos).order(egui::Order::Foreground).show(ctx, |ui| {
egui::Frame::new()
+3 -1
View File
@@ -119,7 +119,9 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) {
// take 1 pt off each side of the panel (240 pt by default)).
let edge = ui.max_rect();
app.ui.right_panel_rect = Some(edge);
ui.painter().line_segment([edge.left_top(), edge.left_bottom()], Stroke::new(1.0, t.border));
// On the side facing the canvas (its right in a mirrored window).
let x = if app.ui.mirrored { edge.max.x } else { edge.min.x };
ui.painter().vline(x, edge.y_range(), Stroke::new(1.0, t.border));
let w = ui.available_width();
// Top bar: people (you and the others in a live session), present, share.
let (top, _) = ui.allocate_exact_size(vec2(w, 48.0), Sense::hover());
+10
View File
@@ -51,6 +51,9 @@ pub enum Open {
/// Its right edge on `x` (the right panel's left edge), its top on `y` (the button's top),
/// lifted when that would run its bottom past the screen's bottom.
Anchor(Pos2),
/// [`Open::Anchor`] in a mirrored (right-to-left) window: its left edge on `x` (the Design
/// panel's right edge).
AnchorMirrored(Pos2),
/// Its top-left exactly here.
At(Pos2),
}
@@ -167,6 +170,7 @@ impl Popover {
let p = match open {
Open::At(p) => p,
Open::Anchor(a) => open_pos(a, self.size, screen),
Open::AnchorMirrored(a) => open_pos(a, self.size, screen) + vec2(self.size.x, 0.0),
};
clamp(p, self.size, screen)
}
@@ -277,6 +281,12 @@ fn open_pos(anchor: Pos2, size: Vec2, screen: Rect) -> Pos2 {
pos2(anchor.x - size.x, y)
}
/// The left x of a window `width` wide that floats beside the Design panel (`panel`, its x range)
/// `gap` out over the canvas: left of the panel, or right of it in a mirrored window.
pub fn beside_panel(panel: egui::Rangef, width: f32, gap: f32, mirrored: bool) -> f32 {
if mirrored { panel.max + gap } else { panel.min - gap - width }
}
/// The height a popover's content is offered: the screen's, less [`EDGE`] above and below.
///
/// egui offers an area the size it had last frame, so content that fills what it is offered (a
+21
View File
@@ -84,6 +84,27 @@ fn it_opens_with_its_right_edge_on_the_anchor_and_its_top_aligned() {
assert!((p.rect.min.y - 200.0).abs() < 0.01, "its top on the anchor: {:?}", p.rect);
}
#[test]
fn mirrored_it_opens_with_its_left_edge_on_the_anchor_and_lifts_the_same() {
let ctx = context();
let mut t = 0.0;
let mut p = Popover::new("popover_anchor_mirrored", "Popup");
settle(&ctx, &mut t, &mut p, Open::AnchorMirrored(pos2(300.0, 200.0)));
assert!((p.rect.min.x - 300.0).abs() < 0.01, "its left edge on the anchor: {:?}", p.rect);
assert!((p.rect.min.y - 200.0).abs() < 0.01, "its top on the anchor: {:?}", p.rect);
let mut p = Popover::new("popover_lift_mirrored", "Popup");
settle(&ctx, &mut t, &mut p, Open::AnchorMirrored(pos2(300.0, 860.0)));
assert!((p.rect.max.y - (SCREEN.y - 16.0)).abs() < 0.01, "lifted off the screen's bottom: {:?}", p.rect);
}
#[test]
fn a_window_beside_the_design_panel_floats_over_the_canvas() {
let panel = egui::Rangef::new(1200.0, 1440.0);
assert_eq!(crate::popover::beside_panel(panel, 280.0, 8.0, false), 1200.0 - 8.0 - 280.0, "left of the panel");
let panel = egui::Rangef::new(0.0, 240.0);
assert_eq!(crate::popover::beside_panel(panel, 280.0, 8.0, true), 248.0, "right of a mirrored one");
}
#[test]
fn it_lifts_so_its_bottom_keeps_16_from_the_screens_bottom() {
let ctx = context();
+2 -2
View File
@@ -318,9 +318,9 @@ fn close_tab(app: &mut FigApp, ui: &Ui, i: usize) {
}
/// The Home tab at `x`; returns its width. Without inset window buttons its divider continues the
/// icon rail's edge below.
/// icon rail's edge below (a mirrored window has the rail on the right: no edge to continue).
fn home_tab(app: &mut FigApp, ui: &mut Ui, t: &Tokens, x: f32, rect: Rect) -> f32 {
let w = if app.ui.title_bar == TitleBar::Inset { 44.0 } else { rect.min.x + crate::layers::RAIL_WIDTH - x };
let w = if app.ui.title_bar == TitleBar::Inset || app.ui.mirrored { 44.0 } else { rect.min.x + crate::layers::RAIL_WIDTH - x };
let hr = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT));
let hresp = ui.interact(hr, ui.scope_id().with("home"), Sense::click()).tip(tr("Home"));
let home_active = (app.ui.home || !app.has_files()) && app.ui.present.is_none();
+18 -3
View File
@@ -29,8 +29,15 @@ pub struct Tokens {
pub accent_text: Color32,
pub selected_row: Color32,
pub selected_row_child: Color32,
/// Component, variant and instance names and icons in the panels: at least 4.5:1 (WCAG AA
/// for text) on the panel, on a hovered row and on `component_row`. The canvas keeps
/// [`COMPONENT`] for its outlines and labels.
pub component: Color32,
/// A selected component's row in the Layers panel.
pub component_row: Color32,
/// The rows inside a selected component: a tone of its row (`selected_row_child` is the plain
/// selection's).
pub component_row_child: Color32,
pub danger: Color32,
pub success: Color32,
pub warning: Color32,
@@ -101,7 +108,9 @@ impl Tokens {
text: Color32::from_rgb(0x1E, 0x1E, 0x1E),
// 4.7:1 on fields and hovered rows (#F5F5F5), 4.6:1 on selected rows: WCAG AA.
text_secondary: Color32::from_rgb(0x6E, 0x6E, 0x6E),
text_tertiary: Color32::from_rgb(0x8A, 0x8A, 0x8A),
// Dim text: 3.2:1 on the selected component row (#F1E9FF, the tightest of the
// rows it lands on; #8A8A8A was 2.93:1 there), still dimmer than secondary text.
text_tertiary: Color32::from_rgb(0x84, 0x84, 0x84),
icon: Color32::from_rgb(0x1E, 0x1E, 0x1E),
icon_secondary: Color32::from_rgb(0x75, 0x75, 0x75),
hover: Color32::from_rgb(0xF5, 0xF5, 0xF5),
@@ -115,8 +124,11 @@ impl Tokens {
accent_text: Color32::from_rgb(0x00, 0x7A, 0x56),
selected_row: Color32::from_rgb(0xE0, 0xFB, 0xF1),
selected_row_child: Color32::from_rgb(0xF1, 0xFC, 0xF7),
component: COMPONENT,
// A shade darker than the canvas purple: 5.5:1 on white, 4.7:1 on the selected
// component row and 5.0:1 on a hovered row (the canvas purple reaches 3.8:1 and 4.1:1).
component: Color32::from_rgb(0x86, 0x38, 0xF0),
component_row: Color32::from_rgb(0xF1, 0xE9, 0xFF),
component_row_child: Color32::from_rgb(0xF8, 0xF4, 0xFF),
danger: Color32::from_rgb(0xF2, 0x48, 0x22),
success: DEV_READY,
warning: Color32::from_rgb(0xFF, 0xCD, 0x29),
@@ -155,8 +167,11 @@ impl Tokens {
accent_text: Color32::from_rgb(0x52, 0xE9, 0xB7),
selected_row: Color32::from_rgb(0x16, 0x42, 0x36),
selected_row_child: Color32::from_rgb(0x12, 0x37, 0x2D),
// 5.4:1 on the panel, 4.6:1 on the selected component row, 4.5:1 on a hovered row
// (the canvas purple reaches 3.1:1 and 2.7:1, #1903).
component: Color32::from_rgb(0xD2, 0x7C, 0xFF),
component_row: Color32::from_rgb(0x3E, 0x30, 0x52),
component_row_child: Color32::from_rgb(0x36, 0x2B, 0x47),
danger: Color32::from_rgb(0xF2, 0x48, 0x22),
success: DEV_READY,
warning: Color32::from_rgb(0xFF, 0xCD, 0x29),
@@ -494,7 +509,7 @@ mod tests {
let theme = if t.dark { "dark" } else { "light" };
// Disabled text (a disabled button's label, placeholders) on panels, fields and
// selected rows, still dimmer than secondary text.
for bg in [t.panel, t.field, t.hover, t.selected_row, t.selected_row_child] {
for bg in [t.panel, t.field, t.hover, t.selected_row, t.selected_row_child, t.component_row, t.component_row_child] {
let ratio = c(t.text_tertiary).contrast(c(bg));
assert!(ratio >= 3.0, "{theme}: dim text {:?} on {bg:?} is {ratio:.2}:1", t.text_tertiary);
assert!(ratio < c(t.text_secondary).contrast(c(bg)), "{theme}: dim text reads dimmer than secondary text");
+12 -2
View File
@@ -994,8 +994,18 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
};
let open = beside.map(Open::At).unwrap_or_else(|| {
let top = app.ui.picker_button.map_or(140.0, |r| r.min.y);
let edge = app.ui.right_panel_rect.map_or(screen.max.x - app.ui.right_width, |r| r.min.x);
Open::Anchor(pos2(edge - 8.0, top))
// Beside the Design panel, on its canvas side (its right in a mirrored window).
let panel = app.ui.right_panel_rect.map_or_else(
|| {
if app.ui.mirrored {
egui::Rangef::new(screen.min.x, screen.min.x + app.ui.right_width)
} else {
egui::Rangef::new(screen.max.x - app.ui.right_width, screen.max.x)
}
},
|r| r.x_range(),
);
if app.ui.mirrored { Open::AnchorMirrored(pos2(panel.max + 8.0, top)) } else { Open::Anchor(pos2(panel.min - 8.0, top)) }
});
// The colour picker's own header — title 16 in, the X 8 in from the right — is the shared
// popover's chrome now; the picker keeps its body. Its area id is [`window_id`], the one the
+11 -5
View File
@@ -1117,11 +1117,17 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
return;
}
let screen = ctx.content_rect();
// Left of the Design panel; further left while the colour picker (which sits there) is open.
let mut x = screen.max.x - app.ui.right_width - W - 32.0;
if app.ui.picker.is_some() {
x -= 260.0;
}
// Beside the Design panel, on its canvas side; further out while the colour picker (which
// sits there) is open.
let gap = if app.ui.picker.is_some() { 32.0 + 260.0 } else { 32.0 };
// Mirrored, the panel as drawn: a small window draws it narrower than asked, and a narrow one
// across the window, so the asked width would put the picker over it.
let panel = if app.ui.mirrored {
app.ui.right_panel_rect.map_or_else(|| egui::Rangef::new(screen.min.x, screen.min.x + app.ui.right_width), |r| r.x_range())
} else {
egui::Rangef::new(screen.max.x - app.ui.right_width, screen.max.x)
};
let x = crate::popover::beside_panel(panel, W, gap, app.ui.mirrored);
let y = p.y.clamp(screen.min.y + 48.0, (screen.max.y - 460.0).max(screen.min.y + 48.0));
let mut out = Frame { act: None, hover: None, budget: 4 };
// The title and the X are the shared popover's now; the list keeps its tools row.
+6 -2
View File
@@ -465,11 +465,15 @@ app. It is opt-in: the page installs `window.figcraft` only when its URL has a `
|---|---|
| `figcraft.request(method, params)` | any control method of this document → its `result` |
| `figcraft.execute(command, params)` | `engine.execute` |
| `figcraft.commands()` / `figcraft.inspect()` | `engine.commands` / the inspect method |
| `figcraft.screenshot(params)` | `ui.screenshot` (no `path`: the PNG comes back inline) |
| `figcraft.commands()` | `engine.commands` |
| `figcraft.docInspect(params)` / `figcraft.uiInspect(params)` | `document.inspect` / `ui.inspect` |
| `figcraft.inspect()` | `document.inspect` (the name before `docInspect`) |
| `figcraft.screenshot(params)` | `ui.screenshot` (no `path`: the PNG comes back inline, also as `data`) |
| `figcraft.openFile(name, bytes)` | runs `file.open` with the bytes as `data` and resolves with its result |
| `figcraft.captureDownloads(on)` | while on, a save or export is kept in the page instead of downloaded |
| `figcraft.files()` / `figcraft.readFile(name)` | the kept files (`{path, size}`) / one of them as a `Uint8Array` |
Errors reject with an `Error` carrying the method's message. Requests run on the UI's next frame, so a
hidden tab (whose frames the browser pauses) answers when it is shown again.
`request`, `execute`, `commands`, `docInspect`, `uiInspect` and `screenshot` come from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app. A call the app does not answer within 30 seconds rejects, and the app drops it unrun if it had not started it. `openFile`, `captureDownloads`, `files`, `readFile` and `inspect` are FigCraft's own.
+5 -1
View File
@@ -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 | No representative corpus 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, reviewed Russian and Turkish, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs; Arabic draft: 2,508 entries, native review and panel mirroring pending | Localization | 29–51 | [localization-parity](localization-parity.md) |
| 5 | Localization: English, reviewed Russian and Turkish, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs; Arabic draft: 2,508 entries, native review and mirrored panel rows pending (the window mirrors) | Localization | 29–51 | [localization-parity](localization-parity.md) |
| 6 | Plugins: widgets without timers, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 6–22 | [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) |
@@ -208,6 +208,10 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
### 5. Localization: English, reviewed Russian and Turkish, plus unreviewed drafts
- **Missing:** native reviews of the Spanish, French, German, Brazilian Portuguese, Japanese,
Korean, Simplified Chinese, Indonesian and Czech drafts and the Latin American Spanish
overlay, the strings added since each catalog was drafted (1–5% of each), the Arabic draft's native review and mirrored rows inside the panels (the window mirrors since 2026-10-31), Hindi and
Vietnamese, localized engine messages and number formatting; CJK UI text on the web without the
craft-fonts CJK build.
Korean, Simplified Chinese, Traditional Chinese, Indonesian and Czech drafts and the Latin
American Spanish overlay, the strings added since each catalog was drafted (1–5% of each), the
Arabic draft's native review and panel mirroring, Hindi and Vietnamese, localized engine
+13 -4
View File
@@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (`FIGCRAFT_EMBED_SCRIPTS` embeds other scripts' craft-fonts faces in a web build, so Arabic and Persian documents draw there (#1902); ; documents: a paragraph's base direction can be forced with Text ▸ Text direction, as Add right-to-left text says (#1830); Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (a right-to-left UI language (Arabic) mirrors the window: the icon rail and layers panel on the right, the Design panel on the left, popovers beside it over the canvas, egui's scroll bars, submenus and checkboxes mirrored; `FIGCRAFT_EMBED_SCRIPTS` embeds other scripts' craft-fonts faces in a web build, so Arabic and Persian documents draw there (#1902); documents: a paragraph's base direction can be forced with Text ▸ Text direction, as Add right-to-left text says (#1830); Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed
by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian
@@ -68,8 +68,14 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
either).
- **Script support in the UI:** menus widen to their longest label; dropdowns, the two-column
labels of the right panel and the home screen's cards keep a long translation on one line with an
ellipsis and show it in full on hover (checked in German, the longest); no mirrored
(right-to-left) layout of panels. Cyrillic, Turkish and Czech letters draw from the UI fonts
ellipsis and show it in full on hover (checked in German, the longest). A right-to-left UI
language (Arabic; Hebrew, Persian, Urdu and the other right-to-left languages when they get a
catalog) mirrors the window: the icon rail and the layers panel on the right, the Design panel on
the left with its edge and its popovers (colour picker, style picker, interaction editor) on the
canvas side, the panel edges dragged the other way (same widths in the preferences), and egui's
own chrome mirrored (scroll bars on the left, submenus opening left, checkbox labels on the left;
`Style::right_to_left` in the egui fork). The rows inside the panels keep their left-to-right
order (labels on the left, values on the right) and the toolbar and tab bar keep theirs. Cyrillic, Turkish and Czech letters draw from the UI fonts
(no font added; Russian checked in screenshots). CJK translations are shorter than English; the same ellipsis
and tooltip cover the few that don't fit (the sizing dropdowns).
- **CJK UI fonts:** egui's fonts and Inter have no CJK glyphs. Once the UI language, the open
@@ -108,6 +114,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
| Arabic | ar | 1,857 / 1,857 UI strings, 620 command labels, 31 menu wordings (draft, counted 2026-10-28) | catalog complete; native review pending | Existing bidi/shaping and Arabic system fallback; web: only a build with `FIGCRAFT_EMBED_SCRIPTS=Arab` embeds Noto Sans Arabic (the font budget leaves it out otherwise, #1902); documents draw in it and the picker offers the Arabic UI then; panels do not mirror | no | draft | review + panel mirroring (not re-estimated) |
| Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; the desktop UI takes an installed Devanagari face (Nirmala UI, Kohinoor Devanagari, Noto Sans Devanagari, Lohit) for names and values (#2014); no UI catalog | no | none | 4–7 |
| Arabic | ar | 1,857 / 1,857 UI strings, 620 command labels, 31 menu wordings (draft, counted 2026-10-28) | catalog complete; native review pending | Existing bidi/shaping and Arabic system fallback; web not offered (font budget excludes Arabic); panels do not mirror | no | draft | review + panel mirroring (not re-estimated) |
| Arabic | ar | 1,857 / 1,857 UI strings, 620 command labels, 31 menu wordings (draft, counted 2026-10-28) | catalog complete; native review pending | Existing bidi/shaping and Arabic system fallback; web not offered (font budget excludes Arabic); the window mirrors (panels swap sides), rows inside the panels do not | no | draft | review + mirrored panel rows (not re-estimated) |
| French | fr | 2,328 / 2,352 (99.0%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
| Portuguese | pt-BR | 2,331 / 2,352 (99.1%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
| Indonesian | id | 2,292 / 2,352 (97.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
@@ -129,7 +136,8 @@ The layer is done. Per-language hours above are agent hours for a catalog of ~1,
(a machine draft, then a pass over the panels for overflow), plus each language's plural rule where
it isn't one/other; every language also needs a native speaker's review (human). Left before full
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** (kept on
right-to-left mirroring of the rows inside the panels is in Arabic's hours (the window itself
mirrors since 2026-10-31). Total: **29–51 h** (kept on
2026-10-17: Indonesian's draft takes 2–3 h off, about what bringing the drafts back from 95% to
every string adds).
@@ -145,6 +153,7 @@ Hindi and Arabic.
| 2026-11-03 | patch | M10, #1902: the web build drew Persian and Arabic text as missing-glyph boxes. The browser has no system fonts, and `crates/text/build.rs` keeps the web catalog to Inter, Noto Sans Georgian, the wordmark and (with `FIGCRAFT_EMBED_CJK`) Noto Sans CJK, so craft-fonts' Noto Sans Arabic never reached it. `FIGCRAFT_EMBED_SCRIPTS=Arab,Hebr,…` (script tags as the manifest's scripts column lists them) now embeds those scripts' faces in a web build; the default build and its size are unchanged, and with `Arab` embedded the picker offers the Arabic UI as well (`cjk::can_show`, as on the desktop). Noto Sans Hebrew, Devanagari and Georgian join Arabic and Thai among the document fallbacks, so an embedded face draws its script in an Inter document. Tests: `web_embeds_other_scripts_faces_only_when_asked` in the build script, `every_embeddable_script_face_is_a_document_fallback`. |
| 2026-11-05 | patch | #2014: the panels drew a String variable's Amharic value as boxes: the UI took a system face for Hebrew and Arabic only. `bidi::script_fallbacks` now samples one letter of each of 16 scripts (Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as well as Hebrew and Arabic; Bengali and Thai keep `crate::cjk`'s faces) against the system's families for each (Windows, macOS, the common Linux fonts), the first installed one that draws the letter taken once. Desktop only: the web build has no system fonts. Test: `bidi::tests::other_scripts_take_an_installed_system_face` (skips scripts the machine has no face for). |
| 2026-11-05 | minor | Documents: a paragraph's base direction can be forced left to right or right to left (Text ▸ Text direction, `text.direction`, `node.set textDirection`, the Type settings control, the `.fig` lines' `sourceDirectionality`), as Add right-to-left text says (#1830); detection stays the default. Four new UI strings (English only; other catalogs fall back). |
| 2026-10-31 | minor | A right-to-left UI language mirrors the window: `i18n::right_to_left` (Arabic, Hebrew, Persian, Urdu, Pashto, Sindhi, Sorani, Uyghur, Dhivehi, Yiddish); the icon rail and layers panel on the right, the Design panel on the left, its edge line and popovers on the canvas side, panel edges and narrow-window buttons mirrored, egui's `Style::right_to_left` on. Rows inside the panels, the toolbar and the tab bar keep their order. Evidence: `crates/ui-egui/src/mirror_tests.rs`, `popover_tests.rs`, `i18n/tests.rs`. No grade change |
| 2026-10-30 | minor | Bosnian (`bs`, Bosanski) draft catalog: every UI string (1,906), command label (629) and menu wording (31), machine-drafted in Latin script with ijekavian forms and marked as a draft awaiting native review. Bosnian plurals: one for 1, 21, 31… (not 11), few for 2–4, 22–24… (not 12–14), other for the rest. Grade and hours unchanged (Bosnian is not one of the twelve key languages) |
| 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged |
| 2026-10-28 | minor | Arabic draft: 1,857 UI entries, 620 command labels and 31 menu wordings, with count-neutral plurals. AI-assisted, native review pending. Desktop uses the existing Arabic fallback and bidi; web requires an embedded Arabic face. No panel mirroring or parity-grade increase claimed |
+3 -2
View File
File diff suppressed because one or more lines are too long