Merge main into #1855

This commit is contained in:
bflatastic
2026-11-07 09:22:57 +07:00
29 changed files with 607 additions and 54 deletions
+3 -2
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -112,3 +112,6 @@ display_name = "Denis Gomes"
[people.Hamza0101]
real_name = "Hamza Naveed"
display_name = "Hamza0101"
[people.ashokram-si]
real_name = "Ashok Ram"
+8
View File
@@ -201,6 +201,7 @@ impl ToolId {
| ToolId::Star
| ToolId::Line
| ToolId::Arrow
| ToolId::BoardShape
)
}
}
@@ -591,6 +592,13 @@ pub struct ToolState {
pub picked: Option<figcraft_color::Color>,
/// The widget layers whose hover styles show while the pointer is over them (a live preview).
pub(crate) widget_hover: Vec<NodeId>,
/// The placement crosshair: where Enter puts the next shape or frame once the arrows have
/// shown it (in the middle of the view) and moved it (`tool.crosshair`), in page coordinates
/// of the document and page in `place_on` (elsewhere it isn't shown: `Session::crosshair`).
/// Placing, Escape and a change of tool take it away.
pub place_at: Option<Point>,
/// The document (uid) and page the crosshair was shown on.
pub(crate) place_on: Option<(u64, NodeId)>,
}
impl ToolState {
+2
View File
@@ -212,6 +212,8 @@ impl Session {
crate::normalize(ds.doc_mut(), &before);
}
self.commit_interaction()?;
// A placement by the pointer takes the placement crosshair with it, as Enter does.
self.tool.place_at = None;
if !self.prefs.keep_tool {
self.set_tool(ToolId::Move);
}
+26 -3
View File
@@ -84,9 +84,14 @@ impl Session {
if self.tool.busy() {
return refuse("finish the drag first");
}
// The crosshair the arrows moved goes with the placement, which lands on it when no point
// is given.
let crosshair = self.crosshair();
self.tool.place_at = None;
let v = &self.ds()?.view;
let (c, zoom) = (at.unwrap_or(Point::new(v.cx, v.cy)), v.zoom);
let (w, h) = tool.click_size();
let (c, zoom) = (at.or(crosshair).unwrap_or(Point::new(v.cx, v.cy)), v.zoom);
// The board Shapes tool centres its own shape on the press; the others get half a click's size.
let (w, h) = if matches!(tool, ToolId::BoardShape) { (0.0, 0.0) } else { tool.click_size() };
let ev = |kind| PointerEvent::at(kind, c.x - w / 2.0, c.y - h / 2.0, zoom);
let r = self.pointer(&ev(PointerKind::Down)).and_then(|()| self.pointer(&ev(PointerKind::Up)));
if r.is_err() {
@@ -111,6 +116,16 @@ impl Session {
self.tool.overlay = Overlay::default();
return self.cancel_interaction();
}
// The placement crosshair goes first; the tool stays. One left on another page or
// document goes quietly.
let shown = self.crosshair().is_some();
self.tool.place_at = None;
if shown {
if let Ok(ds) = self.ds_mut() {
ds.revision += 1;
}
return Ok(());
}
if self.end_origin_edit() {
return Ok(());
}
@@ -174,6 +189,14 @@ impl Session {
/// Snapshot of tool state for the UI / inspect.
pub fn tool_info(&self) -> serde_json::Value {
let shapes: Vec<serde_json::Value> = self.shape_handles().into_iter().map(|(h, at)| json!({"handle": h, "at": at})).collect();
json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles(), "shapeHandles": shapes})
let crosshair = self.crosshair().map(|p| [p.x, p.y]);
json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles(), "shapeHandles": shapes, "crosshair": crosshair})
}
/// The placement crosshair, while the document and page it was shown on are the ones in
/// front (`ToolState::place_at`).
pub fn crosshair(&self) -> Option<Point> {
let here = self.ds().ok().map(|d| (d.uid, d.page));
self.tool.place_at.filter(|_| here.is_some() && here == self.tool.place_on)
}
}
@@ -76,6 +76,10 @@ impl Session {
if !matches!(t, ToolId::Move | ToolId::Scale | ToolId::Hand) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) {
let _ = crate::vectoredit::end(self);
}
// The placement crosshair goes with a change of tool, not with the same tool chosen again.
if self.tool.tool != t {
self.tool.place_at = None;
}
self.tool.tool = t;
self.tool.gesture = Gesture::None;
self.tool.pick_only = false;
+36
View File
@@ -529,6 +529,42 @@ pub fn specs() -> Vec<CommandSpec> {
tool(s, t)
}
),
spec!(
"tool.crosshair",
"Move the placement crosshair",
"{dx?, dy? (arrow-key steps, e.g. 1 or -1), big? (the large nudge, like Shift)} — the arrows with a shape or frame tool chosen and nothing selected: the first shows the crosshair in the middle of the view, the next move it by the nudge amount; Enter (`tool.place`) puts the object there",
false,
|s, p| {
if !s.tool.tool.draws_shape() {
return Err(EngineError::Disabled("tool.crosshair".into(), "choose a shape or frame tool first".into()));
}
let step = if boolean(p, "big").unwrap_or(false) { s.prefs.nudge_big } else { s.prefs.nudge_small };
let dir = |k| num(p, k).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-100.0, 100.0) * step;
let (dx, dy) = (dir("dx"), dir("dy"));
let (uid, page, centre, half) = {
let ds = s.ds()?;
let z = ds.view.zoom.max(1e-6);
(ds.uid, ds.page, figcraft_geom::Point::new(ds.view.cx, ds.view.cy), (ds.view.vw / z / 2.0, ds.view.vh / z / 2.0))
};
// "It will appear in the middle of the viewport": the first arrow shows it there (a
// crosshair left on another page or document starts over).
let at = match s.crosshair() {
Some(c) => figcraft_geom::Point::new(c.x + dx, c.y + dy),
None => centre,
};
s.tool.place_at = Some(at);
s.tool.place_on = Some((uid, page));
if let Ok(ds) = s.ds_mut() {
// "Move the crosshair around the canvas": nudged past an edge of the view, the
// view pans by the overshoot to keep it in sight.
let v = &mut ds.view;
v.cx += (at.x - (v.cx + half.0)).max(0.0) + (at.x - (v.cx - half.0)).min(0.0);
v.cy += (at.y - (v.cy + half.1)).max(0.0) + (at.y - (v.cy - half.1)).min(0.0);
ds.revision += 1;
}
Ok(json!({"x": at.x, "y": at.y}))
}
),
spec!(
"tool.place",
"Place with the tool",
+1
View File
@@ -354,6 +354,7 @@ pub static GESTURES: &[(Group, &str, &str, &str)] = &[
(Tools, "Pan with nothing selected (Shift: farther)", "← ↑ → ↓", "← ↑ → ↓"),
(Transform, "Rotate a degree (Shift: 15)", "⌥⌘ ← ↑ → ↓", "Ctrl+Alt+← ↑ → ↓"),
(Tools, "Place a shape or frame (its tool chosen)", "↵", "Enter"),
(Tools, "Show and move the placement crosshair (its tool chosen, nothing selected)", "← ↑ → ↓", "← ↑ → ↓"),
(Zoom, "Zoom tool: click to zoom in, drag an area", "Hold Z", "Hold Z"),
(Zoom, "Zoom tool: zoom out", "Hold ⌥Z", "Hold Alt+Z"),
(Zoom, "Zoom around the pointer", "⌘ scroll", "Ctrl+scroll"),
+2
View File
@@ -469,6 +469,8 @@ mod penpot_export_tests;
#[cfg(test)]
mod pixel_preview_tests;
#[cfg(test)]
mod place_crosshair_tests;
#[cfg(test)]
mod placed_image_size_tests;
#[cfg(test)]
mod playground_tests;
+122
View File
@@ -0,0 +1,122 @@
//! The placement crosshair: the arrows with a shape or frame tool chosen show it in the middle of
//! the view and move it, and Enter places the object there ("Use the arrow keys to activate the
//! crosshair cursor. It will appear in the middle of the viewport indicating where the new object
//! will be placed." / "Use the arrow keys to move the crosshair around the canvas or board, then
//! press Return/Enter to place the object.", Accessibility at Figma, #1839).
use serde_json::json;
use crate::Session;
use crate::tools::{PointerEvent, PointerKind};
#[test]
fn the_arrows_show_and_move_the_crosshair_and_enter_places_there() {
let mut s = Session::new();
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
assert!(s.execute("tool.crosshair", &json!({"dx": 1})).is_err(), "a shape or frame tool first");
s.execute("tool.rectangle", &json!({})).unwrap();
// The first arrow shows the crosshair in the middle of the view, unmoved; the next move it by
// the nudge amounts.
let r = s.execute("tool.crosshair", &json!({"dx": 1, "dy": 0})).unwrap();
assert_eq!((r["x"].as_f64(), r["y"].as_f64()), (Some(300.0), Some(200.0)));
s.execute("tool.crosshair", &json!({"dx": 1, "dy": 0})).unwrap();
let r = s.execute("tool.crosshair", &json!({"dx": 0, "dy": -1, "big": true})).unwrap();
assert_eq!((r["x"].as_f64(), r["y"].as_f64()), (Some(301.0), Some(190.0)), "a nudge, then a big nudge");
assert_eq!(s.tool_info()["crosshair"], json!([301.0, 190.0]));
let v = &s.ds().unwrap().view;
assert_eq!((v.cx, v.cy), (300.0, 200.0), "the view hasn't panned");
// Enter: the rectangle is centred on the crosshair, which goes.
let placed = s.execute("tool.place", &json!({})).unwrap();
let id = crate::params::id_of(&placed["ids"][0]).unwrap();
let b = s.doc().unwrap().world_bounds(id).unwrap();
assert_eq!((b.center().x, b.center().y), (301.0, 190.0));
assert_eq!(s.tool.place_at, None);
assert!(s.tool_info()["crosshair"].is_null());
// Escape takes a shown crosshair away before anything else, the tool staying; a tool change
// takes it away too.
s.execute("select.set", &json!({"ids": []})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({})).unwrap();
assert!(s.tool.place_at.is_some());
s.escape().unwrap();
assert_eq!((s.tool.place_at, s.tool.tool), (None, crate::ToolId::Rectangle));
s.execute("tool.crosshair", &json!({})).unwrap();
s.execute("tool.ellipse", &json!({})).unwrap();
assert_eq!(s.tool.place_at, None);
}
#[test]
fn the_crosshair_keeps_its_page_and_goes_with_a_pointer_placement_but_not_the_same_tool_again() {
let mut s = Session::new();
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({"dx": 1})).unwrap();
s.execute("tool.crosshair", &json!({"dx": 1})).unwrap();
// The tool already chosen named again keeps the crosshair: `tool.place {tool}` lands on it.
let placed = s.execute("tool.place", &json!({"tool": "rectangle"})).unwrap();
let id = crate::params::id_of(&placed["ids"][0]).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(id).unwrap().center().x, 301.0);
// A click places too (the tool kept by the preference) and takes the crosshair with it.
s.prefs.keep_tool = true;
s.execute("select.set", &json!({"ids": []})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 600.0, 600.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 600.0, 600.0, 1.0)).unwrap();
assert_eq!((s.tool.tool, s.tool.place_at), (crate::ToolId::Rectangle, None), "placed by the pointer: the crosshair goes, the tool stays");
// Another page in front: the crosshair shown on the first isn't there, Escape has nothing to
// take, and the next arrow starts over in the middle of this page's view.
s.execute("select.set", &json!({"ids": []})).unwrap();
s.execute("tool.crosshair", &json!({"dx": 5})).unwrap();
s.execute("page.add", &json!({})).unwrap();
s.execute("page.select", &json!({"index": 1})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
assert_eq!(s.crosshair(), None);
assert!(s.tool_info()["crosshair"].is_null());
s.escape().unwrap();
assert_eq!(s.tool.tool, crate::ToolId::Move, "Escape went on to the tool");
s.execute("tool.rectangle", &json!({})).unwrap();
let r = s.execute("tool.crosshair", &json!({"dx": 1})).unwrap();
let v = &s.ds().unwrap().view;
assert_eq!((r["x"].as_f64(), r["y"].as_f64()), (Some(v.cx), Some(v.cy)), "shown afresh in the middle of this page's view");
}
#[test]
fn the_view_pans_to_keep_the_crosshair_in_sight() {
// "Use the arrow keys to move the crosshair around the canvas or board": nudged past an edge
// of the view, the view follows it by the overshoot; inside it, the view stays put.
let mut s = Session::new();
s.execute("view.set", &json!({"zoom": 10.0, "cx": 300, "cy": 200})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({})).unwrap();
for _ in 0..6 {
s.execute("tool.crosshair", &json!({"dx": 1, "big": true})).unwrap();
}
let v = s.ds().unwrap().view.clone();
assert_eq!((s.tool.place_at.map(|p| p.x), v.cx, v.cy), (Some(360.0), 300.0, 200.0), "at the edge of the 120 px wide view: no pan yet");
s.execute("tool.crosshair", &json!({"dx": 1, "big": true})).unwrap();
let v = s.ds().unwrap().view.clone();
assert_eq!((v.cx, v.cy), (310.0, 200.0), "ten past the edge: the view moved ten");
for _ in 0..5 {
s.execute("tool.crosshair", &json!({"dy": -1, "big": true})).unwrap();
}
let v = s.ds().unwrap().view.clone();
assert_eq!((v.cx, v.cy), (310.0, 190.0), "the view is 80 px tall: 40 up is its edge, 50 moves it ten");
}
#[test]
fn the_board_shapes_tool_places_on_the_crosshair_too() {
// "...move the crosshair around the canvas or board, then press Return/Enter to place the
// object": the board Shapes tool takes the crosshair like the shape tools, its shape centred
// on it (the tool centres its own 160 px shape on a press).
let mut s = crate::board_tests::board();
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
s.execute("tool.set", &json!({"tool": "shape"})).unwrap();
s.execute("tool.crosshair", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({"dx": 2})).unwrap();
let placed = s.execute("tool.place", &json!({})).unwrap();
let id = crate::params::id_of(&placed["ids"][0]).unwrap();
let b = s.doc().unwrap().world_bounds(id).unwrap();
assert_eq!((b.center().x, b.center().y, b.width(), b.height()), (302.0, 200.0, 160.0, 160.0));
assert_eq!(s.tool.place_at, None);
}
+2 -2
View File
@@ -403,10 +403,10 @@ fn the_schema_numbers_what_the_format_numbers() {
#[test]
fn schema_text_takes_numbers_and_refuses_bad_ones() {
let s = Schema::parse("enum E A=3 B C=9\nmessage M uint a=7, E b, string c=2").unwrap();
let s = Schema::parse("enum E A=3 B C=9\nmessage M uint a=7, E b, string c=5").unwrap();
let values = |i: usize| s.defs[i].fields.iter().map(|f| (f.name.as_str(), f.value)).collect::<Vec<_>>();
assert_eq!(values(0), [("A", 3), ("B", 1), ("C", 9)]);
assert_eq!(values(1), [("a", 7), ("b", 2), ("c", 2)]);
assert_eq!(values(1), [("a", 7), ("b", 2), ("c", 5)]);
for bad in ["enum E A=", "enum E A=x", "enum E A=-1", "message M uint a=1.5", "message M uint a=99999999999", "enum E =", "message M uint ="] {
assert!(Schema::parse(bad).is_err() || bad.ends_with('='), "{bad}");
}
+6
View File
@@ -276,6 +276,9 @@ impl Schema {
let ty = r.var_int()?;
let array = r.byte()? & 1 != 0;
let value = r.var_uint()?;
if kind == Kind::Message && fields.iter().any(|f: &Field| f.value == value) {
return err(format!("schema: duplicate field id {value} in {name}"));
}
raw_types.push(ty);
fields.push(Field { name: fname, ty: None, array, value });
}
@@ -645,6 +648,9 @@ impl Schema {
}
.ok_or_else(|| KiwiError(format!("schema text: unknown type {base}")))?;
let (name, value) = numbered(n.trim(), id)?;
if *kind == "message" && fields.iter().any(|f: &Field| f.value == value) {
return err(format!("schema text: duplicate field id {value} in {name}"));
}
fields.push(Field { name, ty: Some(ty), array: t.ends_with("[]"), value });
}
(if *kind == "struct" { Kind::Struct } else { Kind::Message }, fields)
+5
View File
@@ -562,6 +562,11 @@ fn oversized_grid_track_lists_warn_and_use_default_tracks() {
assert!(warnings.iter().any(|w| w.contains("grid track list exceeds 1000 entries")), "{warnings:?}");
}
#[test]
fn duplicate_explicit_field_ids_are_ambiguous() {
assert!(Schema::parse("message M uint a=1, uint b=1").is_err());
}
#[test]
fn kiwi_round_trip() {
let sc = schema();
+1
View File
@@ -1089,6 +1089,7 @@ name = "English"
"Pages: {pages}" = "Pages: {pages}"
"Paint bucket" = "Paint bucket"
"Pan the canvas" = "Pan the canvas"
"Show and move the placement crosshair (its tool chosen, nothing selected)" = "Show and move the placement crosshair (its tool chosen, nothing selected)"
"Pan with nothing selected (Shift: farther)" = "Pan with nothing selected (Shift: farther)"
"Panels" = "Panels"
"Paper" = "Paper"
+1
View File
@@ -2369,6 +2369,7 @@ status = "draft — needs native review"
"tool.arrow" = "Flecha"
"tool.comment" = "Comentario"
"tool.connector" = "Conector"
"tool.crosshair" = "Mover la cruz de colocación"
"tool.ellipse" = "Elipse"
"tool.eraser" = "Borrador"
"tool.frame" = "Marco"
+43
View File
@@ -1952,6 +1952,39 @@ status = "draft — needs native review"
"⌘ click" = "⌘ click"
"⌘ scroll" = "⌘ scroll"
"⌥ drag" = "⌥ drag"
"1 year" = "1 vuosi"
"30 days" = "30 päivää"
"90 days" = "90 päivää"
"Wrap style" = "Rivitystyyli"
"Balance" = "Tasattu"
"Pretty" = "Siisti"
"Auto: as many words as fit on each line" = "Automaattinen: jokaiselle riville niin monta sanaa kuin mahtuu"
"Balance: every line close to the same length" = "Tasattu: kaikki rivit lähes samanpituisia"
"Pretty: full lines, no one word alone on the last" = "Siisti: täydet rivit, viimeiselle riville ei jää yksittäistä sanaa"
"Set hug contents" = "Aseta Sovita sisältöön"
"Set fill container" = "Aseta Täytä säilö"
"Double-click an edge of an auto layout frame" = "Kaksoisnapsauta automaattisen asettelun kehyksen reunaa"
"⌥ double-click an edge of an auto layout child" = "⌥-kaksoisnapsauta automaattisen asettelun alikohteen reunaa"
"Alt+double-click an edge of an auto layout child" = "Alt+kaksoisnapsauta automaattisen asettelun alikohteen reunaa"
"Drop to use this image" = "Käytä tätä kuvaa pudottamalla"
"Expand the interface" = "Laajenna käyttöliittymä"
"Hide UI" = "Piilota käyttöliittymä"
"Past 30 days" = "Viimeiset 30 päivää"
"Past 90 days" = "Viimeiset 90 päivää"
"Past year" = "Viimeinen vuosi"
"Nothing counted yet" = "Mitään ei ole vielä laskettu"
"Rotate a degree (Shift: 15)" = "Kierrä asteen verran (Shift: 15)"
"⌥⌘ ← ↑ → ↓" = "⌥⌘ ← ↑ → ↓"
"Ctrl+Alt+← ↑ → ↓" = "Ctrl+Alt+← ↑ → ↓"
"Remove all auto layout" = "Poista kaikki automaattinen asettelu"
"This server doesn't count insertions" = "Tämä palvelin ei laske lisäyksiä"
"This server doesn't count styles and variables" = "Tämä palvelin ei laske tyylejä ja muuttujia"
"Upload from computer" = "Lataa tietokoneelta"
"The bars are hidden. {shortcut} shows them again" = "Palkit on piilotettu. {shortcut} näyttää ne uudelleen"
"{insertions} · {detachments}" = "{insertions} · {detachments}"
"{n} inserted" = { one = "{n} lisätty", other = "{n} lisätty" }
"{period}: {counts}" = "{period}: {counts}"
"{used} · {inserted}" = "{used} · {inserted}"
[commands]
"align.bottom" = "Tasaa alas"
@@ -2585,6 +2618,16 @@ status = "draft — needs native review"
"vector.fillRule" = "Aseta täyttösääntö"
"vector.reverseLoop" = "Käännä silmukka"
"widget.hover" = "Pienoisohjelman kohdistus"
"board.reshape" = "Muotoile yhdistin uudelleen"
"image.replace" = "Korvaa kuva"
"layout.removeAll" = "Poista kaikki automaattinen asettelu"
"pref.uiScaleDown" = "Pienempi käyttöliittymä"
"pref.uiScaleReset" = "Käyttöliittymän oletuskoko"
"pref.uiScaleUp" = "Suurempi käyttöliittymä"
"text.openLink" = "Avaa linkki"
"text.wrapStyle" = "Rivitystyyli"
"variable.extendCollection" = "Laajenna kokoelma"
"view.minimize" = "Pienennä käyttöliittymä"
[menu]
"bool.exclude" = "Jätä pois"
+71 -17
View File
@@ -48,25 +48,79 @@ pub fn single_line(ui: &mut Ui, id: Id, text: &mut String, font: FontId, width:
.response
}
/// One letter per script the UI's own fonts may lack: Hebrew, Arabic, Ethiopic, Devanagari,
/// Gurmukhi, Gujarati, Tamil, Telugu, Kannada, Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian
/// and Georgian. egui's shaper selects the joined contextual forms itself. Bengali and Thai are
/// not here: [`crate::cjk`] picks their faces, in its own order of preference, after this list.
pub(crate) const SCRIPT_SAMPLES: [char; 16] = [
'\u{05D0}', '\u{0628}', '\u{1200}', '\u{0915}', '\u{0A15}', '\u{0A95}', '\u{0B95}', '\u{0C15}', '\u{0C95}', '\u{0D15}', '\u{0D9A}', '\u{0E81}',
'\u{1000}', '\u{1780}', '\u{0531}', '\u{10D0}',
];
/// System families that draw those scripts, in order of preference: the first installed one that
/// has a script's letter is taken, so a family covering several scripts (Segoe UI, Nirmala UI,
/// Kohinoor, Noto Sans) joins once. Windows, macOS and the common Linux fonts for each script.
pub(crate) const SCRIPT_FAMILIES: [&str; 46] = [
// Hebrew and Arabic.
"Segoe UI",
"Arial",
"Tahoma",
"Noto Sans Hebrew",
"Noto Sans Arabic",
"Arial Hebrew",
"Geeza Pro",
"Lucida Grande",
"DejaVu Sans",
"FreeSans",
"Noto Sans",
// Ethiopic (#2014).
"Ebrima",
"Nyala",
"Kefa",
"Noto Sans Ethiopic",
"Abyssinica SIL",
// Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada, Malayalam and Sinhala.
"Nirmala UI",
"Kohinoor Devanagari",
"Kohinoor Gujarati",
"Kohinoor Telugu",
"Gurmukhi MN",
"Tamil Sangam MN",
"Kannada Sangam MN",
"Malayalam Sangam MN",
"Sinhala Sangam MN",
"Noto Sans Devanagari",
"Noto Sans Gurmukhi",
"Noto Sans Gujarati",
"Noto Sans Tamil",
"Noto Sans Telugu",
"Noto Sans Kannada",
"Noto Sans Malayalam",
"Noto Sans Sinhala",
"Lohit Devanagari",
// Lao, Myanmar and Khmer.
"Leelawadee UI",
"Lao Sangam MN",
"Myanmar Text",
"Myanmar Sangam MN",
"Khmer Sangam MN",
"Noto Sans Lao",
"Noto Sans Myanmar",
"Noto Sans Khmer",
// Armenian and Georgian.
"Sylfaen",
"Mshtakan",
"Noto Sans Armenian",
"Noto Sans Georgian",
];
/// Faces the UI adds for scripts its own fonts lack (desktop: from the system), as egui font data;
/// `taken` as for [`crate::theme::system_fallbacks`].
/// `taken` as for [`crate::theme::system_fallbacks`]. Hebrew and Arabic names were covered first;
/// Ethiopic and the other scripts in [`SCRIPT_SAMPLES`] drew as boxes in the panels (a String
/// variable's Amharic value, #2014) until the system face for each was taken too. Bengali and
/// Thai keep [`crate::cjk`]'s choice, made after this list.
pub fn script_fallbacks(have: &dyn Fn(char) -> bool, taken: &mut Vec<Arc<figcraft_text::Face>>) -> Vec<(String, egui::FontData)> {
// Hebrew and Arabic letters; egui's shaper selects the joined contextual forms itself.
const SAMPLES: [char; 2] = ['\u{05D0}', '\u{0628}'];
const FAMILIES: [&str; 11] = [
"Segoe UI",
"Arial",
"Tahoma",
"Noto Sans Hebrew",
"Noto Sans Arabic",
"Arial Hebrew",
"Geeza Pro",
"Lucida Grande",
"DejaVu Sans",
"FreeSans",
"Noto Sans",
];
crate::theme::system_fallbacks(SAMPLES, &FAMILIES, have, taken)
crate::theme::system_fallbacks(SCRIPT_SAMPLES, &SCRIPT_FAMILIES, have, taken)
}
#[cfg(test)]
+49
View File
@@ -310,3 +310,52 @@ fn clicks_place_the_logical_caret_by_what_is_shown() {
run(vec![Event::Text("א".into())], &mut text, &mut rect);
assert_eq!(text, "שלוםא");
}
/// Scripts beyond Hebrew and Arabic (#2014): a String variable's Amharic value drew as boxes
/// because the UI took system faces for two scripts only. Now every script in `SCRIPT_SAMPLES`
/// with an installed face gets one, and the UI's fonts then have its glyphs. A machine without a
/// face for a script skips that script (CI has few fonts); a face the UI's own fonts cover is not
/// taken twice.
#[test]
fn other_scripts_take_an_installed_system_face() {
figcraft_text::scan_system_fonts();
let installed = |c: char| figcraft_text::face_with_char(c, &crate::bidi::SCRIPT_FAMILIES).is_some();
let mut taken = Vec::new();
let added = crate::bidi::script_fallbacks(&|_| false, &mut taken);
let scripts = [('\u{1200}', "Ethiopic"), ('\u{0915}', "Devanagari"), ('\u{0B95}', "Tamil"), ('\u{1000}', "Myanmar"), ('\u{0531}', "Armenian")];
let mut checked = 0;
for (c, script) in scripts {
if !installed(c) {
eprintln!("no {script} face on this system; skipping");
continue;
}
let families: Vec<&str> = taken.iter().map(|f| f.family.as_str()).collect();
assert!(taken.iter().any(|f| f.has_char(c)), "a face for {script} is taken: {families:?}");
checked += 1;
}
// A face that several scripts share is added once, under its system key.
let keys: Vec<&str> = added.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(keys.len(), taken.len(), "{keys:?}");
assert!(keys.iter().all(|k| k.starts_with("system-")), "{keys:?}");
let mut seen = keys.clone();
seen.sort_unstable();
seen.dedup();
assert_eq!(seen.len(), keys.len(), "no face twice: {keys:?}");
// Nothing is taken for a script the UI's own fonts draw.
assert!(crate::bidi::script_fallbacks(&|_| true, &mut Vec::new()).is_empty());
if checked == 0 {
eprintln!("no face for any of the sampled scripts on this system; the UI font check was not run");
return;
}
// Through the whole font setup: the UI draws the letters of every script it found a face for.
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx, true, 0);
ctx.run_ui(RawInput::default(), |_| {}).textures_delta.clear();
ctx.fonts_mut(|f| {
for (c, script) in scripts {
if installed(c) {
assert!(f.has_glyph(&regular(11.0), c), "the UI fonts draw {script} ({c:?})");
}
}
});
}
+37 -17
View File
@@ -25,6 +25,14 @@ impl HoverKey {
}
}
/// Whether a button that pans the canvas is held: the middle button, or a mouse's side buttons
/// (4 and 5), which the incumbent treats the same way (#1758). Space + primary pans too, handled
/// with the Space key.
fn pan_button_down(i: &egui::InputState) -> bool {
use egui::PointerButton::{Extra1, Extra2, Middle};
[Middle, Extra1, Extra2].into_iter().any(|b| i.pointer.button_down(b))
}
/// The pointer left the canvas (or something over it took the pointer): nothing is hovered there.
fn left(app: &mut FigApp) {
app.canvas.last_hover = None;
@@ -140,6 +148,31 @@ fn import_dropped(app: &mut FigApp, ctx: &egui::Context, hover_pos: Option<egui:
}
}
/// One frame of a pan (the middle or a side button held, or Space + drag): the view follows the
/// pointer from where the pan began, and the pan ends once no button that pans is held.
fn pan_view(app: &mut FigApp, ctx: &egui::Context, pos: Pos2, mods: &egui::Modifiers, middle_down: bool) {
if let Ok(ds) = app.session.ds_mut() {
match app.canvas.middle_pan {
None => app.canvas.middle_pan = Some((pos, ds.view.cx, ds.view.cy)),
Some((p0, cx, cy)) => {
let z = ds.view.zoom.max(1e-6);
ds.view.cx = cx - f64::from(pos.x - p0.x) / z;
ds.view.cy = cy - f64::from(pos.y - p0.y) / z;
ds.revision += 1;
}
}
}
// A primary drag under way when the pan began ends when its button is let go, pan or
// not: the engine gets its release here, as it does over the canvas or off it.
if app.canvas.dragging && !ctx.input(|i| i.pointer.primary_down()) {
app.canvas.dragging = false;
send(app, PointerKind::Up, pos, mods);
}
if !middle_down && !ctx.input(|i| i.pointer.primary_down()) {
app.canvas.middle_pan = None;
}
}
/// The pointer and keys on the canvas. False when something on it with a cursor of its own has
/// the pointer (a comment pin, a ruler guide, a gradient or padding handle): the tool's cursor
/// then doesn't show.
@@ -180,7 +213,7 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo
left(app);
}
// A pan whose button was released off the canvas is over.
if !ctx.input(|i| i.pointer.button_down(egui::PointerButton::Middle) || i.pointer.primary_down()) {
if !ctx.input(|i| pan_button_down(i) || i.pointer.primary_down()) {
app.canvas.middle_pan = None;
}
// So is a handle drag.
@@ -213,23 +246,10 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo
if zoom_tool(app, ui, resp, pos, mods, editing_text || typing_elsewhere) {
return true;
}
// Middle-button / space panning.
let middle_down = ctx.input(|i| i.pointer.button_down(egui::PointerButton::Middle));
// Middle-button (or side-button) / space panning.
let middle_down = ctx.input(pan_button_down);
if (middle_down || (app.canvas.space_pan && ctx.input(|i| i.pointer.primary_down()))) && resp.hovered() || app.canvas.middle_pan.is_some() {
if let Ok(ds) = app.session.ds_mut() {
match app.canvas.middle_pan {
None => app.canvas.middle_pan = Some((pos, ds.view.cx, ds.view.cy)),
Some((p0, cx, cy)) => {
let z = ds.view.zoom.max(1e-6);
ds.view.cx = cx - f64::from(pos.x - p0.x) / z;
ds.view.cy = cy - f64::from(pos.y - p0.y) / z;
ds.revision += 1;
}
}
}
if !middle_down && !ctx.input(|i| i.pointer.primary_down()) {
app.canvas.middle_pan = None;
}
pan_view(app, &ctx, pos, &mods, middle_down);
return true;
}
// A handle drag whose button went up ends first, wherever on the canvas the pointer is: over a
+13
View File
@@ -145,6 +145,7 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
}
// Pen path.
pen_overlay(app, painter, zoom);
crosshair_overlay(app, painter);
// Eraser brush (vector edit mode).
if let Some([x, y, r]) = ov.brush {
let c = app.to_screen(x, y);
@@ -416,6 +417,18 @@ fn attach_points(app: &FigApp, painter: &egui::Painter, points: &[([f64; 2], boo
}
}
/// The placement crosshair the arrows showed and moved: where Enter puts the next shape or frame.
fn crosshair_overlay(app: &FigApp, painter: &egui::Painter) {
let Some(p) = app.session.crosshair().filter(|_| app.session.tool.tool.draws_shape()) else { return };
let c = app.to_screen(p.x, p.y);
let col = accent(painter);
for d in [vec2(1.0, 0.0), vec2(0.0, 1.0)] {
painter.line_segment([c - d * 12.0, c - d * 3.0], stroke(col, 1.5));
painter.line_segment([c + d * 3.0, c + d * 12.0], stroke(col, 1.5));
}
painter.circle_stroke(c, 3.0, stroke(col, 1.5));
}
fn pen_overlay(app: &FigApp, painter: &egui::Painter, zoom: f64) {
let ov = &app.session.tool.overlay;
if !ov.pen.is_empty() {
+6
View File
@@ -1030,6 +1030,12 @@ fn ui_key(app: &mut FigApp, ctx: &Context, p: &Value) -> Result<Value, String> {
"ArrowUp" => (0, -1),
_ => (0, 1),
};
// A shape or frame tool chosen and nothing selected: the arrows show and move the
// placement crosshair, as the window's key path does.
if app.session.selection().is_empty() && app.session.tool.tool.draws_shape() && !app.session.tool.dev_mode && !(m.cmd || m.ctrl || m.alt)
{
return app.session.execute("tool.crosshair", &json!({"dx": dx, "dy": dy, "big": m.shift})).map_err(|e| e.to_string());
}
let (cmd, params) = if (m.cmd || m.ctrl) && m.alt {
// Rotate a degree, 15 with Shift: down and right clockwise, up and left back, about
// the selection's rotation origin; nothing while points are being edited.
@@ -411,6 +411,81 @@ fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree()
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
}
/// A press of `key` in the frame that's next, then a few frames to settle.
fn press_page_field_key(u: &mut Ui, key: egui::Key) {
u.frame(vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
for _ in 0..5 {
u.frame(vec![]);
}
}
/// Asserts a click on the name of page `page`, away from page `other`, opens it and starts no
/// rename: the pages list is back to plain rows however the last rename ended.
fn assert_clicking_page_opens_it(u: &mut Ui, page: NodeId, other: NodeId, row: Pos2) {
assert!(u.app.ui.rename_page.is_none(), "the rename is over");
u.app.session.execute("page.select", &json!({"id": other})).unwrap();
for _ in 0..30 {
u.frame(vec![Event::PointerMoved(row)]);
}
u.button(row, true);
u.button(row, false);
for _ in 0..5 {
u.frame(vec![]);
}
assert_eq!(u.app.session.page().unwrap(), page, "the click opens the page");
assert!(!u.ctx.memory(|m| m.has_focus(Id::unique(("pagename", page)).with("te"))), "and doesn't edit its name");
}
/// Asserts a page's row is a plain row after a rename that was cancelled, committed unchanged or
/// committed with a new name: a click on its name opens the page rather than editing the name.
#[test]
fn a_finished_page_rename_leaves_a_row_that_a_click_opens() {
for (key, text) in [(egui::Key::Escape, None), (egui::Key::Enter, None), (egui::Key::Enter, Some("Renamed"))] {
let mut s = Session::new();
let p1 = s.page().unwrap();
let p2 = id(s.execute("page.add", &json!({})).unwrap());
let mut u = Ui::new(s);
let r2 = page_row(&mut u, p2, p1);
u.ctx.enable_accesskit();
double_click_checking_focus(&mut u, r2);
assert_eq!(u.app.ui.rename_page.as_ref().map(|r| r.0), Some(p2), "a double click renames the page");
if let Some(text) = text {
u.frame(vec![Event::Text(text.into())]);
}
press_page_field_key(&mut u, key);
assert_clicking_page_opens_it(&mut u, p2, p1, r2);
}
}
/// Asserts the row's Rename page puts the keyboard in the name, and a name typed there is kept.
#[test]
fn the_page_menus_rename_starts_with_the_keyboard_in_the_name() {
let mut s = Session::new();
let p1 = s.page().unwrap();
let p2 = id(s.execute("page.add", &json!({})).unwrap());
let mut u = Ui::new(s);
let r2 = page_row(&mut u, p2, p1);
u.frame(vec![
Event::PointerMoved(r2),
Event::PointerButton { pos: r2, button: egui::PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE },
]);
u.frame(vec![Event::PointerButton { pos: r2, button: egui::PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }]);
let shapes: Vec<egui::Shape> = u.frame_output(vec![]).shapes.into_iter().map(|c| c.shape).collect();
let item = crate::live_tests::text_center(&shapes, "Rename page");
u.frame(vec![Event::PointerMoved(item)]);
u.button(item, true);
u.button(item, false);
for _ in 0..3 {
u.frame(vec![]);
}
assert_eq!(u.app.ui.rename_page.as_ref().map(|r| r.0), Some(p2), "the menu renames the page");
assert!(u.ctx.memory(|m| m.has_focus(Id::unique(("pagename", p2)).with("te"))), "with the keyboard in its name");
u.frame(vec![Event::Text("Renamed".into())]);
press_page_field_key(&mut u, egui::Key::Enter);
assert_eq!(u.app.session.doc().unwrap().node(p2).map(|n| n.name.clone()).as_deref(), Some("Renamed"));
assert_clicking_page_opens_it(&mut u, p2, p1, r2);
}
/// The y of the line between the pages and the layers, above the layers' row at `layer` (found by
/// the resize cursor it shows when hovered).
fn pages_edge_at(u: &mut Ui, layer: f32) -> f32 {
+7 -1
View File
@@ -293,8 +293,13 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0));
let start = ui.data_mut(|d| d.remove_temp::<bool>(page_rename_start_id())).unwrap_or(false);
let font = if id == cur { semibold(11.0) } else { regular(11.0) };
if let (Some(new), _) = crate::widgets::rename_field(ui, Id::unique(("pagename", id)), &name, tr, font, start) {
let (out, open) = crate::widgets::rename_field(ui, Id::unique(("pagename", id)), &name, tr, font, start);
if let Some(new) = out {
rename_done = Some((id, new));
} else if !open {
// Cancelled, or committed unchanged: the row goes back to its name, or the idle
// field would catch the next click and a click wouldn't open this page.
app.ui.rename_page = None;
}
} else if !divider {
let font = if id == cur { semibold(11.0) } else { regular(11.0) };
@@ -315,6 +320,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
if ui.button(tr("Rename page")).clicked() {
if !crate::menus::dev_mode_refuses(app, "page.rename") {
app.ui.rename_page = Some((id, name.clone()));
ui.data_mut(|d| d.insert_temp(page_rename_start_id(), true));
}
ui.close();
}
+22
View File
@@ -54,6 +54,28 @@ fn arrows_pan_the_view_when_nothing_is_selected() {
assert_eq!(u.app.session.doc().unwrap().world_bounds(r).unwrap().x0, 1.0);
}
/// With a shape or frame tool chosen and nothing selected, the arrows show the placement crosshair
/// in the middle of the view and move it (Shift: the large nudge) instead of panning, and Enter
/// places the object there (Accessibility at Figma, #1839).
#[test]
fn arrows_show_and_move_the_placement_crosshair_with_a_shape_tool() {
let mut u = Ui::new(Session::new());
u.app.run("select.set", json!({"ids": []}));
u.app.run("view.set", json!({"zoom": 2.0, "cx": 100, "cy": 50}));
u.app.run("tool.rectangle", json!({}));
press(&mut u, Key::ArrowRight, Modifiers::NONE);
assert_eq!(view(&u), (100.0, 50.0, 2.0), "no pan: the crosshair shows instead");
assert_eq!(u.app.session.tool.place_at.map(|p| (p.x, p.y)), Some((100.0, 50.0)), "in the middle of the view");
press(&mut u, Key::ArrowDown, Modifiers::SHIFT);
assert_eq!(u.app.session.tool.place_at.map(|p| (p.x, p.y)), Some((100.0, 60.0)), "moved by the large nudge");
press(&mut u, Key::Enter, Modifiers::NONE);
let sel = u.app.session.selection();
let b = u.app.session.doc().unwrap().world_bounds(sel[0]).unwrap();
assert_eq!((b.center().x, b.center().y), (100.0, 60.0), "placed at the crosshair");
assert!(u.app.session.tool.place_at.is_none(), "which goes with the placement");
assert_eq!(view(&u), (100.0, 50.0, 2.0));
}
#[test]
fn keyboard_box_selection_key_and_cmd_arrows() {
let mut s = Session::new();
+5 -1
View File
@@ -480,7 +480,11 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, re
_ => (0.0, 0.0),
};
if (dx, dy) != (0.0, 0.0) {
if app.session.selection().is_empty() {
if app.session.selection().is_empty() && app.session.tool.tool.draws_shape() && !app.session.tool.dev_mode && !m.command && !m.alt {
// A shape or frame tool chosen: the arrows show the placement crosshair in the middle
// of the view and move it; Enter places there (Accessibility at Figma).
app.run("tool.crosshair", json!({"dx": dx, "dy": dy, "big": m.shift}));
} else if app.session.selection().is_empty() {
app.run("view.pan", json!({"dx": dx, "dy": dy, "big": m.shift}));
} else if crate::menus::dev_mode_refuses(app, "transform.nudge") {
// Dev Mode: said so, nothing moves.
+2 -1
View File
@@ -271,7 +271,8 @@ pub fn on_dark<R>(ui: &mut egui::Ui, f: impl FnOnce(&mut egui::Ui) -> R) -> R {
/// UI fonts: Inter and the Archivo wordmark (from the craft-fonts build input) when present;
/// otherwise egui's defaults. With
/// `system`, also system faces for scripts those lack (Hebrew, joined Arabic) so names in them show,
/// `system`, also system faces for scripts those lack (Hebrew, joined Arabic, Ethiopic, the Indic
/// scripts and the others [`crate::bidi::script_fallbacks`] names) so names in them show,
/// and faces for the CJK scripts in `cjk` ([`crate::cjk`]).
///
/// The craft-fonts set embeds Noto Sans Arabic and Noto Sans Hebrew; with `system` off those
+41 -3
View File
@@ -1,5 +1,6 @@
//! A canvas pan (middle button or Space + primary) ends when its button is released, even when the
//! release happens over a panel: returning to the canvas with no button held must not move the view.
//! A canvas pan (middle button, a side button, or Space + primary) ends when its button is
//! released, even when the release happens over a panel: returning to the canvas with no button held
//! must not move the view.
use egui::{Event, Key, PointerButton, Pos2, RawInput, Rect, vec2};
use figcraft_engine::Session;
@@ -45,6 +46,12 @@ fn space(pressed: bool) -> Event {
}
fn check(space_pan: bool, outside: bool) {
check_button(if space_pan { PointerButton::Primary } else { PointerButton::Middle }, space_pan, outside);
}
/// A pan with button `b` (with Space held when `space_pan`) moves the view, ends when `b` is
/// released (`outside` the canvas or over it), and a new one moves it again.
fn check_button(b: PointerButton, space_pan: bool, outside: bool) {
let mut u = Frames::new();
let rect = u.app.canvas.rect;
let start = Pos2::new(rect.min.x + 200.0, rect.center().y);
@@ -55,7 +62,6 @@ fn check(space_pan: bool, outside: bool) {
if space_pan {
u.frame(vec![space(true)]);
}
let b = if space_pan { PointerButton::Primary } else { PointerButton::Middle };
let before = u.cx();
u.frame(vec![button(start, b, true)]);
u.frame(vec![Event::PointerMoved(dragged)]);
@@ -93,3 +99,35 @@ fn space_pan_released_over_panel_does_not_move_view_on_return() {
fn middle_pan_released_inside_canvas_keeps_view() {
check(false, false);
}
/// #1758: a mouse's side buttons (4 and 5) pan the canvas as the middle button does, and the pan
/// ends on release, over the canvas or off it.
#[test]
fn side_buttons_pan_like_the_middle_button() {
for b in [PointerButton::Extra1, PointerButton::Extra2] {
check_button(b, false, false);
check_button(b, false, true);
}
}
/// A primary drag under way when a pan starts (a side or the middle button pressed mid-drag) ends
/// when the primary button is let go during the pan: the engine gets the release, so its gesture
/// isn't left open once the pan is over.
#[test]
fn a_primary_drag_released_during_a_pan_ends() {
for pan in [PointerButton::Middle, PointerButton::Extra1] {
let mut u = Frames::new();
let rect = u.app.canvas.rect;
let start = Pos2::new(rect.min.x + 200.0, rect.center().y);
let moved = Pos2::new(rect.min.x + 400.0, rect.center().y);
u.frame(vec![Event::PointerMoved(start)]);
u.frame(vec![button(start, PointerButton::Primary, true)]);
u.frame(vec![Event::PointerMoved(moved)]);
assert!(u.app.session.tool.busy(), "{pan:?}: a primary drag is under way");
u.frame(vec![button(moved, pan, true)]);
u.frame(vec![button(moved, PointerButton::Primary, false)]);
u.frame(vec![button(moved, pan, false)]);
u.frame(vec![]);
assert!(!u.app.session.tool.busy(), "{pan:?}: the primary release reached the engine");
}
}
+9 -4
View File
@@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (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 (; 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
@@ -60,8 +60,12 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
layers; a paragraph's base direction forced with Text ▸ Text direction (#1830); right-to-left
list markers and indents; right-to-left names in the layers panel, tabs
and canvas labels (visual order, joined Arabic); inline IME composition with the candidate window
at the caret; system-font fallback for scripts the UI fonts lack. Devanagari shapes through
harfrust. No vertical text (Figma has none either).
at the caret; system-font fallback for scripts the UI fonts lack: Hebrew and Arabic, Bengali
and Thai (with the CJK faces), and since #2014 Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and
Georgian, so a layer name or a String variable's value in them
draws in the panels when the system has a face (`crates/ui-egui/src/bidi.rs`, `SCRIPT_SAMPLES`
and `SCRIPT_FAMILIES`). Devanagari shapes through 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
@@ -91,7 +95,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
| English | en | 2,352 / 2,352 (100%, source) | yes | Latin | yes (source) | full | 0 |
| 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 |
| 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) |
| 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) |
@@ -126,6 +130,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see
| Date | Change | Summary |
|---|---|---|
| 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-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 |
+5 -3
View File
File diff suppressed because one or more lines are too long