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 |