Merge pull request #1772 from SoldierPePe/i18n/rtl-window-mirror

M10: a right-to-left UI language mirrors the window
This commit is contained in:
BFlat authored and GitHub committed 2026-11-07 13:11:40 +08:00
commit 808e60eeae
16 files changed
+256 -31

No files matched your search

+1 -1
View File
@@ -104,7 +104,7 @@ native speakers' reviews of the nine language drafts need people.
| Traditional Chinese (Taiwan) | zh-Hant | 2,647 / 2,647 entries (100%, counted 2026-11-03) | draft (needs native review) |
| Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) |
| Hindi | hi | 0% | none |
| Arabic | ar | 2,508 / 2,508 entries (100%, counted 2026-10-28) | draft; native review and panel mirroring pending |
| Arabic | ar | 2,508 / 2,508 entries (100%, counted 2026-10-28) | draft; the window mirrors (panels swap sides); native review and mirrored panel rows pending |
| French | fr | 95% | draft (needs native review) |
| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) |
| Indonesian | id | 99% | draft (needs native review) |
+30 -7
View File
@@ -80,6 +80,13 @@ impl FigApp {
if self.language.as_ref() != Some(&self.session.prefs.language) {
i18n::set(&self.session.prefs.language, self.services.system_language().as_deref());
self.language = Some(self.session.prefs.language.clone());
self.ui.mirrored = i18n::right_to_left();
}
// egui's own chrome follows: scroll bars on the left, submenus opening to the left,
// checkboxes with their label on the left.
if ctx.global_style().right_to_left != self.ui.mirrored {
let mirrored = self.ui.mirrored;
ctx.all_styles_mut(|s| s.right_to_left = mirrored);
}
// Screen-reader and bidi language of the UI (fork E17).
ctx.set_language(i18n::current().map(|l| l.code.to_string()));
@@ -289,8 +296,12 @@ impl FigApp {
self.ui.narrow = narrow;
// A narrow window shows the right panel when its button asks for it, a board's too.
let right_open = (self.right_panel_shown(hidden) && !narrow && !right_minimized) || mobile == Some(MobilePanel::Right);
// A right-to-left UI language mirrors the window: the "left" rail and panel go on the
// right, the "right" (Design) panel on the left. Their ids and widths stay as they are.
let mirrored = self.ui.mirrored;
let side = |start: bool, id: &'static str| if start != mirrored { egui::Panel::left(id) } else { egui::Panel::right(id) };
if !hidden && !minimized && (!narrow || mobile == Some(MobilePanel::Left)) {
egui::Panel::left("left_rail")
side(true, "left_rail")
.exact_size(layers::RAIL_WIDTH)
.resizable(false)
.frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border)))
@@ -309,11 +320,18 @@ impl FigApp {
};
let rows = ui.available_rect_before_wrap().y_range();
let mut edges: Vec<(&str, f32, f32, f32)> = Vec::new();
// The x of the edge facing the canvas of a panel `w` wide on the window's start (left)
// side, or its end side, and which way a drag to the right sizes it.
let edge = |ui: &egui::Ui, start: bool, w: f32| {
let room = ui.available_rect_before_wrap();
if start != mirrored { (room.min.x + w, 1.0) } else { (room.max.x - w, -1.0) }
};
if left_open {
if !narrow {
edges.push(("left", ui.available_rect_before_wrap().min.x + left_w, left_w, 1.0));
let (x, grows) = edge(ui, true, left_w);
edges.push(("left", x, left_w, grows));
}
egui::Panel::left("left_panel")
side(true, "left_panel")
.resizable(false)
.exact_size(left_w)
.frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border)))
@@ -330,9 +348,10 @@ impl FigApp {
self.ui.right_panel_rect = None;
if right_open {
if !narrow {
edges.push(("right", ui.available_rect_before_wrap().max.x - right_w, right_w, -1.0));
let (x, grows) = edge(ui, false, right_w);
edges.push(("right", x, right_w, grows));
}
egui::Panel::right("right_panel")
side(false, "right_panel")
.resizable(false)
.exact_size(right_w)
// No stroke: a frame's stroke is part of its margin, which would take 1 pt off
@@ -361,10 +380,14 @@ impl FigApp {
}
});
if narrow {
let x = if mobile == Some(MobilePanel::Left) { ctx.content_rect().max.x - MOBILE_STRIP + 4.0 } else { ctx.content_rect().min.x + 4.0 };
// In the strip beside the open panel; with none open, on the Design panel's side.
let at_right = (mobile == Some(MobilePanel::Left)) != mirrored;
let x = if at_right { ctx.content_rect().max.x - MOBILE_STRIP + 4.0 } else { ctx.content_rect().min.x + 4.0 };
self.mobile_buttons(&ctx, egui::pos2(x, rows.min + 8.0));
} else if minimized {
self.expand_button(&ctx, egui::pos2(ctx.content_rect().min.x + 8.0, rows.min + 8.0));
// Where the navigation bar was: on the right in a mirrored window.
let x = if mirrored { ctx.content_rect().max.x - 40.0 } else { ctx.content_rect().min.x + 8.0 };
self.expand_button(&ctx, egui::pos2(x, rows.min + 8.0));
}
// The panels' edges, over the canvas and the panels (drawn after them: they take the press).
for (name, x, shown, grows) in edges {
+12
View File
@@ -60,6 +60,18 @@ pub fn current() -> Option<&'static Lang> {
LANGS.get(CURRENT.load(Ordering::Relaxed))
}
/// Whether the language the UI shows is written right to left, so the window mirrors.
pub fn right_to_left() -> bool {
current().is_some_and(|l| writes_right_to_left(l.code))
}
/// Whether language `code` ("ar", "he-IL"…) is written right to left: Arabic, Hebrew, Persian,
/// Urdu, Pashto, Sindhi, Kurdish (Sorani), Uyghur, Dhivehi and Yiddish.
fn writes_right_to_left(code: &str) -> bool {
let base = code.split(['-', '_']).next().unwrap_or("");
["ar", "he", "fa", "ur", "ps", "sd", "ckb", "ug", "dv", "yi"].iter().any(|rtl| rtl.eq_ignore_ascii_case(base))
}
/// Show the language of `pref` (a language code, or "system" for `system`, the OS or browser
/// languages, most preferred first and comma-separated). Unknown languages show English.
pub fn set(pref: &str, system: Option<&str>) {
+14 -1
View File
@@ -4,7 +4,7 @@
use std::collections::BTreeSet;
use super::{Forms, LANGS, Lang, Plural, Text, chain, counted, fill, find, lookup, plural, plural_one, resolve};
use super::{Forms, LANGS, Lang, Plural, Text, chain, counted, fill, find, lookup, plural, plural_one, resolve, writes_right_to_left};
/// Keys looked up from data rather than written as `tr("…")` in the code: the main menu's
/// submenu names (the engine's menu catalog) and labels of engine and document enums.
@@ -550,6 +550,19 @@ fn arabic_tags_and_count_neutral_forms() {
}
}
#[test]
fn right_to_left_languages_mirror_the_window() {
for code in ["ar", "ar-SA", "AR", "he", "he-IL", "fa", "ur", "ckb", "yi"] {
assert!(writes_right_to_left(code), "{code}");
}
for code in ["en", "", "es-419", "zh-Hans", "tr", "ru", "arn", "hey"] {
assert!(!writes_right_to_left(code), "{code}");
}
// Every catalog that mirrors is one of them, and the Arabic one does.
assert!(LANGS.iter().any(|l| l.code == "ar" && writes_right_to_left(l.code)));
assert!(!writes_right_to_left(LANGS[0].code), "English reads left to right");
}
#[test]
fn the_built_in_arabic_face_draws_the_arabic_catalog() {
if !figcraft_text::CRAFT_FONTS.iter().any(|f| f.family == "Noto Sans Arabic" && f.style == "Regular") {
+6
View File
@@ -178,6 +178,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 +561,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 +714,7 @@ impl Default for UiState {
left_collapsed: false,
mobile_panel: None,
narrow: false,
mirrored: false,
comments: comments::CommentUi::default(),
versions: versions::VersionUi::default(),
branch_review: None,
+106
View File
@@ -0,0 +1,106 @@
//! A right-to-left UI language mirrors the window, driven through egui headless: the icon rail
//! and the layers panel go on the right, the Design panel on the left, their edges still size them
//! (now dragged the other way) and the widths are the same preferences; egui's own chrome (scroll
//! bars, submenus) mirrors with it, and an English window is as it was.
use egui::pos2;
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
use crate::{PANEL_DEFAULT, layers};
const WINDOW_W: f32 = 1440.0;
fn ui(mirrored: bool) -> Ui {
let mut s = Session::new();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap();
let mut u = Ui::new(s);
u.app.ui.mirrored = mirrored;
u.frame(vec![]);
u.frame(vec![]);
u
}
/// The canvas's left and right x.
fn canvas(u: &Ui) -> (f32, f32) {
(u.app.canvas.rect.min.x, u.app.canvas.rect.max.x)
}
/// Press on `from`, move to `to` in steps and let go.
fn drag(u: &mut Ui, from: egui::Pos2, to: egui::Pos2) {
u.drag_to(from, to);
u.button(to, false);
u.frame(vec![]);
}
#[test]
fn the_design_panel_is_on_the_left_and_the_layers_on_the_right() {
let u = ui(true);
let (left, right) = canvas(&u);
assert_eq!(left, PANEL_DEFAULT, "the Design panel from the window's left edge");
assert_eq!(right, WINDOW_W - layers::RAIL_WIDTH - PANEL_DEFAULT, "the layers panel and the rail on the right");
// egui keeps a point between a side panel and the canvas, on the canvas side.
let design = u.app.ui.right_panel_rect.expect("the Design panel drew");
assert_eq!((design.min.x, design.max.x), (0.0, PANEL_DEFAULT - 1.0));
assert!(u.ctx.global_style().right_to_left, "egui's chrome mirrors too");
}
#[test]
fn an_english_window_is_as_it_was() {
let u = ui(false);
let (left, right) = canvas(&u);
assert_eq!((left, right), (layers::RAIL_WIDTH + PANEL_DEFAULT, WINDOW_W - PANEL_DEFAULT));
let design = u.app.ui.right_panel_rect.expect("the Design panel drew");
assert_eq!((design.min.x, design.max.x), (WINDOW_W - PANEL_DEFAULT + 1.0, WINDOW_W));
assert!(!u.ctx.global_style().right_to_left);
}
#[test]
fn mirrored_edges_size_their_panels_dragged_the_other_way() {
let mut u = ui(true);
let (left, right) = canvas(&u);
// The layers panel's edge (the canvas's right), 60 to the left: the layers panel 60 wider.
drag(&mut u, pos2(right, 400.0), pos2(right - 60.0, 400.0));
assert_eq!((u.app.ui.left_width, u.app.ui.right_width), (PANEL_DEFAULT + 60.0, PANEL_DEFAULT));
assert_eq!(canvas(&u), (left, right - 60.0));
// The Design panel's edge (the canvas's left), 40 to the right: the Design panel 40 wider.
drag(&mut u, pos2(left, 400.0), pos2(left + 40.0, 400.0));
assert_eq!((u.app.ui.left_width, u.app.ui.right_width), (PANEL_DEFAULT + 60.0, PANEL_DEFAULT + 40.0));
assert_eq!(canvas(&u), (left + 40.0, right - 60.0));
// The same preferences as an English window's.
assert_eq!(u.app.session.prefs.panel_widths, (PANEL_DEFAULT + 60.0, PANEL_DEFAULT + 40.0));
}
#[test]
fn closing_the_layers_panel_gives_the_canvas_the_right_side() {
let mut u = ui(true);
u.app.ui.close_left_panel();
u.frame(vec![]);
assert_eq!(canvas(&u), (PANEL_DEFAULT, WINDOW_W - layers::RAIL_WIDTH), "only the rail is left on the right");
}
#[test]
fn switching_back_unmirrors_the_window_and_egui() {
let mut u = ui(true);
u.app.ui.mirrored = false;
u.frame(vec![]);
u.frame(vec![]);
assert_eq!(canvas(&u), (layers::RAIL_WIDTH + PANEL_DEFAULT, WINDOW_W - PANEL_DEFAULT));
assert!(!u.ctx.global_style().right_to_left);
}
#[test]
fn a_minimized_window_has_its_expand_button_where_the_rail_was() {
use egui::accesskit::Role;
for mirrored in [true, false] {
let mut u = ui(mirrored);
u.app.run("select.set", json!({"ids": []}));
u.app.run("view.minimize", json!({"on": true}));
u.frame(vec![]);
let expand = crate::panel_width_tests::control(&mut u, Role::Button, "Expand the interface").expect("the Expand button");
// 32 wide, 8 from the window's edge.
let x = if mirrored { WINDOW_W - 24.0 } else { 24.0 };
assert_eq!(expand.x, x, "mirrored: {mirrored}");
}
}
+1 -1
View File
@@ -211,7 +211,7 @@ fn minimize_ui_hides_the_panels_and_keeps_their_widths() {
}
/// The middle of the accessible control with `role` and `label`, if one shows.
fn control(u: &mut Ui, role: egui::accesskit::Role, label: &str) -> Option<Pos2> {
pub(crate) fn control(u: &mut Ui, role: egui::accesskit::Role, label: &str) -> Option<Pos2> {
u.ctx.enable_accesskit();
u.frame(vec![]);
let out = u.frame_output(vec![]);
+9 -6
View File
@@ -639,30 +639,33 @@ fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, nod
&& n == id
&& let Some(it) = list.get(k)
{
let panel_left = ui.clip_rect().min.x;
let panel = ui.clip_rect().x_range();
let y = anchor.map(|a| a.min.y).unwrap_or(ui.clip_rect().min.y + 80.0);
editor_popover(app, ui.ctx(), InteractionPopover { doc, page, node: id, index: k, interaction: it, anchor: pos2(panel_left - 8.0, y) });
editor_popover(app, ui.ctx(), InteractionPopover { doc, page, node: id, index: k, interaction: it, panel, y });
} else if open.is_some_and(|(n, _)| n == id) {
open_editor(ui.ctx(), None);
}
}
/// The interaction editor, floating left of the panel (`at` is its top-right corner).
/// The interaction editor, floating beside the panel (`panel`, its x range) on the canvas side,
/// its top at `y`.
struct InteractionPopover<'a> {
doc: &'a Document,
page: NodeId,
node: NodeId,
index: usize,
interaction: &'a Interaction,
anchor: Pos2,
panel: egui::Rangef,
y: f32,
}
fn editor_popover(app: &mut FigApp, ctx: &egui::Context, popover: InteractionPopover<'_>) {
let InteractionPopover { doc, page, node, index: k, interaction: it, anchor: at } = popover;
let InteractionPopover { doc, page, node, index: k, interaction: it, panel, y } = popover;
let t = Tokens::get(ctx);
let width = 280.0;
let screen = ctx.content_rect();
let pos = pos2(at.x - width, at.y.min(screen.max.y - 420.0).max(screen.min.y + 48.0));
let x = crate::popover::beside_panel(panel, width, 8.0, app.ui.mirrored);
let pos = pos2(x, y.min(screen.max.y - 420.0).max(screen.min.y + 48.0));
let mut close = false;
egui::Area::new(Id::unique("proto_editor_area")).fixed_pos(pos).order(egui::Order::Foreground).show(ctx, |ui| {
egui::Frame::new()
+3 -1
View File
@@ -119,7 +119,9 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) {
// take 1 pt off each side of the panel (240 pt by default)).
let edge = ui.max_rect();
app.ui.right_panel_rect = Some(edge);
ui.painter().line_segment([edge.left_top(), edge.left_bottom()], Stroke::new(1.0, t.border));
// On the side facing the canvas (its right in a mirrored window).
let x = if app.ui.mirrored { edge.max.x } else { edge.min.x };
ui.painter().vline(x, edge.y_range(), Stroke::new(1.0, t.border));
let w = ui.available_width();
// Top bar: people (you and the others in a live session), present, share.
let (top, _) = ui.allocate_exact_size(vec2(w, 48.0), Sense::hover());
+10
View File
@@ -51,6 +51,9 @@ pub enum Open {
/// Its right edge on `x` (the right panel's left edge), its top on `y` (the button's top),
/// lifted when that would run its bottom past the screen's bottom.
Anchor(Pos2),
/// [`Open::Anchor`] in a mirrored (right-to-left) window: its left edge on `x` (the Design
/// panel's right edge).
AnchorMirrored(Pos2),
/// Its top-left exactly here.
At(Pos2),
}
@@ -167,6 +170,7 @@ impl Popover {
let p = match open {
Open::At(p) => p,
Open::Anchor(a) => open_pos(a, self.size, screen),
Open::AnchorMirrored(a) => open_pos(a, self.size, screen) + vec2(self.size.x, 0.0),
};
clamp(p, self.size, screen)
}
@@ -277,6 +281,12 @@ fn open_pos(anchor: Pos2, size: Vec2, screen: Rect) -> Pos2 {
pos2(anchor.x - size.x, y)
}
/// The left x of a window `width` wide that floats beside the Design panel (`panel`, its x range)
/// `gap` out over the canvas: left of the panel, or right of it in a mirrored window.
pub fn beside_panel(panel: egui::Rangef, width: f32, gap: f32, mirrored: bool) -> f32 {
if mirrored { panel.max + gap } else { panel.min - gap - width }
}
/// The height a popover's content is offered: the screen's, less [`EDGE`] above and below.
///
/// egui offers an area the size it had last frame, so content that fills what it is offered (a
+21
View File
@@ -84,6 +84,27 @@ fn it_opens_with_its_right_edge_on_the_anchor_and_its_top_aligned() {
assert!((p.rect.min.y - 200.0).abs() < 0.01, "its top on the anchor: {:?}", p.rect);
}
#[test]
fn mirrored_it_opens_with_its_left_edge_on_the_anchor_and_lifts_the_same() {
let ctx = context();
let mut t = 0.0;
let mut p = Popover::new("popover_anchor_mirrored", "Popup");
settle(&ctx, &mut t, &mut p, Open::AnchorMirrored(pos2(300.0, 200.0)));
assert!((p.rect.min.x - 300.0).abs() < 0.01, "its left edge on the anchor: {:?}", p.rect);
assert!((p.rect.min.y - 200.0).abs() < 0.01, "its top on the anchor: {:?}", p.rect);
let mut p = Popover::new("popover_lift_mirrored", "Popup");
settle(&ctx, &mut t, &mut p, Open::AnchorMirrored(pos2(300.0, 860.0)));
assert!((p.rect.max.y - (SCREEN.y - 16.0)).abs() < 0.01, "lifted off the screen's bottom: {:?}", p.rect);
}
#[test]
fn a_window_beside_the_design_panel_floats_over_the_canvas() {
let panel = egui::Rangef::new(1200.0, 1440.0);
assert_eq!(crate::popover::beside_panel(panel, 280.0, 8.0, false), 1200.0 - 8.0 - 280.0, "left of the panel");
let panel = egui::Rangef::new(0.0, 240.0);
assert_eq!(crate::popover::beside_panel(panel, 280.0, 8.0, true), 248.0, "right of a mirrored one");
}
#[test]
fn it_lifts_so_its_bottom_keeps_16_from_the_screens_bottom() {
let ctx = context();
+2 -2
View File
@@ -318,9 +318,9 @@ fn close_tab(app: &mut FigApp, ui: &Ui, i: usize) {
}
/// The Home tab at `x`; returns its width. Without inset window buttons its divider continues the
/// icon rail's edge below.
/// icon rail's edge below (a mirrored window has the rail on the right: no edge to continue).
fn home_tab(app: &mut FigApp, ui: &mut Ui, t: &Tokens, x: f32, rect: Rect) -> f32 {
let w = if app.ui.title_bar == TitleBar::Inset { 44.0 } else { rect.min.x + crate::layers::RAIL_WIDTH - x };
let w = if app.ui.title_bar == TitleBar::Inset || app.ui.mirrored { 44.0 } else { rect.min.x + crate::layers::RAIL_WIDTH - x };
let hr = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT));
let hresp = ui.interact(hr, ui.scope_id().with("home"), Sense::click()).tip(tr("Home"));
let home_active = (app.ui.home || !app.has_files()) && app.ui.present.is_none();
+12 -2
View File
@@ -994,8 +994,18 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
};
let open = beside.map(Open::At).unwrap_or_else(|| {
let top = app.ui.picker_button.map_or(140.0, |r| r.min.y);
let edge = app.ui.right_panel_rect.map_or(screen.max.x - app.ui.right_width, |r| r.min.x);
Open::Anchor(pos2(edge - 8.0, top))
// Beside the Design panel, on its canvas side (its right in a mirrored window).
let panel = app.ui.right_panel_rect.map_or_else(
|| {
if app.ui.mirrored {
egui::Rangef::new(screen.min.x, screen.min.x + app.ui.right_width)
} else {
egui::Rangef::new(screen.max.x - app.ui.right_width, screen.max.x)
}
},
|r| r.x_range(),
);
if app.ui.mirrored { Open::AnchorMirrored(pos2(panel.max + 8.0, top)) } else { Open::Anchor(pos2(panel.min - 8.0, top)) }
});
// The colour picker's own header — title 16 in, the X 8 in from the right — is the shared
// popover's chrome now; the picker keeps its body. Its area id is [`window_id`], the one the
+11 -5
View File
@@ -1117,11 +1117,17 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
return;
}
let screen = ctx.content_rect();
// Left of the Design panel; further left while the colour picker (which sits there) is open.
let mut x = screen.max.x - app.ui.right_width - W - 32.0;
if app.ui.picker.is_some() {
x -= 260.0;
}
// Beside the Design panel, on its canvas side; further out while the colour picker (which
// sits there) is open.
let gap = if app.ui.picker.is_some() { 32.0 + 260.0 } else { 32.0 };
// Mirrored, the panel as drawn: a small window draws it narrower than asked, and a narrow one
// across the window, so the asked width would put the picker over it.
let panel = if app.ui.mirrored {
app.ui.right_panel_rect.map_or_else(|| egui::Rangef::new(screen.min.x, screen.min.x + app.ui.right_width), |r| r.x_range())
} else {
egui::Rangef::new(screen.max.x - app.ui.right_width, screen.max.x)
};
let x = crate::popover::beside_panel(panel, W, gap, app.ui.mirrored);
let y = p.y.clamp(screen.min.y + 48.0, (screen.max.y - 460.0).max(screen.min.y + 48.0));
let mut out = Frame { act: None, hover: None, budget: 4 };
// The title and the X are the shared popover's now; the list keeps its tools row.
+5 -1
View File
@@ -18,7 +18,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [collaboration-parity](collaboration-parity.md) |
| 3 | No representative corpus checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) |
| 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) |
| 5 | Localization: English, reviewed Russian and Turkish, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs; Arabic draft: 2,508 entries, native review and panel mirroring pending | Localization | 29–51 | [localization-parity](localization-parity.md) |
| 5 | Localization: English, reviewed Russian and Turkish, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs; Arabic draft: 2,508 entries, native review and mirrored panel rows pending (the window mirrors) | Localization | 29–51 | [localization-parity](localization-parity.md) |
| 6 | Plugins: widgets without timers, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 6–22 | [target-app-parity](target-app-parity.md) |
| 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) |
| 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) |
@@ -208,6 +208,10 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
### 5. Localization: English, reviewed Russian and Turkish, plus unreviewed drafts
- **Missing:** native reviews of the Spanish, French, German, Brazilian Portuguese, Japanese,
Korean, Simplified Chinese, Indonesian and Czech drafts and the Latin American Spanish
overlay, the strings added since each catalog was drafted (1–5% of each), the Arabic draft's native review and mirrored rows inside the panels (the window mirrors since 2026-10-31), Hindi and
Vietnamese, localized engine messages and number formatting; CJK UI text on the web without the
craft-fonts CJK build.
Korean, Simplified Chinese, Traditional Chinese, Indonesian and Czech drafts and the Latin
American Spanish overlay, the strings added since each catalog was drafted (1–5% of each), the
Arabic draft's native review and panel mirroring, Hindi and Vietnamese, localized engine
+13 -4
View File
@@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (`FIGCRAFT_EMBED_SCRIPTS` embeds other scripts' craft-fonts faces in a web build, so Arabic and Persian documents draw there (#1902); ; documents: a paragraph's base direction can be forced with Text ▸ Text direction, as Add right-to-left text says (#1830); Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (a right-to-left UI language (Arabic) mirrors the window: the icon rail and layers panel on the right, the Design panel on the left, popovers beside it over the canvas, egui's scroll bars, submenus and checkboxes mirrored; `FIGCRAFT_EMBED_SCRIPTS` embeds other scripts' craft-fonts faces in a web build, so Arabic and Persian documents draw there (#1902); documents: a paragraph's base direction can be forced with Text ▸ Text direction, as Add right-to-left text says (#1830); Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed
by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian
@@ -68,8 +68,14 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
either).
- **Script support in the UI:** menus widen to their longest label; dropdowns, the two-column
labels of the right panel and the home screen's cards keep a long translation on one line with an
ellipsis and show it in full on hover (checked in German, the longest); no mirrored
(right-to-left) layout of panels. Cyrillic, Turkish and Czech letters draw from the UI fonts
ellipsis and show it in full on hover (checked in German, the longest). A right-to-left UI
language (Arabic; Hebrew, Persian, Urdu and the other right-to-left languages when they get a
catalog) mirrors the window: the icon rail and the layers panel on the right, the Design panel on
the left with its edge and its popovers (colour picker, style picker, interaction editor) on the
canvas side, the panel edges dragged the other way (same widths in the preferences), and egui's
own chrome mirrored (scroll bars on the left, submenus opening left, checkbox labels on the left;
`Style::right_to_left` in the egui fork). The rows inside the panels keep their left-to-right
order (labels on the left, values on the right) and the toolbar and tab bar keep theirs. Cyrillic, Turkish and Czech letters draw from the UI fonts
(no font added; Russian checked in screenshots). CJK translations are shorter than English; the same ellipsis
and tooltip cover the few that don't fit (the sizing dropdowns).
- **CJK UI fonts:** egui's fonts and Inter have no CJK glyphs. Once the UI language, the open
@@ -108,6 +114,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
| Arabic | ar | 1,857 / 1,857 UI strings, 620 command labels, 31 menu wordings (draft, counted 2026-10-28) | catalog complete; native review pending | Existing bidi/shaping and Arabic system fallback; web: only a build with `FIGCRAFT_EMBED_SCRIPTS=Arab` embeds Noto Sans Arabic (the font budget leaves it out otherwise, #1902); documents draw in it and the picker offers the Arabic UI then; panels do not mirror | no | draft | review + panel mirroring (not re-estimated) |
| Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; the desktop UI takes an installed Devanagari face (Nirmala UI, Kohinoor Devanagari, Noto Sans Devanagari, Lohit) for names and values (#2014); no UI catalog | no | none | 4–7 |
| Arabic | ar | 1,857 / 1,857 UI strings, 620 command labels, 31 menu wordings (draft, counted 2026-10-28) | catalog complete; native review pending | Existing bidi/shaping and Arabic system fallback; web not offered (font budget excludes Arabic); panels do not mirror | no | draft | review + panel mirroring (not re-estimated) |
| Arabic | ar | 1,857 / 1,857 UI strings, 620 command labels, 31 menu wordings (draft, counted 2026-10-28) | catalog complete; native review pending | Existing bidi/shaping and Arabic system fallback; web not offered (font budget excludes Arabic); the window mirrors (panels swap sides), rows inside the panels do not | no | draft | review + mirrored panel rows (not re-estimated) |
| French | fr | 2,328 / 2,352 (99.0%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
| Portuguese | pt-BR | 2,331 / 2,352 (99.1%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
| Indonesian | id | 2,292 / 2,352 (97.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
@@ -129,7 +136,8 @@ The layer is done. Per-language hours above are agent hours for a catalog of ~1,
(a machine draft, then a pass over the panels for overflow), plus each language's plural rule where
it isn't one/other; every language also needs a native speaker's review (human). Left before full
parity besides the languages: localizing engine messages and number formatting (4–6 h);
right-to-left mirroring of the panels is in Arabic's hours. Total: **29–51 h** (kept on
right-to-left mirroring of the rows inside the panels is in Arabic's hours (the window itself
mirrors since 2026-10-31). Total: **29–51 h** (kept on
2026-10-17: Indonesian's draft takes 2–3 h off, about what bringing the drafts back from 95% to
every string adds).
@@ -145,6 +153,7 @@ Hindi and Arabic.
| 2026-11-03 | patch | M10, #1902: the web build drew Persian and Arabic text as missing-glyph boxes. The browser has no system fonts, and `crates/text/build.rs` keeps the web catalog to Inter, Noto Sans Georgian, the wordmark and (with `FIGCRAFT_EMBED_CJK`) Noto Sans CJK, so craft-fonts' Noto Sans Arabic never reached it. `FIGCRAFT_EMBED_SCRIPTS=Arab,Hebr,…` (script tags as the manifest's scripts column lists them) now embeds those scripts' faces in a web build; the default build and its size are unchanged, and with `Arab` embedded the picker offers the Arabic UI as well (`cjk::can_show`, as on the desktop). Noto Sans Hebrew, Devanagari and Georgian join Arabic and Thai among the document fallbacks, so an embedded face draws its script in an Inter document. Tests: `web_embeds_other_scripts_faces_only_when_asked` in the build script, `every_embeddable_script_face_is_a_document_fallback`. |
| 2026-11-05 | patch | #2014: the panels drew a String variable's Amharic value as boxes: the UI took a system face for Hebrew and Arabic only. `bidi::script_fallbacks` now samples one letter of each of 16 scripts (Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as well as Hebrew and Arabic; Bengali and Thai keep `crate::cjk`'s faces) against the system's families for each (Windows, macOS, the common Linux fonts), the first installed one that draws the letter taken once. Desktop only: the web build has no system fonts. Test: `bidi::tests::other_scripts_take_an_installed_system_face` (skips scripts the machine has no face for). |
| 2026-11-05 | minor | Documents: a paragraph's base direction can be forced left to right or right to left (Text ▸ Text direction, `text.direction`, `node.set textDirection`, the Type settings control, the `.fig` lines' `sourceDirectionality`), as Add right-to-left text says (#1830); detection stays the default. Four new UI strings (English only; other catalogs fall back). |
| 2026-10-31 | minor | A right-to-left UI language mirrors the window: `i18n::right_to_left` (Arabic, Hebrew, Persian, Urdu, Pashto, Sindhi, Sorani, Uyghur, Dhivehi, Yiddish); the icon rail and layers panel on the right, the Design panel on the left, its edge line and popovers on the canvas side, panel edges and narrow-window buttons mirrored, egui's `Style::right_to_left` on. Rows inside the panels, the toolbar and the tab bar keep their order. Evidence: `crates/ui-egui/src/mirror_tests.rs`, `popover_tests.rs`, `i18n/tests.rs`. No grade change |
| 2026-10-30 | minor | Bosnian (`bs`, Bosanski) draft catalog: every UI string (1,906), command label (629) and menu wording (31), machine-drafted in Latin script with ijekavian forms and marked as a draft awaiting native review. Bosnian plurals: one for 1, 21, 31… (not 11), few for 2–4, 22–24… (not 12–14), other for the rest. Grade and hours unchanged (Bosnian is not one of the twelve key languages) |
| 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged |
| 2026-10-28 | minor | Arabic draft: 1,857 UI entries, 620 command labels and 31 menu wordings, with count-neutral plurals. AI-assisted, native review pending. Desktop uses the existing Arabic fallback and bidi; web requires an embedded Arabic face. No panel mirroring or parity-grade increase claimed |