From 5233c451de5b570eb472adbd4b9151088ad492fd Mon Sep 17 00:00:00 2001 From: SoldierPePe Date: Sat, 31 Oct 2026 18:21:35 +0300 Subject: [PATCH] M10: a right-to-left UI language mirrors the window With the UI in Arabic (or another right-to-left language), the icon rail and the layers panel go on the right and the Design panel on the left. The panel edges size the panels dragged the other way, with the same width preferences. The Design panel's edge line, the colour picker, the style picker and the interaction editor sit on its canvas side, and egui's own chrome mirrors through Style::right_to_left. Rows inside the panels, the toolbar and the tab bar keep their order for now. --- ROADMAP.md | 2 +- crates/ui-egui/src/app.rs | 37 +++++++-- crates/ui-egui/src/i18n.rs | 12 +++ crates/ui-egui/src/i18n/tests.rs | 15 +++- crates/ui-egui/src/lib.rs | 6 ++ crates/ui-egui/src/mirror_tests.rs | 91 ++++++++++++++++++++++ crates/ui-egui/src/panels/proto_panel.rs | 15 ++-- crates/ui-egui/src/panels/right.rs | 4 +- crates/ui-egui/src/popover.rs | 10 +++ crates/ui-egui/src/popover_tests.rs | 21 +++++ crates/ui-egui/src/tabs.rs | 4 +- crates/ui-egui/src/widgets/colorpicker.rs | 14 +++- crates/ui-egui/src/widgets/style_picker.rs | 14 ++-- docs/gaps.md | 4 +- docs/localization-parity.md | 18 +++-- 15 files changed, 235 insertions(+), 32 deletions(-) create mode 100644 crates/ui-egui/src/mirror_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 410705919..4c0b108af 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -103,7 +103,7 @@ native speakers' reviews of the nine language drafts need people. | Simplified Chinese | zh-Hans | 95% | 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) | diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index e13acce68..bd16057ad 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -79,6 +79,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())); @@ -288,8 +295,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))) @@ -308,11 +319,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))) @@ -329,9 +347,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 @@ -360,10 +379,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 { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index d05f4d03a..380a78076 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -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>) { diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 1cc4623e8..489672f0f 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -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") { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e9969c106..d41d51ace 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -172,6 +172,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; @@ -545,6 +547,9 @@ pub struct UiState { pub mobile_panel: Option, /// 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. @@ -693,6 +698,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, diff --git a/crates/ui-egui/src/mirror_tests.rs b/crates/ui-egui/src/mirror_tests.rs new file mode 100644 index 000000000..b64ae828c --- /dev/null +++ b/crates/ui-egui/src/mirror_tests.rs @@ -0,0 +1,91 @@ +//! 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); +} diff --git a/crates/ui-egui/src/panels/proto_panel.rs b/crates/ui-egui/src/panels/proto_panel.rs index 2bc635244..d7728cbdc 100644 --- a/crates/ui-egui/src/panels/proto_panel.rs +++ b/crates/ui-egui/src/panels/proto_panel.rs @@ -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() diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 6dde13136..a38dfd0d3 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -113,7 +113,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()); diff --git a/crates/ui-egui/src/popover.rs b/crates/ui-egui/src/popover.rs index 14d1e133f..f9fe61fc3 100644 --- a/crates/ui-egui/src/popover.rs +++ b/crates/ui-egui/src/popover.rs @@ -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) } @@ -276,6 +280,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 } +} + /// Keeps the panel inside the screen: whole when it fits, its top-left corner when it doesn't. fn clamp(pos: Pos2, size: Vec2, screen: Rect) -> Pos2 { let (x0, y0) = (screen.min.x + EDGE, screen.min.y + EDGE); diff --git a/crates/ui-egui/src/popover_tests.rs b/crates/ui-egui/src/popover_tests.rs index 713e8d396..06daca851 100644 --- a/crates/ui-egui/src/popover_tests.rs +++ b/crates/ui-egui/src/popover_tests.rs @@ -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(); diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index e59552d12..2eea157ef 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -319,9 +319,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(); diff --git a/crates/ui-egui/src/widgets/colorpicker.rs b/crates/ui-egui/src/widgets/colorpicker.rs index 5fbcc7b40..eb2ffe34e 100644 --- a/crates/ui-egui/src/widgets/colorpicker.rs +++ b/crates/ui-egui/src/widgets/colorpicker.rs @@ -967,8 +967,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 diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs index eb2a2b44b..8ba7c01d5 100644 --- a/crates/ui-egui/src/widgets/style_picker.rs +++ b/crates/ui-egui/src/widgets/style_picker.rs @@ -1082,11 +1082,15 @@ 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 }; + let panel = 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) + }; + 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. diff --git a/docs/gaps.md b/docs/gaps.md index 5437c6766..45e344b2f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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) | @@ -209,7 +209,7 @@ 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 panel mirroring, Hindi and + 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. - **Evidence (measured from source):** the layer landed 2026-10-11: catalogs in diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 541e7ab90..7b404a8bf 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (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-10-31 · **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; 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 @@ -63,8 +63,14 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v harfrust. No vertical text (Figma has none 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 @@ -91,7 +97,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Simplified Chinese (Mandarin) | zh-Hans | 2,216 / 2,352 (94.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) | | Spanish | es, es-419 | 2,352 / 2,352 (100%, draft); es-419 overlays 104 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) | | Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 | -| Arabic | ar | 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) | @@ -113,7 +119,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). @@ -125,6 +132,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see | Date | Change | Summary | |---|---|---| +| 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 |