mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-09 20:12:49 +00:00
Merge main into #1873
This commit is contained in:
commit
3f6aa299ea
33 files changed
+748
-146
No files matched your search
Generated
+13
-1
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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 ¬es {
|
||||
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);
|
||||
|
||||
@@ -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
@@ -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(¶ms)))
|
||||
"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(¶ms)}))
|
||||
})
|
||||
.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(¶ms))).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(())
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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![]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export formLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#85Line truncated
|
||||
|
||||
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
|
||||
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
|
||||
@@ -15,8 +15,8 @@ interaction feel not yet compared.
|
||||
|
||||
| Item | Status | Notes |
|
||||
|---|---|---|
|
||||
| Light and dark themes | done | colours from `theme::Tokens`; component, variant and instance rows in the Layers panel take the theme's component tint, at AA on the panel and on their selected row (hidden ones in the faded text colour, at least 3:1 on the panel, a hovered row and their selected row), and the rows inside a selected component a tone of its row (`layers_component_rows_tests`, #1903); 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`) |
|
||||
| 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; tabs reorder by dragging along the strip, and closing one whose file has unsaved changes asks Don't Save / Cancel / Save (Alt+close drops the changes at once, #2032); on a Wayland compositor that offers no server-side decorations (GNOME) the window gets winit's Adwaita controls instead (#592; checked by its author on GNOME, KDE not run) |
|
||||
| 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`) |
|
||||
| Minimize UI (Shift+Cmd/Ctrl+\\, the Minimize button by the file name) and Show/Hide UI (Cmd/Ctrl+\\) | done | Minimize collapses the navigation bar and both sidebars and keeps the toolbar and the panel widths; "If you select an object while the panels are minimized, the left sidebar remains minimized while the right sidebar expands [...] Once all objects are deselected, the right panel minimizes again" (Explore the navigation bar and left sidebar); a small Expand button at the top left of the canvas puts them back (FigCraft's own, the article names no control for it). Hide UI takes the toolbar too (`panel_width_tests`, `shortcuts_tests`; #1835) |
|
||||
| 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 |
|
||||
| Design panel's sections on one grid | done | every section lays its rows out on the same grid: the panel is 240 pt, its content 16 pt in from the left and 8 pt from the right, two 88 pt columns at that width with 8 pt gutters and a 24 pt column for the buttons at a row's end (the columns are flex, so they share the panel when it is dragged wider); rows 24 pt in a 32 pt track, 10 pt captions over them; section titles 11 pt semibold and a row's values 11 pt, their buttons 24 pt and 4 pt apart. The panel's edge is drawn inside it: a frame stroke counts as margin and took 1 pt off each side, putting every right margin 1–2 pt out (checked in the app on Windows over the control channel) |
|
||||
@@ -191,6 +191,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
| 2026-11-02 | minor | M8: layer hierarchy backgrounds compute each visible row's selection root once, plus its immediate offscreen neighbors, rather than walking ancestors three times per row. Selection membership uses a set instead of a linear search. The scrolling regression keeps the internal seams square at viewport boundaries. Numbers unchanged. |
|
||||
| 2026-11-02 | minor | M8: selected layer hierarchies have a continuous background across row spacing, with rounded outer corners; nested selections stay in the same block and separate selected trees keep their own corners. Regressions cover continuous fills, the selected-row highlight above them, scrolling and the original palette's dim-text contrast. Numbers unchanged. |
|
||||
| 2026-11-03 | patch | M1, #1904: the Layers panel cut a name that didn't fit with an ellipsis and had no horizontal scrollbar, so the variants of an imported component set all read `Size=small, Type=prim…` and a deeply nested name lost its room to the indent. The list is now as wide as its widest name needs (the row cache lays names out in descending order of a bound on their width until the bound falls under the widest found: a list with one long name lays out a handful, names all alike all of them, through egui's galley cache) and scrolls sideways, as the reference app's Layers panel does with its horizontal scrollbar; revealing a layer scrolls up or down only; the eye and padlock sit at the end of the row and scroll with it; a drag released past the list, over the canvas, drops nowhere; a name past 1200 pt still elides (#1650). Tests: `layers_wide_names_tests`. Numbers unchanged. |
|
||||
| 2026-11-03 | patch | M1, #1903: the Layers panel drew component, variant and instance names in the canvas outline purple whatever the theme (3.1:1 on the dark panel, 2.7:1 on the selected row, under WCAG AA's 4.5:1), and the rows inside a selected component in the green of a plain selection. They take the theme's component tint (`Tokens::component`: the dark theme's lilac; the light theme's a shade darker than the canvas purple, so it reads at AA on a selected and a hovered row too), and the rows inside a selected component a tone of its row (`component_row_child`). The selection's own green is FigCraft's and stays; the canvas keeps its purple. Test: `layers_component_rows_tests`. Numbers unchanged. |
|
||||
| 2026-11-06 | patch | M7, #2032: closing a tab whose file has unsaved changes, by its X, a middle click, File ▸ Close tab or its shortcut, only showed a toast ("save first, or close again with Alt to discard") and left the tab open, so closing seemed not to work. It now asks the unsaved-changes question the window asks (`windows::TabClose`): Don't Save closes the tab, Cancel or Escape keeps it, Save saves the file and then closes it (a file without a path, or saved as .fig, asks where with Save as first and stays open if that is cancelled); Alt+close still drops the changes at once, and `file.close`'s `dialog.unsaved` request asks too. The three toasts left the catalogs. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Reordering tabs by dragging, the issue's other half, landed on 2026-10-19 (`dragging_a_tab_along_the_bar_reorders_it_without_detaching`) |
|
||||
| 2026-11-05 | minor | M4, #1830: a paragraph's base direction can be forced (`TextDirection`: Auto, Ltr, Rtl on `ParaList`): Text ▸ Text direction ▸ Auto / Left to right / Right to left (`text.directionAuto` / `Ltr` / `Rtl`) and `text.direction {direction, ids?}` set the edited paragraphs while editing, else the layer's; `node.set textDirection`; the Type settings control once a right-to-left script is in the text; inspect `textDirection` / `lineDirections`; CSS `direction`; `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` runs the algorithm per paragraph, as it starts over at each). Before, direction came from the letters only ("controls are available to override text direction per paragraph", Add right-to-left text). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests::a_lines_source_directionality_is_its_paragraphs_direction_both_ways`. |
|
||||
| 2026-11-05 | patch | M1, #2003: the Design panel's boolean operation menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code (two offset squares, the result filled at reduced alpha). Numbers unchanged. |
|
||||
|
||||
Reference in new issue
Block a user