mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-07 12:13:14 +00:00
Merge main into #1772
This commit is contained in:
+6
-4
@@ -101,6 +101,7 @@ native speakers' reviews of the nine language drafts need people.
|
||||
|---|---|---|---|
|
||||
| English | en | 100% | full |
|
||||
| Simplified Chinese | zh-Hans | 95% | draft (needs native review) |
|
||||
| Traditional Chinese (Taiwan) | zh-Hant | 2,647 / 2,647 entries (100%, counted 2026-11-03) | draft (needs native review) |
|
||||
| Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) |
|
||||
| Hindi | hi | 0% | none |
|
||||
| Arabic | ar | 2,508 / 2,508 entries (100%, counted 2026-10-28) | draft; the window mirrors (panels swap sides); native review and mirrored panel rows pending |
|
||||
@@ -114,9 +115,9 @@ native speakers' reviews of the nine language drafts need people.
|
||||
|
||||
Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%), Czech (`cs`, 99%, draft) and Bosnian (`bs`, 100% on 2026-10-30, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
|
||||
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
|
||||
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese and Arabic drafts (Preferences ▸
|
||||
Language; Traditional Chinese has no catalog; CJK UI
|
||||
text uses an installed CJK font, loaded only when needed; adding a language:
|
||||
French, German, Brazilian Portuguese, Japanese, Korean, Simplified and Traditional Chinese, Indonesian, Vietnamese and Arabic drafts (Preferences ▸
|
||||
Language; Traditional Chinese reads zh-TW, zh-HK, zh-MO and zh-Hant, while an explicit Hans stays
|
||||
Simplified; CJK UI text uses an installed CJK font, loaded only when needed; adding a language:
|
||||
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
|
||||
IME) already works (macOS IME composition fixed for #747, not yet checked on a Mac). Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
|
||||
|
||||
@@ -127,7 +128,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h.
|
||||
2. Real-file corpus and measured fidelity: 15–30 h.
|
||||
3. Tool-by-tool interaction pass against Figma: 40–60 h.
|
||||
4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi and Vietnamese; Arabic native review and panel mirroring ([localization-parity](docs/localization-parity.md)).
|
||||
4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, zh-Hant, id and cs drafts (people), the entries added since each catalog was written, then Hindi and Vietnamese; Arabic native review and panel mirroring ([localization-parity](docs/localization-parity.md)).
|
||||
5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h.
|
||||
|
||||
## Progress log
|
||||
@@ -363,6 +364,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`, 繁體中文(台灣)) draft catalog: every UI string (1,975), command label (641) and menu wording (31), translated clean-room from the English source; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant` read it while an explicit Hans stays Simplified; the desktop UI prefers an installed Noto Sans CJK TC, then Microsoft JhengHei, PingFang TC and Heiti TC; no font is bundled; native review pending. Numbers unchanged |
|
||||
| 2026-11-06 | patch | M1, #2028: Edit ▸ Set default properties gives new rectangles, ellipses, polygons and stars drawn with the tools (and, set from a text layer, new text) the selected layer's properties; it only copied them for Paste properties. Tests: `default_props_tests` (3). Numbers unchanged |
|
||||
| 2026-11-06 | patch | M5, #2027: `proto.settings` refuses an unknown device name with the list of devices (it saved any string, and the presenter showed no device), takes known names in any case, and answers the page's prototype settings, so `proto.settings {}` reads them back over MCP. Test: `proto_present_tests::unknown_devices_are_refused_and_settings_read_back`. Numbers unchanged |
|
||||
| 2026-11-06 | patch | M7, #2032: closing a tab whose file has unsaved changes asks Don't Save / Cancel / Save, as closing the window does, instead of a toast that left the tab open; Alt+close still discards at once. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Numbers unchanged |
|
||||
|
||||
+47
-1
@@ -1,6 +1,6 @@
|
||||
//! Node property types: strokes, effects, corners, constraints, auto layout, grids, exports.
|
||||
|
||||
use figcraft_color::{BlendMode, Color};
|
||||
use figcraft_color::{BlendMode, Color, ColorProfile};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Where a stroke sits relative to the shape edge.
|
||||
@@ -1535,11 +1535,56 @@ pub struct ExportSetting {
|
||||
/// Android Vector Drawable: what to do with what a drawable can't hold exactly.
|
||||
#[serde(skip_serializing_if = "VdCompat::is_default")]
|
||||
pub vd_compat: VdCompat,
|
||||
/// PNG, JPG and WebP: the color profile the file is written in. SVG, PDF and XML are always
|
||||
/// sRGB, whatever this says.
|
||||
#[serde(skip_serializing_if = "ExportColorProfile::is_default")]
|
||||
pub color_profile: ExportColorProfile,
|
||||
/// JPG: the encoder's quality, 1 (smallest file) to 100 (see [`ExportSetting::quality`]).
|
||||
#[serde(skip_serializing_if = "is_default_quality")]
|
||||
pub quality: u8,
|
||||
}
|
||||
|
||||
/// The color profile an export setting writes PNG, JPG and WebP files in.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ExportColorProfile {
|
||||
/// The document's own profile at the time of the export.
|
||||
#[default]
|
||||
SameAsFile,
|
||||
Srgb,
|
||||
DisplayP3,
|
||||
}
|
||||
|
||||
impl ExportColorProfile {
|
||||
pub const ALL: [ExportColorProfile; 3] = [ExportColorProfile::SameAsFile, ExportColorProfile::Srgb, ExportColorProfile::DisplayP3];
|
||||
|
||||
fn is_default(&self) -> bool {
|
||||
*self == ExportColorProfile::SameAsFile
|
||||
}
|
||||
|
||||
/// The name commands and files use ("sameAsFile", "srgb", "displayP3").
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
ExportColorProfile::SameAsFile => "sameAsFile",
|
||||
ExportColorProfile::Srgb => "srgb",
|
||||
ExportColorProfile::DisplayP3 => "displayP3",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_key(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|c| c.key() == s)
|
||||
}
|
||||
|
||||
/// The profile the file is written in when the document is in `file`.
|
||||
pub fn resolve(self, file: ColorProfile) -> ColorProfile {
|
||||
match self {
|
||||
ExportColorProfile::SameAsFile => file,
|
||||
ExportColorProfile::Srgb => ColorProfile::Srgb,
|
||||
ExportColorProfile::DisplayP3 => ColorProfile::DisplayP3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How an Android Vector Drawable export handles what the format can't draw exactly (layer
|
||||
/// opacity over overlapping shapes, even-odd fills, diamond gradients, alpha masks).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
@@ -1588,6 +1633,7 @@ impl Default for ExportSetting {
|
||||
svg_id_attribute: false,
|
||||
svg_simplify_stroke: true,
|
||||
vd_compat: VdCompat::BestEffort,
|
||||
color_profile: ExportColorProfile::SameAsFile,
|
||||
quality: ExportSetting::DEFAULT_QUALITY,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,7 +448,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"export.add",
|
||||
"Add export setting",
|
||||
"{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp|xml (Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), quality? (JPG: 1-100, default 90)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)",
|
||||
"{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp|xml (Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), colorProfile? (PNG, JPG and WebP: sameAsFile|srgb|displayP3), quality? (JPG: 1-100, default 90)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)",
|
||||
true,
|
||||
|s, p| {
|
||||
let cmd = "export.add";
|
||||
@@ -466,7 +466,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"export.update",
|
||||
"Edit export setting",
|
||||
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), quality? (JPG: 1-100), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}",
|
||||
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), colorProfile? (PNG, JPG and WebP: sameAsFile (the document's profile when it exports)|srgb|displayP3; kept when the format changes), quality? (JPG: 1-100), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}",
|
||||
true,
|
||||
|s, p| {
|
||||
let cmd = "export.update";
|
||||
|
||||
@@ -4,8 +4,8 @@ use std::sync::Arc;
|
||||
|
||||
use base64::Engine as _;
|
||||
use figcraft_doc::{
|
||||
BOARD_PAGE, Document, ExportFormat, ExportSetting, ExportSize, FileKind, LineHeight, Node, NodeId, NodeKind, PrintSettings, StyleKind, TextStyle,
|
||||
VdCompat,
|
||||
BOARD_PAGE, Document, ExportColorProfile, ExportFormat, ExportSetting, ExportSize, FileKind, LineHeight, Node, NodeId, NodeKind, PrintSettings,
|
||||
StyleKind, TextStyle, VdCompat,
|
||||
};
|
||||
use figcraft_format::Version;
|
||||
use serde_json::{Value, json};
|
||||
@@ -120,14 +120,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.export",
|
||||
"Export",
|
||||
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x archive, with warnings for unsupported content), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings)), quality? (JPG: 1-100, default 90)}",
|
||||
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x archive, with warnings for unsupported content), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings)), colorProfile? (PNG, JPG and WebP: sameAsFile (default: the document's profile)|srgb|displayP3; colors convert, out-of-gamut ones clip; SVG, PDF and XML are always sRGB), quality? (JPG: 1-100, default 90)}",
|
||||
false,
|
||||
export
|
||||
),
|
||||
spec!(
|
||||
"file.exportLayers",
|
||||
"Export layers",
|
||||
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own, vdCompat? (bestEffort|exact|ignore: for every XML instead of each setting's own)} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data, warnings (XML only)}], skipped (layers without settings), data? (zip)}",
|
||||
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own, vdCompat? (bestEffort|exact|ignore: for every XML instead of each setting's own), colorProfile? (sameAsFile|srgb|displayP3: for every PNG, JPG and WebP instead of each setting's own)} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data, warnings (XML only)}], skipped (layers without settings), data? (zip)}",
|
||||
false,
|
||||
export_layers
|
||||
),
|
||||
@@ -408,14 +408,38 @@ pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scal
|
||||
Ok((pdf_bytes(doc, &groups, &setting.print, (!own_area).then_some(area))?, Vec::new()))
|
||||
}
|
||||
_ => {
|
||||
let pix = figcraft_render::render_area(doc, &drawn, area, scale)
|
||||
let mut pix = figcraft_render::render_area(doc, &drawn, area, scale)
|
||||
.ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?;
|
||||
let bytes = figcraft_render::encode_export_with(&pix, setting.format, doc.color_profile, setting.quality());
|
||||
// The render is in the document's profile; the setting's profile converts the pixels
|
||||
// (out-of-gamut colors clip) and tags the file.
|
||||
let profile = setting.color_profile.resolve(doc.color_profile);
|
||||
let conversion = doc.color_profile.conversion_to(profile);
|
||||
// JPG has no alpha: the render is flattened over white in the document's profile
|
||||
// before the conversion, so translucent pixels match what the document shows.
|
||||
if setting.format == ExportFormat::Jpg && !conversion.is_identity() {
|
||||
for [r, g, b, a] in pix.data_mut().as_chunks_mut::<4>().0.iter_mut() {
|
||||
let under = 255 - *a;
|
||||
*r = r.saturating_add(under);
|
||||
*g = g.saturating_add(under);
|
||||
*b = b.saturating_add(under);
|
||||
*a = 255;
|
||||
}
|
||||
}
|
||||
conversion.apply_pixels(pix.data_mut());
|
||||
let bytes = figcraft_render::encode_export_with(&pix, setting.format, profile, setting.quality());
|
||||
Ok((bytes.map_err(EngineError::Other)?, Vec::new()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `colorProfile` `p` passes: sameAsFile, srgb or displayP3 (anything else is an error).
|
||||
fn color_profile_param(cmd: &str, p: &Value) -> Result<Option<ExportColorProfile>> {
|
||||
match get(p, "colorProfile") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_str().and_then(ExportColorProfile::from_key).map(Some).ok_or_else(|| bad(cmd, "colorProfile: sameAsFile|srgb|displayP3")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `vdCompat` `p` passes: bestEffort, exact or ignore (anything else is an error).
|
||||
fn vd_compat_param(cmd: &str, p: &Value) -> Result<Option<VdCompat>> {
|
||||
match get(p, "vdCompat") {
|
||||
|
||||
@@ -175,6 +175,9 @@ pub(in crate::cmd) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value)
|
||||
if let Some(c) = vd_compat_param(cmd, p)? {
|
||||
e.vd_compat = c;
|
||||
}
|
||||
if let Some(c) = color_profile_param(cmd, p)? {
|
||||
e.color_profile = c;
|
||||
}
|
||||
if let Some(q) = quality_param(cmd, p)? {
|
||||
e.quality = q;
|
||||
}
|
||||
@@ -342,10 +345,16 @@ pub(super) fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?;
|
||||
// And a compatibility mode for every Android Vector Drawable.
|
||||
let compat = vd_compat_param(cmd, p)?;
|
||||
// And a color profile for every PNG, JPG and WebP.
|
||||
let profile = color_profile_param(cmd, p)?;
|
||||
let mut files = Vec::with_capacity(jobs.len());
|
||||
for job in &jobs {
|
||||
let setting =
|
||||
ExportSetting { print: print.unwrap_or(job.setting.print), vd_compat: compat.unwrap_or(job.setting.vd_compat), ..job.setting.clone() };
|
||||
let setting = ExportSetting {
|
||||
print: print.unwrap_or(job.setting.print),
|
||||
vd_compat: compat.unwrap_or(job.setting.vd_compat),
|
||||
color_profile: profile.unwrap_or(job.setting.color_profile),
|
||||
..job.setting.clone()
|
||||
};
|
||||
let scale = export_scale(doc, &[job.id], &setting);
|
||||
let (bytes, warnings) = export_file(doc, &[job.id], &setting, scale)?;
|
||||
let mut f = json!({"name": job.file, "id": job.id, "bytes": bytes.len()});
|
||||
|
||||
@@ -8,10 +8,12 @@ use crate::params::{boolean, get, num, num_or, req_index, req_num, req_string, s
|
||||
use crate::tools::ToolId;
|
||||
use crate::{EngineError, Result, Session, ViewState, bad, spec};
|
||||
|
||||
/// The active document's view state, for changing it.
|
||||
fn view(s: &mut Session) -> Result<&mut ViewState> {
|
||||
Ok(&mut s.ds_mut()?.view)
|
||||
}
|
||||
|
||||
/// Set one of a view's on/off settings from `on`, or toggle it when `on` is absent.
|
||||
fn flag(s: &mut Session, p: &Value, f: fn(&mut ViewState) -> &mut bool) -> Result<Value> {
|
||||
let v = view(s)?;
|
||||
let slot = f(v);
|
||||
@@ -90,6 +92,7 @@ fn pixel_preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"on": v.pixel_preview, "scale": v.pixel_scale}))
|
||||
}
|
||||
|
||||
/// Zoom the active document to `z` about the view's centre, keeping that point in place.
|
||||
fn zoom_to(s: &mut Session, z: f64) -> Result<Value> {
|
||||
let v = view(s)?;
|
||||
let c = (v.cx, v.cy);
|
||||
@@ -114,6 +117,7 @@ fn pan(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Figma's zoom steps.
|
||||
const STEPS: &[f64] = &[0.02, 0.03, 0.06, 0.13, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0];
|
||||
|
||||
/// The next zoom step above (`up`) or below `z` in Figma's fixed steps.
|
||||
fn step(z: f64, up: bool) -> f64 {
|
||||
if up {
|
||||
STEPS.iter().copied().find(|s| *s > z * 1.001).unwrap_or(256.0)
|
||||
@@ -141,6 +145,7 @@ fn zoom_at(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"zoom": v.zoom, "cx": v.cx, "cy": v.cy}))
|
||||
}
|
||||
|
||||
/// Select and fit the next (`dir` 1) or previous (-1) top-level frame on the page.
|
||||
fn zoom_frame(s: &mut Session, dir: i64) -> Result<Value> {
|
||||
let ds = s.ds()?;
|
||||
let frames = ds.doc.top_frames(ds.page);
|
||||
@@ -161,6 +166,7 @@ fn zoom_frame(s: &mut Session, dir: i64) -> Result<Value> {
|
||||
Ok(json!({"frame": f}))
|
||||
}
|
||||
|
||||
/// Move to the next (`dir` 1) or previous (-1) page, skipping dividers, and fit it.
|
||||
fn page_step(s: &mut Session, dir: i64) -> Result<Value> {
|
||||
let ds = s.ds()?;
|
||||
let pages = &ds.doc.pages;
|
||||
@@ -183,6 +189,7 @@ fn page_step(s: &mut Session, dir: i64) -> Result<Value> {
|
||||
Ok(json!({"page": pg}))
|
||||
}
|
||||
|
||||
/// Ask the UI to open or toggle the panel or dialog named `kind`.
|
||||
fn ui(s: &mut Session, kind: &str) -> Result<Value> {
|
||||
s.request_ui(kind, json!({}));
|
||||
ok()
|
||||
@@ -210,12 +217,14 @@ fn ui_scale_step(s: &mut Session, dir: i32) -> Result<Value> {
|
||||
Ok(json!({"scale": s.prefs.ui_scale}))
|
||||
}
|
||||
|
||||
/// Set one of the preferences' on/off settings from `on`, or toggle it when `on` is absent.
|
||||
fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Result<Value> {
|
||||
let slot = f(&mut s.prefs);
|
||||
*slot = boolean(p, "on").unwrap_or(!*slot);
|
||||
Ok(json!({"on": *slot}))
|
||||
}
|
||||
|
||||
/// Switch the tool, unless the file is read-only and this tool needs editing.
|
||||
pub(crate) fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
|
||||
// A file you can't edit now (view only, or on its way to a server): moving around only, and
|
||||
// commenting with a comment link.
|
||||
@@ -226,6 +235,7 @@ pub(crate) fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
|
||||
Ok(json!({"tool": t.id()}))
|
||||
}
|
||||
|
||||
/// The View menu's commands and their parameters.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("view.zoomIn", "Zoom in", "{}", false, |s, _| {
|
||||
@@ -480,7 +490,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"pref.language",
|
||||
"Interface language",
|
||||
"{language: system|en|bs|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|vi|zh-Hans|…; else English}",
|
||||
"{language: system|en|bs|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|vi|zh-Hans|zh-Hant|…; else English}",
|
||||
false,
|
||||
|s, p| {
|
||||
let lang = req_string("pref.language", p, "language")?;
|
||||
|
||||
@@ -319,9 +319,10 @@ fn a_dropped_connection_keeps_editing_and_catches_up() {
|
||||
}
|
||||
|
||||
impl Room {
|
||||
/// Person `i` has been told to join again: the server takes them as someone new.
|
||||
fn welcome_anew(&mut self, i: usize) {
|
||||
let ClientMsg::Hello(h) = Client::hello("f", "t", "Person", "") else { panic!("not a hello") };
|
||||
/// Person `i` has been told to join again: the server takes them as `resume` says.
|
||||
fn welcome_as(&mut self, i: usize, resume: Option<figcraft_collab::Resume>) {
|
||||
let ClientMsg::Hello(mut h) = Client::hello("f", "t", "Person", "") else { panic!("not a hello") };
|
||||
h.resume = resume;
|
||||
let (id, out) = self.server.join(h).unwrap();
|
||||
let w = welcome_of(&out, id);
|
||||
let p = &mut self.people[i];
|
||||
@@ -351,7 +352,7 @@ fn reconnecting_can_be_told_to_try_now_and_shows_as_disconnected() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_that_cant_go_on_stays_on_its_tab_and_joins_again_there() {
|
||||
fn a_session_that_cant_go_on_stays_on_its_tab_and_merges_when_it_joins_again() {
|
||||
let mut room = Room::new(base(), 2, 7);
|
||||
let (f, r) = frame_and_rect(room.server.document());
|
||||
let uid = room.s(0).ds().unwrap().uid;
|
||||
@@ -363,7 +364,8 @@ fn a_session_that_cant_go_on_stays_on_its_tab_and_joins_again_there() {
|
||||
let live = room.s(0).live();
|
||||
assert!(live.disconnected() && live.rejoinable() && live.doc_uid == Some(uid));
|
||||
assert!(room.s(0).live_read_only(uid).is_none());
|
||||
room.s(0).execute("node.set", &json!({"id": f, "props": {"name": "Offline too"}})).unwrap();
|
||||
let made = room.s(0).execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
|
||||
let made = NodeId(made["id"].as_u64().unwrap());
|
||||
// A try that fails leaves it just as it was.
|
||||
assert_eq!(room.s(0).execute("collab.reconnect", &json!({})).unwrap()["status"], "connecting");
|
||||
assert!(room.s(0).live().disconnected() && !room.s(0).live().rejoinable());
|
||||
@@ -371,23 +373,85 @@ fn a_session_that_cant_go_on_stays_on_its_tab_and_joins_again_there() {
|
||||
room.event(0, Event::Status(Status::Failed { reason: "Couldn't join the live session: no answer".into() }));
|
||||
assert!(room.s(0).live().rejoinable());
|
||||
// Someone else's edit lands meanwhile.
|
||||
room.s(1).execute("transform.move", &json!({"ids": [r], "dx": 5, "dy": 5})).unwrap();
|
||||
room.s(1).execute("transform.move", &json!({"ids": [f], "dx": 5, "dy": 5})).unwrap();
|
||||
let theirs = |d: &Document| d.node(f).unwrap().transform;
|
||||
room.settle();
|
||||
// The next try joins in the same tab, and what was done offline opens beside it.
|
||||
let moved = theirs(room.server.document());
|
||||
// The next try joins in the same tab, as the same client, and everything goes on top.
|
||||
room.s(0).execute("collab.reconnect", &json!({})).unwrap();
|
||||
room.welcome_anew(0);
|
||||
let resumed = room.people[0].script.lock().unwrap().resumed.expect("as who you were");
|
||||
assert_eq!((resumed.client_id, resumed.seq), (room.people[0].id, u64::MAX));
|
||||
room.welcome_as(0, Some(resumed));
|
||||
room.settle();
|
||||
room.check();
|
||||
let s = room.s(0);
|
||||
assert_eq!(s.live().status, Status::Live);
|
||||
assert!(!s.live().disconnected());
|
||||
assert_eq!(s.docs.len(), tabs + 1, "the copy's is the only new tab");
|
||||
let (tab, copy) = (s.docs.iter().find(|d| d.uid == uid).unwrap(), s.docs.last().unwrap());
|
||||
assert_eq!(s.live().doc_uid, Some(uid));
|
||||
assert_ne!(tab.doc.node(r).unwrap().name, "Offline", "the session's tab shows the server's version");
|
||||
assert_eq!(copy.doc.name, format!("{} (offline copy)", tab.doc.name));
|
||||
assert_eq!(copy.doc.node(r).unwrap().name, "Offline");
|
||||
assert_eq!(copy.doc.node(f).unwrap().name, "Offline too");
|
||||
assert!(copy.is_dirty(), "closing the copy asks first");
|
||||
assert!(s.message.as_deref().is_some_and(|m| m.contains("offline copy")));
|
||||
assert_eq!(s.docs.len(), tabs, "no copy: it all went in");
|
||||
let doc = room.server.document();
|
||||
assert_eq!(doc.node(r).unwrap().name, "Offline");
|
||||
assert!(doc.contains(made), "layers you made too");
|
||||
assert_eq!(theirs(doc), moved, "and the other edit is still there");
|
||||
assert!(room.s(0).message.as_deref().is_some_and(|m| m.contains("is in the file")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_thing_changed_both_ways_keeps_the_others_and_opens_your_version_beside_it() {
|
||||
let mut room = Room::new(base(), 2, 7);
|
||||
let (f, r) = frame_and_rect(room.server.document());
|
||||
let tabs = room.s(0).docs.len();
|
||||
room.disconnect(0);
|
||||
room.s(0).execute("node.set", &json!({"id": r, "props": {"name": "Mine"}})).unwrap();
|
||||
room.s(0).execute("node.set", &json!({"id": f, "props": {"name": "Only mine"}})).unwrap();
|
||||
room.event(0, Event::Status(Status::Failed { reason: "gone".into() }));
|
||||
room.s(1).execute("node.set", &json!({"id": r, "props": {"name": "Theirs"}})).unwrap();
|
||||
room.settle();
|
||||
room.s(0).execute("collab.reconnect", &json!({})).unwrap();
|
||||
let resumed = room.people[0].script.lock().unwrap().resumed;
|
||||
room.welcome_as(0, resumed);
|
||||
room.settle();
|
||||
room.check();
|
||||
let doc = room.server.document();
|
||||
assert_eq!(doc.node(r).unwrap().name, "Theirs", "what conflicts isn't written over");
|
||||
assert_eq!(doc.node(f).unwrap().name, "Only mine", "what doesn't goes in");
|
||||
let s = room.s(0);
|
||||
assert_eq!(s.docs.len(), tabs + 1);
|
||||
let copy = s.docs.last().unwrap();
|
||||
assert!(copy.doc.name.ends_with("(offline copy)") && copy.is_dirty(), "closing the copy asks first");
|
||||
assert_eq!(copy.doc.node(r).unwrap().name, "Mine");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_the_new_client_cant_make_open_beside_the_file() {
|
||||
let mut room = Room::new(base(), 1, 7);
|
||||
let tabs = room.s(0).docs.len();
|
||||
room.disconnect(0);
|
||||
let made = room.s(0).execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
|
||||
let made = NodeId(made["id"].as_u64().unwrap());
|
||||
room.event(0, Event::Status(Status::Failed { reason: "gone".into() }));
|
||||
room.s(0).execute("collab.reconnect", &json!({})).unwrap();
|
||||
// The server takes you as someone new.
|
||||
room.welcome_as(0, None);
|
||||
room.settle();
|
||||
room.check();
|
||||
assert!(!room.server.document().contains(made));
|
||||
let s = room.s(0);
|
||||
assert_eq!(s.docs.len(), tabs + 1);
|
||||
assert!(s.docs.last().unwrap().doc.contains(made), "it isn't lost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_forgot_you_keeps_your_offline_edits() {
|
||||
let mut room = Room::new(base(), 1, 7);
|
||||
let (_, r) = frame_and_rect(room.server.document());
|
||||
room.disconnect(0);
|
||||
room.s(0).execute("node.set", &json!({"id": r, "props": {"name": "Offline"}})).unwrap();
|
||||
// The same file under a new session: it knows no one.
|
||||
room.server = Server::new(room.server.document().clone(), "t", Limits::default());
|
||||
room.welcome_as(0, None);
|
||||
room.settle();
|
||||
assert_eq!(room.server.document().node(r).unwrap().name, "Offline");
|
||||
assert_eq!(room.s(0).live().status, Status::Live);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -399,7 +463,8 @@ fn joining_again_with_nothing_changed_opens_no_copy() {
|
||||
assert!(room.s(0).live().rejoinable(), "the end of a session you were in shows on its tab too");
|
||||
assert_eq!(room.s(0).message.as_deref(), Some("Sharing was stopped"));
|
||||
room.s(0).execute("collab.reconnect", &json!({})).unwrap();
|
||||
room.welcome_anew(0);
|
||||
let resumed = room.people[0].script.lock().unwrap().resumed;
|
||||
room.welcome_as(0, resumed);
|
||||
assert_eq!(room.s(0).docs.len(), tabs);
|
||||
assert_eq!(room.s(0).live().status, Status::Live);
|
||||
}
|
||||
@@ -414,7 +479,8 @@ fn a_session_out_of_step_joins_again_by_itself() {
|
||||
assert_eq!(room.s(0).live().status, Status::Connecting);
|
||||
assert!(room.s(0).live().disconnected());
|
||||
assert_eq!(room.people[0].script.lock().unwrap().joined.len(), joins + 1);
|
||||
room.welcome_anew(0);
|
||||
let resumed = room.people[0].script.lock().unwrap().resumed;
|
||||
room.welcome_as(0, resumed);
|
||||
assert_eq!(room.s(0).live().status, Status::Live);
|
||||
assert_eq!(room.s(0).docs.len(), tabs);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
//! The color profile an export setting writes PNG, JPG and WebP files in: same as the file (the
|
||||
//! default, following the document's profile when it exports), sRGB or Display P3, with the pixels
|
||||
//! converted and the file tagged; stored per setting, kept across a format switch, passed to
|
||||
//! export layers, ignored by the always-sRGB SVG, PDF and XML, and bad values refused.
|
||||
|
||||
use base64::Engine as _;
|
||||
use figcraft_color::{Color, ColorProfile};
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
|
||||
/// A session with a red 20 × 10 rectangle in an sRGB document.
|
||||
fn session() -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 10, "name": "Red"})).unwrap()["id"]).unwrap();
|
||||
s.execute("fill.set", &json!({"ids": [r], "color": "#FF0000"})).unwrap();
|
||||
(s, r)
|
||||
}
|
||||
|
||||
fn bytes(v: &Value) -> Vec<u8> {
|
||||
base64::engine::general_purpose::STANDARD.decode(v["data"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, params: Value) -> Vec<u8> {
|
||||
bytes(&s.execute("file.export", ¶ms).unwrap())
|
||||
}
|
||||
|
||||
/// Whether `bytes` holds a PNG chunk of `kind` (with `data`, when given).
|
||||
fn has_chunk(bytes: &[u8], kind: &[u8; 4], data: Option<&[u8]>) -> bool {
|
||||
bytes.windows(4).enumerate().any(|(i, w)| w == kind && data.is_none_or(|d| bytes.get(i + 4..i + 4 + d.len()) == Some(d)))
|
||||
}
|
||||
|
||||
/// An embedded ICC display profile (its device class).
|
||||
fn has_icc(bytes: &[u8]) -> bool {
|
||||
bytes.windows(4).any(|w| w == b"mntr")
|
||||
}
|
||||
|
||||
/// The middle pixel's RGB.
|
||||
fn middle(bytes: &[u8]) -> [u8; 3] {
|
||||
let img = image::load_from_memory(bytes).unwrap().to_rgba8();
|
||||
let [r, g, b, _] = img.get_pixel(img.width() / 2, img.height() / 2).0;
|
||||
[r, g, b]
|
||||
}
|
||||
|
||||
/// `c` as 8-bit RGB.
|
||||
fn rgb8(c: Color) -> [u8; 3] {
|
||||
[c.r, c.g, c.b].map(|v| (v * 255.0).round() as u8)
|
||||
}
|
||||
|
||||
fn close(a: [u8; 3], b: [u8; 3], tolerance: u8) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= tolerance)
|
||||
}
|
||||
|
||||
/// sRGB red in Display P3 values.
|
||||
fn red_in_p3() -> [u8; 3] {
|
||||
rgb8(ColorProfile::Srgb.conversion_to(ColorProfile::DisplayP3).apply(Color::new(1.0, 0.0, 0.0, 1.0)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_display_p3_png_from_an_srgb_file_is_converted_and_tagged() {
|
||||
let (mut s, r) = session();
|
||||
let png = export(&mut s, json!({"ids": [r], "format": "png", "colorProfile": "displayP3"}));
|
||||
assert!(has_chunk(&png, b"iCCP", None) && has_chunk(&png, b"cICP", Some(&[12, 13, 0, 1])) && !has_chunk(&png, b"sRGB", None));
|
||||
let want = red_in_p3();
|
||||
assert!(close(middle(&png), want, 1), "{:?} vs {want:?}", middle(&png));
|
||||
// The default stays the file's own sRGB.
|
||||
let png = export(&mut s, json!({"ids": [r], "format": "png"}));
|
||||
assert!(has_chunk(&png, b"sRGB", None) && !has_chunk(&png, b"iCCP", None));
|
||||
assert_eq!(middle(&png), [255, 0, 0]);
|
||||
// The document is not changed by the export.
|
||||
assert_eq!(s.doc().unwrap().color_profile, ColorProfile::Srgb);
|
||||
assert_eq!(crate::paint_tests::fill(&s, r).to_hex(), "FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_srgb_export_of_a_display_p3_file_converts_back() {
|
||||
let (mut s, r) = session();
|
||||
s.execute("file.colorProfile", &json!({"profile": "displayP3"})).unwrap();
|
||||
let same = export(&mut s, json!({"ids": [r], "format": "png"}));
|
||||
assert!(has_chunk(&same, b"iCCP", None));
|
||||
assert!(close(middle(&same), red_in_p3(), 1), "{:?}", middle(&same));
|
||||
for p in ["srgb", "displayP3", "sameAsFile"] {
|
||||
let png = export(&mut s, json!({"ids": [r], "format": "png", "colorProfile": p}));
|
||||
assert_eq!(has_chunk(&png, b"sRGB", None), p == "srgb", "{p}");
|
||||
let want = if p == "srgb" { [255, 0, 0] } else { red_in_p3() };
|
||||
assert!(close(middle(&png), want, 1), "{p}: {:?} vs {want:?}", middle(&png));
|
||||
}
|
||||
// A wide-gamut color clips in sRGB and keeps its values in Display P3.
|
||||
s.execute("fill.set", &json!({"ids": [r], "color": "#00FF00"})).unwrap();
|
||||
let srgb = export(&mut s, json!({"ids": [r], "format": "png", "colorProfile": "srgb"}));
|
||||
let p3 = export(&mut s, json!({"ids": [r], "format": "png", "colorProfile": "displayP3"}));
|
||||
assert_eq!(middle(&p3), [0, 255, 0]);
|
||||
let [red, green, blue] = middle(&srgb);
|
||||
assert!(red == 0 && green == 255 && blue == 0, "P3 green clips to sRGB green: {:?}", middle(&srgb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpg_and_webp_follow_the_setting() {
|
||||
let (mut s, r) = session();
|
||||
for f in ["jpg", "webp"] {
|
||||
// sRGB JPGs embed their profile and sRGB WebPs stay untagged (read as sRGB); Display P3 files
|
||||
// embed the generated profile.
|
||||
assert_eq!(has_icc(&export(&mut s, json!({"ids": [r], "format": f}))), f == "jpg", "{f}");
|
||||
let p3 = export(&mut s, json!({"ids": [r], "format": f, "colorProfile": "displayP3"}));
|
||||
assert!(has_icc(&p3), "{f}");
|
||||
let want = red_in_p3();
|
||||
assert!(close(middle(&p3), want, 8), "{f}: {:?} vs {want:?}", middle(&p3));
|
||||
}
|
||||
s.execute("file.colorProfile", &json!({"profile": "displayP3"})).unwrap();
|
||||
for f in ["jpg", "webp"] {
|
||||
let srgb = export(&mut s, json!({"ids": [r], "format": f, "colorProfile": "srgb"}));
|
||||
assert_eq!(has_icc(&srgb), f == "jpg", "{f}");
|
||||
assert!(close(middle(&srgb), [255, 0, 0], 8), "{f}: {:?}", middle(&srgb));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_translucent_jpg_is_flattened_before_its_conversion() {
|
||||
let (mut s, r) = session();
|
||||
s.execute("fill.set", &json!({"ids": [r], "opacity": 0.5})).unwrap();
|
||||
// The sRGB document shows 50% red over white; the Display P3 JPG holds that color converted.
|
||||
let flat = export(&mut s, json!({"ids": [r], "format": "jpg", "colorProfile": "srgb"}));
|
||||
let [fr, fg, fb] = middle(&flat);
|
||||
let shown = Color::new(f32::from(fr) / 255.0, f32::from(fg) / 255.0, f32::from(fb) / 255.0, 1.0);
|
||||
let want = rgb8(ColorProfile::Srgb.conversion_to(ColorProfile::DisplayP3).apply(shown));
|
||||
let p3 = export(&mut s, json!({"ids": [r], "format": "jpg", "colorProfile": "displayP3"}));
|
||||
assert!(close(middle(&p3), want, 2), "{:?} vs {want:?} (sRGB {:?})", middle(&p3), [fr, fg, fb]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_pdf_and_xml_stay_srgb() {
|
||||
let (mut s, r) = session();
|
||||
// A Display P3 file: its red converts back to sRGB #FF0000 whatever the setting says.
|
||||
s.execute("file.colorProfile", &json!({"profile": "displayP3"})).unwrap();
|
||||
for p in ["displayP3", "sameAsFile", "srgb"] {
|
||||
let svg = String::from_utf8(export(&mut s, json!({"ids": [r], "format": "svg", "colorProfile": p}))).unwrap().to_ascii_uppercase();
|
||||
assert!(svg.contains("#FF0000"), "{p}: {svg}");
|
||||
let xml = String::from_utf8(export(&mut s, json!({"ids": [r], "format": "xml", "colorProfile": p}))).unwrap().to_ascii_uppercase();
|
||||
assert!(xml.contains("#FF0000"), "{p}: {xml}");
|
||||
let pdf = export(&mut s, json!({"ids": [r], "format": "pdf", "colorProfile": p}));
|
||||
assert!(pdf.starts_with(b"%PDF") && !has_icc(&pdf), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_as_file_follows_the_document_and_a_choice_stays() {
|
||||
let (mut s, r) = session();
|
||||
s.execute("export.add", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("export.add", &json!({"ids": [r], "setting": {"suffix": "-srgb", "colorProfile": "srgb"}})).unwrap();
|
||||
s.execute("export.add", &json!({"ids": [r], "setting": {"suffix": "-p3", "colorProfile": "displayP3"}})).unwrap();
|
||||
let files = |s: &mut Session| -> Vec<(String, bool)> {
|
||||
let v = s.execute("file.exportLayers", &json!({"ids": [r]})).unwrap();
|
||||
v["files"].as_array().unwrap().iter().map(|f| (f["name"].as_str().unwrap().to_string(), has_chunk(&bytes(f), b"iCCP", None))).collect()
|
||||
};
|
||||
let names = |icc: [bool; 3]| -> Vec<(String, bool)> {
|
||||
["Red.png", "Red-srgb.png", "Red-p3.png"].into_iter().zip(icc).map(|(n, i)| (n.to_string(), i)).collect()
|
||||
};
|
||||
assert_eq!(files(&mut s), names([false, false, true]));
|
||||
s.execute("file.colorProfile", &json!({"profile": "displayP3"})).unwrap();
|
||||
assert_eq!(files(&mut s), names([true, false, true]));
|
||||
// A profile passed to export layers stands in for every setting's own.
|
||||
let v = s.execute("file.exportLayers", &json!({"ids": [r], "colorProfile": "srgb"})).unwrap();
|
||||
assert!(v["files"].as_array().unwrap().iter().all(|f| has_chunk(&bytes(f), b"sRGB", None)), "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_settings_keep_their_color_profile() {
|
||||
let (mut s, r) = session();
|
||||
s.execute("export.add", &json!({"ids": [r], "setting": {"colorProfile": "displayP3"}})).unwrap();
|
||||
let e = s.doc().unwrap().node(r).unwrap().exports[0].clone();
|
||||
assert_eq!(e.color_profile, ExportColorProfile::DisplayP3);
|
||||
// Stored camelCase, the default left out, and a row saved without it reads as same as file.
|
||||
assert_eq!(serde_json::to_value(&e).unwrap()["colorProfile"], "displayP3");
|
||||
assert!(serde_json::to_value(ExportSetting::default()).unwrap().get("colorProfile").is_none());
|
||||
let old: ExportSetting = serde_json::from_value(json!({"size": {"by": "scale", "value": 2.0}, "suffix": "@2x", "format": "jpg"})).unwrap();
|
||||
assert_eq!(old.color_profile, ExportColorProfile::SameAsFile);
|
||||
// Saved and loaded.
|
||||
let saved = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
|
||||
let (back, _) = figcraft_format::load_bytes(&saved).unwrap();
|
||||
assert_eq!(back.node(r).unwrap().exports[0], e);
|
||||
// Edited and undone; a format switch keeps it.
|
||||
s.execute("export.update", &json!({"ids": [r], "index": 0, "colorProfile": "srgb"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().exports[0].color_profile, ExportColorProfile::Srgb);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().exports[0].color_profile, ExportColorProfile::DisplayP3);
|
||||
for f in ["svg", "pdf", "png"] {
|
||||
s.execute("export.update", &json!({"ids": [r], "index": 0, "format": f})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().exports[0].color_profile, ExportColorProfile::DisplayP3, "{f}");
|
||||
}
|
||||
// `null` changes nothing.
|
||||
s.execute("export.update", &json!({"ids": [r], "index": 0, "colorProfile": null})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().exports[0].color_profile, ExportColorProfile::DisplayP3);
|
||||
for p in ExportColorProfile::ALL {
|
||||
assert_eq!(ExportColorProfile::from_key(p.key()), Some(p));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_color_profiles_are_refused_and_change_nothing() {
|
||||
let (mut s, r) = session();
|
||||
s.execute("export.add", &json!({"ids": [r]})).unwrap();
|
||||
for junk in [json!("cmyk"), json!(3), json!(true), json!("SRGB"), json!({})] {
|
||||
assert!(s.execute("file.export", &json!({"ids": [r], "colorProfile": junk})).is_err(), "{junk}");
|
||||
assert!(s.execute("export.update", &json!({"ids": [r], "index": 0, "colorProfile": junk})).is_err(), "{junk}");
|
||||
assert!(s.execute("export.add", &json!({"ids": [r], "setting": {"colorProfile": junk}})).is_err(), "{junk}");
|
||||
assert!(s.execute("file.exportLayers", &json!({"ids": [r], "colorProfile": junk})).is_err(), "{junk}");
|
||||
}
|
||||
let n = s.doc().unwrap().node(r).unwrap();
|
||||
assert_eq!((n.exports.len(), n.exports[0].color_profile), (1, ExportColorProfile::SameAsFile));
|
||||
let err = s.execute("file.export", &json!({"ids": [r], "colorProfile": "p3"})).unwrap_err().to_string();
|
||||
assert!(err.contains("sameAsFile|srgb|displayP3"), "{err}");
|
||||
}
|
||||
@@ -366,6 +366,7 @@ pub static GESTURES: &[(Group, &str, &str, &str)] = &[
|
||||
(Transform, "Opacity 10%…90%, 0: 100%", "1…9, 0", "1…9, 0"),
|
||||
(Transform, "Exact opacity (4 then 5: 45%)", "Two digits", "Two digits"),
|
||||
(Transform, "Suspend snapping while dragging", "Hold ⌃ or ⌘", "Hold Ctrl"),
|
||||
(Arrange, "Edit padding on all sides", "⌘ click a padding field", "Ctrl+click a padding field"),
|
||||
(Arrange, "Set hug contents", "Double-click an edge of an auto layout frame", "Double-click an edge of an auto layout frame"),
|
||||
(Arrange, "Set fill container", "⌥ double-click an edge of an auto layout child", "Alt+double-click an edge of an auto layout child"),
|
||||
(Prototype, "Presenting: next frame", "→, Space or N", "→, Space or N"),
|
||||
|
||||
@@ -317,6 +317,8 @@ mod export_bench_tests;
|
||||
#[cfg(test)]
|
||||
mod export_depth_tests;
|
||||
#[cfg(test)]
|
||||
mod export_profile_tests;
|
||||
#[cfg(test)]
|
||||
mod extended_chain_tests;
|
||||
#[cfg(test)]
|
||||
mod eyedropper_create_tests;
|
||||
|
||||
@@ -18,6 +18,8 @@ pub(crate) struct Script {
|
||||
pub(crate) sent: Vec<Presence>,
|
||||
pub(crate) hosted: Vec<HostOptions>,
|
||||
pub(crate) joined: Vec<String>,
|
||||
/// What the last join asked to resume as.
|
||||
pub(crate) resumed: Option<crate::live::Resume>,
|
||||
/// The sessions told to reconnect now, in order.
|
||||
pub(crate) retried: Vec<u64>,
|
||||
pub(crate) left: usize,
|
||||
@@ -48,6 +50,11 @@ impl LiveLink for Mock {
|
||||
i.url = format!("ws://{}/?file=f&token=t", i.address);
|
||||
Ok(i)
|
||||
}
|
||||
fn join_resuming(&mut self, session: u64, link: &str, name: &str, resume: Option<crate::live::Resume>) -> Result<(), String> {
|
||||
self.0.lock().unwrap().resumed = resume;
|
||||
self.join(session, link, name)
|
||||
}
|
||||
|
||||
fn retry(&mut self, session: u64) {
|
||||
self.0.lock().unwrap().retried.push(session);
|
||||
}
|
||||
|
||||
@@ -152,6 +152,8 @@ struct Stranded {
|
||||
link: String,
|
||||
/// The server's state when it stopped: what's different from it in the tab is your own work.
|
||||
confirmed: Box<figcraft_collab::Replica>,
|
||||
/// Who you were: the layers you made are in that client's id space.
|
||||
resume: Resume,
|
||||
}
|
||||
|
||||
impl Live {
|
||||
@@ -182,17 +184,19 @@ impl Live {
|
||||
*self = Live { id: self.id, tab: self.tab, ..Live::default() };
|
||||
}
|
||||
|
||||
/// Keep this session on its tab for joining again (see [`Stranded`]): a session you joined, or
|
||||
/// one that already is; any other goes back to no session.
|
||||
fn strand(&mut self) {
|
||||
/// Remember what joining this session again takes, if it is one you joined (see [`Stranded`]).
|
||||
fn hold(&mut self) {
|
||||
let joined = self.info.as_ref().filter(|i| i.role == Role::Guest && !i.link.is_empty());
|
||||
let held = self.stranded.take().or_else(|| {
|
||||
let (client, info) = (self.client.as_ref()?, joined?);
|
||||
Some(Stranded { link: info.link.clone(), confirmed: Box::new(client.confirmed().clone()) })
|
||||
});
|
||||
if held.is_none() {
|
||||
return self.reset();
|
||||
if let (None, Some(client), Some(info)) = (&self.stranded, &self.client, joined) {
|
||||
self.stranded = Some(Stranded { link: info.link.clone(), confirmed: Box::new(client.confirmed().clone()), resume: client.resume() });
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep this session on its tab for joining again: a session you joined, or one that already
|
||||
/// is; any other goes back to no session.
|
||||
fn strand(&mut self) {
|
||||
self.hold();
|
||||
let Some(held) = self.stranded.take() else { return self.reset() };
|
||||
*self = Live {
|
||||
id: self.id,
|
||||
tab: self.tab,
|
||||
@@ -201,7 +205,7 @@ impl Live {
|
||||
server: std::mem::take(&mut self.server),
|
||||
published: self.published.take(),
|
||||
share: self.share.take(),
|
||||
stranded: held,
|
||||
stranded: Some(held),
|
||||
..Live::default()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use super::host::{clean_hosting, clean_shared};
|
||||
use figcraft_collab::Change;
|
||||
use figcraft_collab::merge::{self, Side};
|
||||
|
||||
use super::types::{Event, MAX_PEERS, PageInfo, Role, SessionId, SessionInfo, Status};
|
||||
use super::{
|
||||
Access, Arc, Client, ClientMsg, DocState, Document, EngineError, Live, NodeId, PUBLISHING, Peer, Presence, Result, ServerMsg, Session, Signal,
|
||||
ToolId, display_name, editing_hint, editing_of, limited_by, sanitize, tool_left,
|
||||
Access, Arc, Client, ClientMsg, DocState, Document, EngineError, Live, NodeId, PUBLISHING, Peer, Presence, Result, Resume, ServerMsg, Session,
|
||||
Signal, ToolId, display_name, editing_hint, editing_of, limited_by, sanitize, tool_left,
|
||||
};
|
||||
|
||||
/// What a live session's document holding components that contain each other's instances shows
|
||||
@@ -371,11 +374,12 @@ impl Session {
|
||||
/// Session `id`'s welcome: start the sync client; the host keeps editing the document it
|
||||
/// shared, a guest opens the host's file as a new tab (or gets it again in that tab after a
|
||||
/// reconnect).
|
||||
fn live_welcome(&mut self, id: SessionId, welcome: ServerMsg) {
|
||||
let client = match Client::join(welcome) {
|
||||
pub(super) fn live_welcome(&mut self, id: SessionId, welcome: ServerMsg) {
|
||||
let mut client = match Client::join(welcome) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return self.live_fail(id, format!("Couldn't open the shared file: {e}")),
|
||||
};
|
||||
let mut merged = None;
|
||||
let Some(live) = self.live.sessions.get(&id) else { return };
|
||||
let existing = live.doc_uid.filter(|u| self.docs.iter().any(|d| d.uid == *u));
|
||||
let (uid, synced) = match (live.role(), existing) {
|
||||
@@ -396,7 +400,7 @@ impl Session {
|
||||
}
|
||||
(Some(Role::Guest), Some(uid)) => {
|
||||
// Joining again in this tab, or the file you published arriving from the server.
|
||||
self.live_keep_offline_work(id, uid);
|
||||
merged = self.live_merge_offline_work(id, uid, &mut client);
|
||||
let doc = Arc::new(client.document().clone());
|
||||
let was_dirty = self.docs.iter().find(|d| d.uid == uid).is_some_and(DocState::is_dirty);
|
||||
self.apply_remote(uid, doc.clone());
|
||||
@@ -444,31 +448,50 @@ impl Session {
|
||||
live.client = Some(client);
|
||||
live.deferred.clear();
|
||||
live.stranded = None;
|
||||
self.live.send(id, merged);
|
||||
}
|
||||
|
||||
/// Session `id` is back on document `uid` after it couldn't go on, and the server's version is
|
||||
/// about to replace the tab's: what you did since opens beside it, unsaved, so nothing is lost.
|
||||
fn live_keep_offline_work(&mut self, id: SessionId, uid: u64) {
|
||||
let Some(held) = self.live.sessions.get(&id).and_then(|l| l.stranded.as_ref()) else { return };
|
||||
let Some(local) = self.docs.iter().find(|d| d.uid == uid).map(|d| d.doc.clone()) else { return };
|
||||
/// about to replace the tab's: what you did since goes on top of it as one edit of `client`,
|
||||
/// returned to send, the way two people's edits merge. What can't (the same thing changed both
|
||||
/// ways, or layers `client` can't make) leaves the server's as it is, and your whole version opens
|
||||
/// beside it, unsaved, so nothing is lost.
|
||||
fn live_merge_offline_work(&mut self, id: SessionId, uid: u64, client: &mut Client) -> Option<ClientMsg> {
|
||||
let held = self.live.sessions.get(&id)?.stranded.as_ref()?;
|
||||
let local = self.docs.iter().find(|d| d.uid == uid)?.doc.clone();
|
||||
let name = local.name.clone();
|
||||
// A document that can't be compared is kept too.
|
||||
if figcraft_collab::diff(&held.confirmed, &local).is_ok_and(|d| d.changes.is_empty()) {
|
||||
let plan = merge::plan(held.confirmed.document(), &local, client.document()).ok();
|
||||
let mine = plan.as_ref().map(|p| p.changes(Side::Branch));
|
||||
if mine.is_some_and(<[Change]>::is_empty) {
|
||||
self.message = Some(format!("Reconnected to {name}"));
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
let me = u64::from(client.id());
|
||||
let own = mine.is_some_and(|m| m.iter().all(|c| !matches!(c, Change::Create { id, .. } if id.0 >> 32 != me)));
|
||||
let merged = plan.as_ref().filter(|_| own).and_then(|p| p.merge(client.document(), Side::Branch, &|_| Some(Side::Main)).ok());
|
||||
let sent = merged.and_then(|(mut doc, touched)| {
|
||||
doc.mark_dirty(touched);
|
||||
client.edit_with(&doc, Some("Changes made while disconnected")).ok()
|
||||
});
|
||||
if sent.is_some() && plan.is_some_and(|p| p.conflicts.is_empty()) {
|
||||
self.message = Some(format!("Reconnected to {name}. What you changed while disconnected is in the file"));
|
||||
return sent.flatten();
|
||||
}
|
||||
let mut copy = DocState::new((*local).clone(), None);
|
||||
let copy_name = format!("{name} (offline copy)");
|
||||
Arc::make_mut(&mut copy.doc).name.clone_from(©_name);
|
||||
self.docs.push(copy);
|
||||
self.message = Some(format!("Reconnected to {name}. What you changed while disconnected is open beside it as “{copy_name}”"));
|
||||
self.message = Some(format!(
|
||||
"Reconnected to {name}. Some of what you changed while disconnected couldn't go in: your whole version is open beside it as “{copy_name}”"
|
||||
));
|
||||
sent.flatten()
|
||||
}
|
||||
|
||||
/// Join session `id` again in its tab (see [`Live::strand`]), as someone new: a fresh join needs
|
||||
/// nothing from the connection that was lost.
|
||||
/// Join session `id` again in its tab (see [`Live::strand`]): nothing is needed from the
|
||||
/// connection that was lost.
|
||||
pub(super) fn live_rejoin(&mut self, cmd: &str, id: SessionId) -> Result<()> {
|
||||
let name = display_name(&self.prefs);
|
||||
let Some(link) = self.live.sessions.get(&id).and_then(|l| l.stranded.as_ref()).map(|s| s.link.clone()) else {
|
||||
let Some((link, resume)) = self.live.sessions.get(&id).and_then(|l| l.stranded.as_ref()).map(|s| (s.link.clone(), s.resume)) else {
|
||||
return Err(crate::bad(cmd, "this file has no live session to join again"));
|
||||
};
|
||||
self.live.leave(id);
|
||||
@@ -476,7 +499,9 @@ impl Session {
|
||||
return Err(EngineError::Disabled(cmd.to_string(), why));
|
||||
}
|
||||
let Some(net) = self.live.link() else { return Err(super::unavailable(cmd)) };
|
||||
net.join_resuming(id, &link, &name, None).map_err(|e| crate::bad(cmd, e))?;
|
||||
// As the client you were when the server still knows it, so the layers you made are yours to
|
||||
// send; the whole document is asked for (no change is "after" this number).
|
||||
net.join_resuming(id, &link, &name, Some(Resume { seq: u64::MAX, ..resume })).map_err(|e| crate::bad(cmd, e))?;
|
||||
if let Some(live) = self.live.sessions.get_mut(&id) {
|
||||
live.status = Status::Connecting;
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"collab.reconnect",
|
||||
"Reconnect live session",
|
||||
"{} → {status}: for the open file's session when it is disconnected: a reconnect that is waiting tries now, and a session that couldn't go on (`failed`, the file open in its tab) is joined again there, what you changed since opening beside it. Refused when nothing was lost",
|
||||
"{} → {status}: for the open file's session when it is disconnected: a reconnect that is waiting tries now, and a session that couldn't go on (`failed`, the file open in its tab) is joined again there, what you changed since going on top (your whole version opening beside it when some of it can't). Refused when nothing was lost",
|
||||
false,
|
||||
reconnect
|
||||
),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::host::hosting_json;
|
||||
use super::types::{NO_SESSION, SessionId, Share, SharedLink, Status};
|
||||
use super::{
|
||||
Access, Arc, Client, EditingText, EngineError, EngineRecompute, FollowedView, Live, Result, Session, Value, ViewState, bad, busy, close, code,
|
||||
editing_of, json,
|
||||
Access, Arc, Client, EditingText, EngineError, EngineRecompute, FollowedView, Live, Result, ServerMsg, Session, Value, ViewState, bad, busy,
|
||||
close, code, editing_of, json,
|
||||
};
|
||||
|
||||
impl Session {
|
||||
@@ -115,7 +115,23 @@ impl Session {
|
||||
let Some(live) = self.live.sessions.get_mut(&id) else { return };
|
||||
let Some(client) = live.client.as_mut() else { return };
|
||||
let mut out = Vec::new();
|
||||
for m in std::mem::take(&mut live.deferred) {
|
||||
let mut waiting = std::mem::take(&mut live.deferred).into_iter();
|
||||
while let Some(m) = waiting.next() {
|
||||
// The server no longer knows this client (it restarted without its saved state, say):
|
||||
// it welcomed you as someone new. What you did goes on top like after any rejoin.
|
||||
if let ServerMsg::Welcome { session, client_id, .. } = &m {
|
||||
let me = client.resume();
|
||||
if (*session, *client_id) != (me.session, me.client_id) {
|
||||
live.hold();
|
||||
live.client = None;
|
||||
let rest: Vec<_> = waiting.collect();
|
||||
self.live_welcome(id, m);
|
||||
if let Some(live) = self.live.sessions.get_mut(&id) {
|
||||
live.deferred = rest;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
match client.receive(m, &EngineRecompute) {
|
||||
Ok(more) => out.extend(more),
|
||||
Err(e) => {
|
||||
|
||||
@@ -279,6 +279,7 @@ static ROWS: &[Row] = &[
|
||||
doc(Arrange, "Add auto layout", &["Shift+A"], &[], Bound("layout.add"), ""),
|
||||
doc(Arrange, "Remove auto layout", &["Alt+Shift+A"], &[], Bound("layout.remove"), ""),
|
||||
doc(Arrange, "Suggest auto layout", &["Ctrl+Shift+A"], &["Ctrl+Alt+Shift+A"], Bound("layout.suggest"), "was unbound"),
|
||||
doc(Arrange, "Edit padding on all sides (from right panel)", &["Cmd"], &["Ctrl"], Gesture("Edit padding on all sides"), "held while clicking a padding field: the value typed goes to every side"),
|
||||
pin(Arrange, "distribute.horizontal", &["Ctrl+Alt+H"], &["Alt+Shift+H"]),
|
||||
pin(Arrange, "distribute.vertical", &["Ctrl+Alt+V"], &["Alt+Shift+V"]),
|
||||
pin(Arrange, "arrange.tidy", &["Ctrl+Alt+T"], &["Ctrl+Alt+Shift+T"]),
|
||||
@@ -413,7 +414,7 @@ fn counts_match_the_docs() {
|
||||
let reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count();
|
||||
let pinned = ROWS.len() - documented.len();
|
||||
// docs/ui-parity.md quotes these.
|
||||
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (134, 133, 1, 0, 2, 57));
|
||||
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (135, 134, 1, 0, 2, 57));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -262,7 +262,7 @@ fn tools() -> Vec<Value> {
|
||||
tool(
|
||||
"export",
|
||||
"Export",
|
||||
"Export layers (default: the selection, else every top-level layer of the current page) as png, jpg, svg, webp, pdf or xml (an Android Vector Drawable, with `warnings` for what it changed or left out, per `vdCompat`) at `scale`, or the whole file as `fig` (experimental: written from public format knowledge, with `warnings` for what was left out; other apps may not open every file) (PDF: with `bleed`, `cropMarks`, `bleedMarks` and `cmyk` for print; options `contentsOnly`, `includeBoundingBox`, and for SVG `svgOutlineText`, `svgIdAttribute`, `svgSimplifyStroke`; for XML `svgIdAttribute` and `vdCompat`). With `path` the file is written there (absolute path recommended) and the result says where. Without `path`: png/jpg/webp come back as an image content block, svg as text, xml as text followed by `{format, warnings}`, other formats as base64 `data`.",
|
||||
"Export layers (default: the selection, else every top-level layer of the current page) as png, jpg, svg, webp, pdf or xml (an Android Vector Drawable, with `warnings` for what it changed or left out, per `vdCompat`) at `scale`, or the whole file as `fig` (experimental: written from public format knowledge, with `warnings` for what was left out; other apps may not open every file) (PDF: with `bleed`, `cropMarks`, `bleedMarks` and `cmyk` for print; options `contentsOnly`, `includeBoundingBox`, and for SVG `svgOutlineText`, `svgIdAttribute`, `svgSimplifyStroke`; for XML `svgIdAttribute` and `vdCompat`; for png, jpg and webp `colorProfile`). With `path` the file is written there (absolute path recommended) and the result says where. Without `path`: png/jpg/webp come back as an image content block, svg as text, xml as text followed by `{format, warnings}`, other formats as base64 `data`.",
|
||||
obj(
|
||||
json!({
|
||||
"ids": ids_schema("Layers to export (several are composited into one image)"),
|
||||
@@ -280,6 +280,7 @@ fn tools() -> Vec<Value> {
|
||||
"svgIdAttribute": boolean("SVG: layer names as unique `id` attributes (default false)"),
|
||||
"svgSimplifyStroke": boolean("SVG: strokes as `stroke` attributes where exact (default true); false writes outlines"),
|
||||
"vdCompat": {"type": "string", "enum": ["bestEffort", "exact", "ignore"], "description": "XML: what a Vector Drawable can't draw exactly is approximated with a warning (bestEffort, default), left out with a warning (exact), or written as far as it maps without warnings (ignore: overlapping layer opacity dropped, even-odd fills kept as fillType=evenOdd, diamond gradients left out)"},
|
||||
"colorProfile": {"type": "string", "enum": ["sameAsFile", "srgb", "displayP3"], "description": "PNG, JPG and WebP: the color profile of the file (default sameAsFile: the document's); colors are converted and out-of-gamut ones clip. SVG, PDF and XML are always sRGB"},
|
||||
"quality": {"type": "integer", "minimum": 1, "maximum": 100, "description": "JPG: the encoder's quality, 1 (smallest file) to 100; default 90"},
|
||||
}),
|
||||
&[],
|
||||
@@ -1060,6 +1061,7 @@ fn export(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, Str
|
||||
"svgIdAttribute",
|
||||
"svgSimplifyStroke",
|
||||
"vdCompat",
|
||||
"colorProfile",
|
||||
"quality",
|
||||
] {
|
||||
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//! The `export` tool's `colorProfile`: a PNG in another profile than the document's is tagged for
|
||||
//! it, and a bad value is refused.
|
||||
|
||||
use base64::Engine as _;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::common::Client;
|
||||
|
||||
fn png(c: &mut Client, args: serde_json::Value) -> Vec<u8> {
|
||||
let r = c.call("export", args);
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let image = r["content"].as_array().unwrap().iter().find(|block| block["type"] == "image").unwrap();
|
||||
base64::engine::general_purpose::STANDARD.decode(image["data"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn has_chunk(bytes: &[u8], kind: &[u8; 4]) -> bool {
|
||||
bytes.windows(4).any(|w| w == kind)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_profile_tags_the_png() {
|
||||
let mut c = Client::new();
|
||||
let id = c.create(json!({"type": "rectangle", "x": 0, "y": 0, "width": 24, "height": 16}));
|
||||
let srgb = png(&mut c, json!({"ids": [id], "format": "png"}));
|
||||
assert!(has_chunk(&srgb, b"sRGB") && !has_chunk(&srgb, b"iCCP"));
|
||||
let p3 = png(&mut c, json!({"ids": [id], "format": "png", "colorProfile": "displayP3"}));
|
||||
assert!(has_chunk(&p3, b"iCCP") && has_chunk(&p3, b"cICP") && !has_chunk(&p3, b"sRGB"));
|
||||
assert!(c.err("export", json!({"ids": [id], "format": "png", "colorProfile": "cmyk"})).contains("colorProfile"));
|
||||
}
|
||||
@@ -13,6 +13,8 @@ mod common;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod export_hidden_roots;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod export_profile;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod export_xml;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod garbage;
|
||||
|
||||
@@ -388,6 +388,8 @@ name = "English"
|
||||
"Crop marks" = "Crop marks"
|
||||
"Ctrl+arrows" = "Ctrl+arrows"
|
||||
"Ctrl+click" = "Ctrl+click"
|
||||
"Ctrl+click a padding field" = "Ctrl+click a padding field"
|
||||
"⌘ click a padding field" = "⌘ click a padding field"
|
||||
"Ctrl+scroll" = "Ctrl+scroll"
|
||||
"Current name" = "Current name"
|
||||
"Current version" = "Current version"
|
||||
@@ -522,6 +524,7 @@ name = "English"
|
||||
"Edit dev resource" = "Edit dev resource"
|
||||
"Edit link" = "Edit link"
|
||||
"Edit link copied" = "Edit link copied"
|
||||
"Edit padding on all sides" = "Edit padding on all sides"
|
||||
"Edit style" = "Edit style"
|
||||
"Edit variable" = "Edit variable"
|
||||
"Edit with the builder" = "Edit with the builder"
|
||||
@@ -1327,6 +1330,7 @@ name = "English"
|
||||
"See all templates" = "See all templates"
|
||||
"Select layers and they space themselves as their content changes" = "Select layers and they space themselves as their content changes"
|
||||
"SHA-256: {sha}…" = "SHA-256: {sha}…"
|
||||
"Same as file ({profile})" = "Same as file ({profile})"
|
||||
"Same as layer" = "Same as layer"
|
||||
"Saturation" = "Saturation"
|
||||
"Save" = "Save"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+49
-12
@@ -59,6 +59,21 @@ const CHINESE: &[&str] = &[
|
||||
"WenQuanYi Zen Hei",
|
||||
"Droid Sans Fallback",
|
||||
];
|
||||
/// Traditional Chinese (Taiwan) faces, an installed Noto Sans CJK TC first, then Windows, macOS
|
||||
/// and Linux names. Nothing is bundled: the desktop finds one of these among the installed fonts.
|
||||
const TRADITIONAL_CHINESE: &[&str] = &[
|
||||
"Noto Sans CJK TC",
|
||||
"Microsoft JhengHei UI",
|
||||
"Microsoft JhengHei",
|
||||
"PingFang TC",
|
||||
"Heiti TC",
|
||||
"Noto Sans TC",
|
||||
"Source Han Sans TC",
|
||||
"LiHei Pro",
|
||||
"Apple LiGothic",
|
||||
"MingLiU",
|
||||
"PMingLiU",
|
||||
];
|
||||
|
||||
/// The CJK, Bengali and Thai scripts in `s`.
|
||||
pub fn scripts(s: &str) -> u8 {
|
||||
@@ -86,6 +101,16 @@ pub fn language(code: &str) -> u8 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `code` is Traditional Chinese: `zh-Hant`, or `zh` with a Taiwan, Hong Kong or Macau
|
||||
/// region and no explicit `Hans` (`zh-TW`, `zh-HK`, `zh-MO`, `zh-Hant-TW`). `zh-Hans-TW` is
|
||||
/// Simplified.
|
||||
pub fn traditional(code: &str) -> bool {
|
||||
let tag = code.trim().split(['.', '@']).next().unwrap_or("").replace('_', "-");
|
||||
let base = tag.split('-').next().unwrap_or("");
|
||||
let has = |subtags: &[&str]| tag.split('-').skip(1).any(|t| subtags.iter().any(|s| s.eq_ignore_ascii_case(t)));
|
||||
base.eq_ignore_ascii_case("zh") && (has(&["Hant"]) || (!has(&["Hans"]) && has(&["TW", "HK", "MO"])))
|
||||
}
|
||||
|
||||
/// Whether the UI can draw language `code`: always on desktop (a system face is found when it's
|
||||
/// needed; tofu at worst), on the web only with built-in faces for its scripts (Georgian: craft-fonts'
|
||||
/// Noto Sans Georgian). The language picker and the system language skip a language it can't.
|
||||
@@ -107,19 +132,20 @@ fn shows(code: &str, web: bool) -> bool {
|
||||
return figcraft_text::face_with_char('ع', &["Noto Sans Arabic"]).is_some();
|
||||
}
|
||||
let need = language(code);
|
||||
need == 0 || samples(need, 0).iter().all(|(c, _)| figcraft_text::face_with_char(*c, &["BIZ UDPGothic", "Noto Sans CJK SC"]).is_some())
|
||||
need == 0 || samples(need, 0, traditional(code)).iter().all(|(c, families)| figcraft_text::face_with_char(*c, families).is_some())
|
||||
}
|
||||
|
||||
/// A character standing for each script in `need`, with the families to look for it in, the UI
|
||||
/// language's script (`ui`, see [`language`]) first. Han reads as Chinese (a simplified character,
|
||||
/// Chinese faces first) in a Chinese UI, and without kana in a UI that isn't Japanese or Korean;
|
||||
/// else a Japanese or Korean face draws it.
|
||||
fn samples(need: u8, ui: u8) -> Vec<(char, Vec<&'static str>)> {
|
||||
/// Simplified or Traditional Chinese faces first as the UI language says) in a Chinese UI, and
|
||||
/// without kana in a UI that isn't Japanese or Korean; else a Japanese or Korean face draws it.
|
||||
fn samples(need: u8, ui: u8, hant: bool) -> Vec<(char, Vec<&'static str>)> {
|
||||
let chinese = need & HAN != 0 && (ui == HAN || (ui == 0 && need & KANA == 0));
|
||||
let han: (char, Vec<&str>) = match (chinese, ui) {
|
||||
(true, _) => ('们', [CHINESE, JAPANESE, KOREAN].concat()),
|
||||
(false, HANGUL) => ('漢', [KOREAN, JAPANESE, CHINESE].concat()),
|
||||
(false, _) => ('漢', [JAPANESE, KOREAN, CHINESE].concat()),
|
||||
let han: (char, Vec<&str>) = match (chinese, hant, ui) {
|
||||
(true, true, _) => ('們', [TRADITIONAL_CHINESE, JAPANESE, KOREAN].concat()),
|
||||
(true, false, _) => ('们', [CHINESE, JAPANESE, KOREAN].concat()),
|
||||
(false, _, HANGUL) => ('漢', [KOREAN, JAPANESE, CHINESE].concat()),
|
||||
(false, _, _) => ('漢', [JAPANESE, KOREAN, CHINESE].concat()),
|
||||
};
|
||||
let mut v = vec![
|
||||
(KANA, ('あ', JAPANESE.to_vec())),
|
||||
@@ -147,7 +173,7 @@ fn samples(need: u8, ui: u8) -> Vec<(char, Vec<&'static str>)> {
|
||||
/// tried are read from disk.
|
||||
pub fn faces(need: u8, lang: &str, have: &dyn Fn(char) -> bool) -> Vec<Arc<Face>> {
|
||||
let mut out: Vec<Arc<Face>> = Vec::new();
|
||||
for (c, families) in samples(need, language(lang)) {
|
||||
for (c, families) in samples(need, language(lang), traditional(lang)) {
|
||||
if have(c) || out.iter().any(|f| f.has_char(c)) {
|
||||
continue;
|
||||
}
|
||||
@@ -177,6 +203,9 @@ mod tests {
|
||||
assert_eq!(language("ko"), HANGUL);
|
||||
assert_eq!(language("zh-Hant"), HAN);
|
||||
assert_eq!(language("zh-Hans"), HAN);
|
||||
assert!(traditional("zh-Hant") && traditional("zh-TW") && traditional("zh-HK") && traditional("zh-MO"));
|
||||
assert!(traditional("zh-Hant-HK") && traditional("zh_TW.UTF-8"));
|
||||
assert!(!traditional("zh-Hans") && !traditional("zh-CN") && !traditional("zh-Hans-TW") && !traditional("zh") && !traditional("ja"));
|
||||
assert_eq!(language("bn"), BENGALI);
|
||||
assert_eq!(language("fr"), 0);
|
||||
assert!(["en", "es", "es-419", "fr", "de", "pt-BR", "tr", "id", "vi", "bs"].iter().all(|c| can_show(c)));
|
||||
@@ -202,8 +231,8 @@ mod tests {
|
||||
/// The UI language's script leads the fallback order; the others follow.
|
||||
#[test]
|
||||
fn the_ui_language_script_comes_first() {
|
||||
let order = |need, ui| samples(need, ui).into_iter().map(|(c, _)| c).collect::<String>();
|
||||
let first_face = |need, ui| samples(need, ui).first().and_then(|(_, f)| f.first()).copied();
|
||||
let order = |need, ui| samples(need, ui, false).into_iter().map(|(c, _)| c).collect::<String>();
|
||||
let first_face = |need, ui| samples(need, ui, false).first().and_then(|(_, f)| f.first()).copied();
|
||||
assert_eq!(order(KANA | HAN | HANGUL, 0), "あ漢한");
|
||||
assert_eq!(order(KANA | HAN | HANGUL | BENGALI, language("bn")), "অあ漢한");
|
||||
assert_eq!(order(KANA | HAN | HANGUL, KANA | HAN), "あ漢한");
|
||||
@@ -220,7 +249,15 @@ mod tests {
|
||||
// A Chinese UI draws Han in a Chinese face first, Japanese names in it too.
|
||||
assert_eq!(order(KANA | HAN | HANGUL, HAN), "们あ한");
|
||||
assert_eq!(order(KANA | HAN, language("zh-Hans")), "们あ");
|
||||
assert!(["Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC"].iter().all(|f| samples(HAN, HAN)[0].1[..4].contains(f)));
|
||||
assert!(["Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC"].iter().all(|f| samples(HAN, HAN, false)[0].1[..4].contains(f)));
|
||||
// Traditional Chinese puts an installed Noto Sans CJK TC, then Taiwan's faces, first.
|
||||
let order_hant = |need, ui| samples(need, ui, true).into_iter().map(|(c, _)| c).collect::<String>();
|
||||
assert_eq!(order_hant(KANA | HAN, language("zh-Hant")), "們あ");
|
||||
assert_eq!(first_face(HAN, HAN), Some("Noto Sans CJK SC"));
|
||||
let hant = samples(HAN, language("zh-Hant"), true);
|
||||
assert_eq!(hant[0].0, '們');
|
||||
assert_eq!(hant[0].1[0], "Noto Sans CJK TC");
|
||||
assert!(["Microsoft JhengHei UI", "PingFang TC", "Noto Sans CJK TC"].iter().all(|f| hant[0].1.contains(f)));
|
||||
assert!(faces(0, "en", &|_| false).is_empty());
|
||||
assert!(faces(KANA | HANGUL | HAN | BENGALI, "ja", &|_| true).is_empty(), "nothing is looked up for scripts the UI draws");
|
||||
}
|
||||
|
||||
@@ -181,3 +181,35 @@ fn advanced_options_offer_the_bounding_box_for_text_layers() {
|
||||
assert!(shown.iter().any(|s| s == "Include bounding box"), "a rectangle that has it on shows it: {shown:?}");
|
||||
assert!(u.app.session.doc().unwrap().node(r).unwrap().exports[0].include_bounding_box, "the setting is kept");
|
||||
}
|
||||
|
||||
/// The advanced options of a PNG, JPG or WebP setting list its color profile: same as the file
|
||||
/// (naming the document's profile), sRGB or Display P3. SVG, PDF and XML settings list none.
|
||||
#[test]
|
||||
fn advanced_options_offer_a_color_profile_for_images() {
|
||||
let mut s = Session::new();
|
||||
let r = crate::present_tests::id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap());
|
||||
s.execute("export.add", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
let mut u = crate::layers_drag_tests::Ui::new(s);
|
||||
let options = |u: &mut crate::layers_drag_tests::Ui| {
|
||||
u.frame(vec![]);
|
||||
egui::Popup::open_id(&u.ctx, egui::Id::unique(("export_more", 0)));
|
||||
u.frame(vec![]);
|
||||
crate::import_report_tests::shown_texts(u.frame_output(vec![]).shapes)
|
||||
};
|
||||
for f in ["png", "jpg", "webp"] {
|
||||
u.app.run("export.update", json!({"ids": [r], "index": 0, "format": f}));
|
||||
let shown = options(&mut u);
|
||||
for label in ["Color profile", "Same as file (sRGB)", "sRGB", "Display P3"] {
|
||||
assert!(shown.iter().any(|s| s == label), "{f}: no {label:?} in {shown:?}");
|
||||
}
|
||||
}
|
||||
u.app.run("file.colorProfile", json!({"profile": "displayP3"}));
|
||||
let shown = options(&mut u);
|
||||
assert!(shown.iter().any(|s| s == "Same as file (Display P3)"), "{shown:?}");
|
||||
for f in ["svg", "pdf", "xml"] {
|
||||
u.app.run("export.update", json!({"ids": [r], "index": 0, "format": f}));
|
||||
let shown = options(&mut u);
|
||||
assert!(!shown.iter().any(|s| s == "Color profile" || s.starts_with("Same as file")), "{f}: {shown:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,15 +90,19 @@ const LATIN_AMERICA: &[&str] =
|
||||
|
||||
/// The [`LANGS`] index for one tag: the exact tag ("pt-BR"), else its base language ("es" for
|
||||
/// "es_ES.UTF-8"), else a regional catalog of that language ("pt-BR" for "pt-PT"); Latin American
|
||||
/// Spanish ("es-MX") reads es-419, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) none: it
|
||||
/// has no catalog and Simplified isn't a stand-in. A language the UI can't draw (CJK on the web
|
||||
/// without CJK faces built in) doesn't count.
|
||||
/// Spanish ("es-MX") reads es-419. A Chinese tag reads Traditional (zh-Hant) for `Hant`, `TW`, `HK`
|
||||
/// or `MO` and Simplified (zh-Hans) otherwise; an explicit `Hans` always reads Simplified. A
|
||||
/// language the UI can't draw (CJK on the web without CJK faces built in) doesn't count.
|
||||
fn resolve_one(want: &str) -> Option<usize> {
|
||||
let tag = want.trim().split(['.', '@']).next().unwrap_or("").replace('_', "-");
|
||||
let base = tag.split('-').next().unwrap_or("");
|
||||
let has = |subtags: &[&str]| tag.split('-').skip(1).any(|t| subtags.iter().any(|s| s.eq_ignore_ascii_case(t)));
|
||||
if base.eq_ignore_ascii_case("zh") && (has(&["Hant"]) || (!has(&["Hans"]) && has(&["TW", "HK", "MO"]))) {
|
||||
return None;
|
||||
let zh = |code: &str| {
|
||||
LANGS.iter().position(|l| l.code.eq_ignore_ascii_case(code)).filter(|i| LANGS.get(*i).is_some_and(|l| crate::cjk::can_show(l.code)))
|
||||
};
|
||||
if base.eq_ignore_ascii_case("zh") {
|
||||
let traditional = has(&["Hant"]) || (!has(&["Hans"]) && has(&["TW", "HK", "MO"]));
|
||||
return zh(if traditional { "zh-Hant" } else { "zh-Hans" });
|
||||
}
|
||||
let find = |t: &str| LANGS.iter().position(|l| l.code.eq_ignore_ascii_case(t));
|
||||
let alias = (base.eq_ignore_ascii_case("es") && has(LATIN_AMERICA)).then(|| find("es-419")).flatten();
|
||||
@@ -106,6 +110,7 @@ fn resolve_one(want: &str) -> Option<usize> {
|
||||
alias.or_else(|| find(&tag)).or_else(|| find(base)).or_else(regional).filter(|i| LANGS.get(*i).is_some_and(|l| crate::cjk::can_show(l.code)))
|
||||
}
|
||||
|
||||
/// Look `key` up in one of the sorted catalog tables.
|
||||
fn find(table: &'static [(&'static str, Text)], key: &str) -> Option<&'static Text> {
|
||||
table.binary_search_by(|(k, _)| (*k).cmp(key)).ok().and_then(|i| table.get(i)).map(|(_, t)| t)
|
||||
}
|
||||
@@ -194,7 +199,7 @@ fn plural(code: &str, n: usize) -> Plural {
|
||||
"bs" if n % 10 == 1 && n % 100 != 11 => Plural::One,
|
||||
"bs" if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) => Plural::Few,
|
||||
"bs" => Plural::Other,
|
||||
"ja" | "ko" | "th" | "zh-Hans" | "vi" => Plural::Other,
|
||||
"ja" | "ko" | "th" | "zh-Hans" | "zh-Hant" | "vi" => Plural::Other,
|
||||
"fr" | "pt-BR" if n <= 1 => Plural::One,
|
||||
_ if n == 1 => Plural::One,
|
||||
_ => Plural::Other,
|
||||
@@ -250,6 +255,8 @@ pub fn menu_item<'a>(id: &str, english: &'a str) -> Cow<'a, str> {
|
||||
label(true, id, english)
|
||||
}
|
||||
|
||||
/// A command's or menu item's label: the catalog wording from `chain`, keeping a dialog's trailing
|
||||
/// ellipsis when the English label has one.
|
||||
fn label<'a>(menu: bool, id: &str, english: &'a str) -> Cow<'a, str> {
|
||||
let text = chain(CURRENT.load(Ordering::Relaxed)).find_map(|l| menu.then(|| find(l.menu, id)).flatten().or_else(|| find(l.commands, id)));
|
||||
match text {
|
||||
|
||||
@@ -514,7 +514,7 @@ fn japanese_korean_chinese_and_vietnamese_have_no_plural_forms() {
|
||||
assert_eq!(code("system", Some("ja,en-US")), "ja");
|
||||
assert_eq!(LANGS[resolve("vi", None)].name, "Tiếng Việt");
|
||||
assert!(LANGS[resolve("vi", None)].draft);
|
||||
for code in ["ja", "ko", "th", "zh-Hans", "vi"] {
|
||||
for code in ["ja", "ko", "th", "zh-Hans", "zh-Hant", "vi"] {
|
||||
let lang = LANGS.iter().position(|l| l.code == code).unwrap();
|
||||
assert!(!plural_one(lang, 0) && !plural_one(lang, 1) && !plural_one(lang, 2), "{code}");
|
||||
let ones: Vec<_> = LANGS[lang].ui.iter().filter(|(_, t)| matches!(t, Text::Plural(Forms { one: Some(_), .. }))).map(|(k, _)| k).collect();
|
||||
@@ -607,17 +607,56 @@ fn the_built_in_georgian_face_draws_the_georgian_catalog() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_reads_simplified_and_traditional_has_no_catalog() {
|
||||
fn chinese_tags_pick_simplified_or_traditional() {
|
||||
let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code;
|
||||
for tag in ["zh-CN", "zh_CN.UTF-8", "zh-SG", "zh-Hans", "zh-Hans-CN", "zh-Hans-HK", "zh"] {
|
||||
assert_eq!(code("system", Some(tag)), "zh-Hans", "{tag}");
|
||||
}
|
||||
// No Traditional Chinese catalog: English, never Simplified; else the next OS language.
|
||||
for tag in ["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-TW", "zh_TW.UTF-8"] {
|
||||
assert_eq!(code("system", Some(tag)), "en", "{tag}");
|
||||
assert_eq!(code(tag, None), "en", "{tag}");
|
||||
// Taiwan, Hong Kong, Macau and Hant read Traditional Chinese, never Simplified.
|
||||
for tag in ["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-TW", "zh_TW.UTF-8", "zh-Hant-HK"] {
|
||||
assert_eq!(code("system", Some(tag)), "zh-Hant", "{tag}");
|
||||
assert_eq!(code(tag, None), "zh-Hant", "{tag}");
|
||||
}
|
||||
assert_eq!(code("system", Some("zh-TW,ja-JP")), "ja");
|
||||
// An explicit Hans still reads Simplified, whatever the region.
|
||||
assert_eq!(code("system", Some("zh-Hans-TW")), "zh-Hans");
|
||||
assert_eq!(code("system", Some("zh-TW,ja-JP")), "zh-Hant");
|
||||
assert_eq!(code("system", Some("ja-JP,zh-TW")), "ja");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traditional_chinese_is_a_draft_in_the_taiwan_design_terms() {
|
||||
let zh = LANGS.iter().position(|l| l.code == "zh-Hant").expect("locales/zh-Hant.toml is baked in");
|
||||
assert_eq!(LANGS[zh].name, "繁體中文(台灣)");
|
||||
assert!(LANGS[zh].draft, "a machine-drafted catalog stays a draft until a native speaker reviews it");
|
||||
// Spot checks for the Taiwan design terms the catalog is written in.
|
||||
assert!(matches!(lookup(zh, "Canvas"), Some(Text::Plain("畫布"))));
|
||||
assert!(matches!(lookup(zh, "Layers"), Some(Text::Plain("圖層"))));
|
||||
assert!(matches!(lookup(zh, "Frame"), Some(Text::Plain("框架"))));
|
||||
assert!(matches!(lookup(zh, "Component"), Some(Text::Plain("元件"))));
|
||||
assert!(matches!(lookup(zh, "Constraints"), Some(Text::Plain("約束"))));
|
||||
assert!(matches!(lookup(zh, "Fill"), Some(Text::Plain("填色"))));
|
||||
assert!(matches!(lookup(zh, "Stroke"), Some(Text::Plain("邊框"))));
|
||||
assert!(matches!(lookup(zh, "Export"), Some(Text::Plain("匯出"))));
|
||||
assert!(matches!(lookup(zh, "Version"), Some(Text::Plain("版本"))));
|
||||
assert!(matches!(lookup(zh, "Prototype"), Some(Text::Plain("原型"))));
|
||||
}
|
||||
|
||||
/// Changing the preference and reading it back keeps the Chinese tag; the tag still resolves to
|
||||
/// the same catalog after the preferences are saved and loaded (a restart).
|
||||
#[test]
|
||||
fn traditional_chinese_switches_and_the_preference_persists() {
|
||||
let mut s = figcraft_engine::Session::new();
|
||||
s.execute("pref.language", &serde_json::json!({"language": "zh-TW"})).unwrap();
|
||||
assert_eq!(s.prefs.language, "zh-TW");
|
||||
assert_eq!(LANGS[resolve(&s.prefs.language, None)].code, "zh-Hant");
|
||||
let saved = serde_json::to_string(&s.prefs).unwrap();
|
||||
let loaded: figcraft_engine::Prefs = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(loaded.language, "zh-TW");
|
||||
assert_eq!(LANGS[resolve(&loaded.language, None)].code, "zh-Hant");
|
||||
s.execute("pref.language", &serde_json::json!({"language": "zh-Hans"})).unwrap();
|
||||
assert_eq!(LANGS[resolve(&s.prefs.language, None)].code, "zh-Hans");
|
||||
s.execute("pref.language", &serde_json::json!({"language": "zh-Hant"})).unwrap();
|
||||
assert_eq!(LANGS[resolve(&s.prefs.language, None)].code, "zh-Hant");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Long and deeply nested names in the Layers panel (#1904), drawn headless: the list is as wide
|
||||
//! as its widest name needs and scrolls sideways, so a name reads in full without widening the
|
||||
//! sidebar, as the reference app's Layers panel does with its horizontal scrollbar; names that
|
||||
//! fit leave the list as wide as the panel; a name past the width cap (#1650) still elides.
|
||||
|
||||
use egui::{Event, MouseWheelUnit, Pos2, Shape, TouchPhase, Vec2, vec2};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
fn id(v: serde_json::Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// A rectangle named `name` on the page, or inside `parent`.
|
||||
fn named(s: &mut Session, name: &str, parent: Option<NodeId>) -> NodeId {
|
||||
let mut p = json!({"x": 0, "y": 0, "width": 20, "height": 20, "props": {"name": name}});
|
||||
if let Some(parent) = parent {
|
||||
p["parent"] = json!(parent);
|
||||
}
|
||||
id(s.execute("shape.rectangle", &p).unwrap())
|
||||
}
|
||||
|
||||
/// The painted text starting with `prefix`: (whether it was elided, its position, its size).
|
||||
fn painted(u: &mut Ui, prefix: &str) -> (bool, Pos2, Vec2) {
|
||||
let shapes = crate::live_tests::flat(&u.frame_output(vec![]).shapes);
|
||||
shapes
|
||||
.iter()
|
||||
.find_map(|s| match s {
|
||||
Shape::Text(t) if t.galley.text().starts_with(prefix) => Some((t.galley.elided, t.pos, t.galley.size())),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no text starting {prefix:?}"))
|
||||
}
|
||||
|
||||
/// Turn the wheel sideways by `dx` points over `at`, and let the scroll settle.
|
||||
fn wheel_sideways(u: &mut Ui, at: Pos2, dx: f32) {
|
||||
let wheel = Event::MouseWheel {
|
||||
unit: MouseWheelUnit::Point,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
delta: vec2(-dx, 0.0),
|
||||
phase: TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
};
|
||||
u.frame(vec![Event::PointerMoved(at), wheel]);
|
||||
for _ in 0..30 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
const LONG: &str = "Size=small, Type=primary, State=default, Icon=leading, Width=full";
|
||||
|
||||
#[test]
|
||||
fn a_long_name_is_painted_in_full_and_the_list_scrolls_sideways() {
|
||||
let mut s = Session::new();
|
||||
let long = named(&mut s, LONG, None);
|
||||
named(&mut s, "Short", None);
|
||||
let mut u = Ui::new(s);
|
||||
let (elided, p, size) = painted(&mut u, "Size=small");
|
||||
assert!(!elided, "the whole name, not an elided one");
|
||||
// The panel is 240 pt wide: the name runs past it, into the space a sideways scroll shows.
|
||||
assert!(p.x + size.x > 320.0, "the name runs past the panel: {} + {}", p.x, size.x);
|
||||
let at = u.row_at(long);
|
||||
wheel_sideways(&mut u, at, 120.0);
|
||||
let (_, scrolled, _) = painted(&mut u, "Size=small");
|
||||
assert!(scrolled.x <= p.x - 100.0, "the list scrolled sideways: {} -> {}", p.x, scrolled.x);
|
||||
// Short names move with it (one list), not on their own.
|
||||
let (_, short, _) = painted(&mut u, "Short");
|
||||
assert!(short.x < 100.0, "the short name moved with the list: {}", short.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deeply_nested_name_gets_its_indent_too() {
|
||||
let mut s = Session::new();
|
||||
// A name that fits at the top level, under five frames: the indent pushes it past the panel.
|
||||
let mut parent = None;
|
||||
for i in 0..5 {
|
||||
let f = id(s
|
||||
.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100, "parent": parent, "props": {"name": format!("Frame {i}")}}))
|
||||
.unwrap());
|
||||
parent = Some(f);
|
||||
}
|
||||
let leaf = named(&mut s, "Component set original (não usar)", parent);
|
||||
s.execute("select.set", &json!({"ids": [leaf]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
let (elided, p, size) = painted(&mut u, "Component set original");
|
||||
assert!(!elided, "the nested name reads in full");
|
||||
assert!(p.x + size.x > 320.0, "it runs past the panel: {} + {}", p.x, size.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_that_fit_leave_the_list_as_wide_as_the_panel() {
|
||||
let mut s = Session::new();
|
||||
let short = named(&mut s, "Short", None);
|
||||
let mut u = Ui::new(s);
|
||||
let (elided, p, _) = painted(&mut u, "Short");
|
||||
assert!(!elided);
|
||||
let at = u.row_at(short);
|
||||
wheel_sideways(&mut u, at, 120.0);
|
||||
let (_, after, _) = painted(&mut u, "Short");
|
||||
assert_eq!(after.x, p.x, "nothing to scroll to sideways");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_past_the_room_cap_still_elides() {
|
||||
let mut s = Session::new();
|
||||
let name = "Layer ".repeat(500);
|
||||
named(&mut s, &name, None);
|
||||
let mut u = Ui::new(s);
|
||||
let (elided, _, size) = painted(&mut u, "Layer Layer");
|
||||
assert!(elided, "a 3000-character name elides");
|
||||
assert!(size.x <= crate::layers::NAME_ROOM + 1.0, "at the cap, not thousands of points wide: {}", size.x);
|
||||
}
|
||||
|
||||
/// Review: the widest name isn't always the longest in bytes. Fifty rows of narrow letters outrank
|
||||
/// one row of wide ones by the bound; it is laid out all the same, so it reads in full.
|
||||
#[test]
|
||||
fn a_wide_name_among_many_long_narrow_ones_is_still_measured() {
|
||||
let mut s = Session::new();
|
||||
for _ in 0..50 {
|
||||
named(&mut s, &"i".repeat(100), None);
|
||||
}
|
||||
named(&mut s, &"W".repeat(70), None);
|
||||
let mut u = Ui::new(s);
|
||||
let (elided, ..) = painted(&mut u, "WWWW");
|
||||
assert!(!elided, "the wide name reads in full");
|
||||
}
|
||||
|
||||
/// Review: a name is laid out as it is painted, on one line, so line breaks in it don't make it
|
||||
/// measure narrower than it paints.
|
||||
#[test]
|
||||
fn a_name_with_line_breaks_is_measured_as_painted() {
|
||||
let mut s = Session::new();
|
||||
named(&mut s, "Size=small,\nType=primary,\nState=default,\nIcon=leading,\nWidth=full", None);
|
||||
let mut u = Ui::new(s);
|
||||
let (elided, ..) = painted(&mut u, "Size=small,");
|
||||
assert!(!elided, "the whole name on one line");
|
||||
}
|
||||
|
||||
/// Review: a wide row runs on under the canvas; a dragged layer released there is no drop.
|
||||
#[test]
|
||||
fn a_drag_released_over_the_canvas_drops_nowhere() {
|
||||
let mut s = Session::new();
|
||||
let wide = named(&mut s, &"Size=small, Type=primary, ".repeat(6), None);
|
||||
let short = named(&mut s, "Short", None);
|
||||
let page = s.page().unwrap();
|
||||
let order = s.doc().unwrap().node(page).unwrap().children.clone();
|
||||
let mut u = Ui::new(s);
|
||||
let from = u.row_at(short);
|
||||
let level = u.row_at(wide).y;
|
||||
// Over the canvas, level with the wide row (which runs on under it).
|
||||
let over_canvas = Pos2::new(800.0, level);
|
||||
u.drag_to(from, over_canvas);
|
||||
u.button(over_canvas, false);
|
||||
for _ in 0..5 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
assert_eq!(u.app.session.doc().unwrap().node(page).unwrap().children, order, "no reorder");
|
||||
}
|
||||
@@ -146,6 +146,8 @@ mod layers_keys_tests;
|
||||
#[cfg(test)]
|
||||
mod layers_rename_tests;
|
||||
#[cfg(test)]
|
||||
mod layers_wide_names_tests;
|
||||
#[cfg(test)]
|
||||
mod library_beacon_tests;
|
||||
#[cfg(test)]
|
||||
mod live_chat_tests;
|
||||
|
||||
@@ -112,3 +112,61 @@ fn shut_pair_fields_edit_their_own_axis() {
|
||||
u.frame(vec![]);
|
||||
assert_eq!(padding(&u, f), [7.0, 20.0, 7.0, 20.0]);
|
||||
}
|
||||
|
||||
/// A press and release at `p` with Cmd/Ctrl held (Alt is the field's own scrub modifier).
|
||||
fn command_click(u: &mut Ui, p: Pos2) {
|
||||
let m = Modifiers::COMMAND;
|
||||
let press = |pressed: bool| Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: m };
|
||||
u.frame_with(vec![Event::PointerMoved(p)], m);
|
||||
u.frame_with(vec![press(true)], m);
|
||||
u.frame_with(vec![press(false)], m);
|
||||
u.frame(vec![]);
|
||||
}
|
||||
|
||||
/// A value typed
|
||||
/// after a Cmd/Ctrl-click on any padding field goes to all four sides in one undo step; a plain
|
||||
/// click keeps editing that side or axis alone.
|
||||
#[test]
|
||||
fn a_command_click_on_a_padding_field_edits_all_sides() {
|
||||
let (mut u, f) = scene();
|
||||
u.app.run("node.set", json!({"ids": [f], "props": {"paddingLeft": 16.0, "paddingRight": 54.0}}));
|
||||
assert_eq!(padding(&u, f), [10.0, 54.0, 10.0, 16.0]);
|
||||
// Cmd/Ctrl-click and Tab away without typing: nothing is committed, to any side.
|
||||
let before = u.app.session.undo_depth().0;
|
||||
let v = named_field(&mut u, "Vertical padding");
|
||||
command_click(&mut u, v);
|
||||
u.frame(vec![
|
||||
Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE },
|
||||
Event::Key { key: Key::Tab, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(padding(&u, f), [10.0, 54.0, 10.0, 16.0], "an untouched field commits nothing");
|
||||
assert_eq!(u.app.session.undo_depth().0, before);
|
||||
// The shut pair: Cmd/Ctrl-click Vertical padding and type 7: every side is 7.
|
||||
let history = u.app.session.undo_depth().0;
|
||||
let v = named_field(&mut u, "Vertical padding");
|
||||
command_click(&mut u, v);
|
||||
u.frame(vec![Event::Text("7".into())]);
|
||||
enter(&mut u);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(padding(&u, f), [7.0; 4]);
|
||||
assert_eq!(u.app.session.undo_depth().0, history + 1, "one undo step");
|
||||
// A plain click afterwards edits its own axis again.
|
||||
let v = named_field(&mut u, "Vertical padding");
|
||||
click(&mut u, v);
|
||||
u.frame(vec![Event::Text("9".into())]);
|
||||
enter(&mut u);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(padding(&u, f), [9.0, 7.0, 9.0, 7.0]);
|
||||
// Individual padding open: Cmd/Ctrl-click the left field and type 3: every side is 3.
|
||||
let t = named_button(&mut u, "Individual padding");
|
||||
click(&mut u, t);
|
||||
let left = u.ctx.read_response(Id::unique(("padding", 3)).with("field")).expect("left field shows").rect.center();
|
||||
command_click(&mut u, left);
|
||||
u.frame(vec![Event::Text("3".into())]);
|
||||
enter(&mut u);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(padding(&u, f), [3.0; 4]);
|
||||
u.app.run("edit.undo", json!({}));
|
||||
assert_eq!(padding(&u, f), [9.0, 7.0, 9.0, 7.0], "undone as one step");
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use figcraft_doc::{Document, ExportFormat, ExportSetting, ExportSize, NodeId, NodeKind, PrintSettings, VdCompat};
|
||||
use figcraft_doc::color::ColorProfile;
|
||||
use figcraft_doc::{Document, ExportColorProfile, ExportFormat, ExportSetting, ExportSize, NodeId, NodeKind, PrintSettings, VdCompat};
|
||||
use figcraft_engine::cmd::file::export_jobs;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -69,7 +70,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, sel: &[NodeId
|
||||
widgets::note(ui, tr("Click + to replace mixed content"), t.text_secondary);
|
||||
}
|
||||
for (i, e) in list.iter().enumerate() {
|
||||
setting_row(app, ui, &ids, i, e, text);
|
||||
setting_row(app, ui, &ids, i, e, text, doc.color_profile);
|
||||
}
|
||||
let Some(first) = first else { return };
|
||||
preview(app, ui, doc, first);
|
||||
@@ -84,8 +85,8 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, sel: &[NodeId
|
||||
|
||||
/// One export setting: size (typed or picked; images only, SVG and PDF are vector), suffix,
|
||||
/// format, advanced options and remove; a PDF's print settings under it. `text`: every layer in
|
||||
/// `ids` is a text layer.
|
||||
fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &ExportSetting, text: bool) {
|
||||
/// `ids` is a text layer; `file`: the document's color profile.
|
||||
fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &ExportSetting, text: bool, file: ColorProfile) {
|
||||
row(ui, 32.0, |ui| {
|
||||
// Tighter spacing so the advanced options and remove buttons fit beside the fields.
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
@@ -129,7 +130,7 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo
|
||||
.width(216.0)
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.show(|ui| {
|
||||
if let Some(mut params) = advanced_options(ui, e, text) {
|
||||
if let Some(mut params) = advanced_options(ui, e, text, file) {
|
||||
params["ids"] = json!(ids);
|
||||
params["index"] = json!(i);
|
||||
app.run("export.update", params);
|
||||
@@ -161,8 +162,9 @@ const VD_COMPAT: [(VdCompat, &str); 3] =
|
||||
/// A setting's advanced options: ignore overlapping layers (`contentsOnly`), include bounding box
|
||||
/// for text layers (and for any setting that has it on, so it can be turned off); for SVG outline
|
||||
/// text, the id attribute and simplified strokes; for XML (Android Vector Drawable) the id attribute
|
||||
/// (as `android:name`) and the compatibility mode. Returns the changed option as command params.
|
||||
fn advanced_options(ui: &mut Ui, e: &ExportSetting, text: bool) -> Option<Value> {
|
||||
/// (as `android:name`) and the compatibility mode; for PNG, JPG and WebP the color profile (`file`
|
||||
/// is the document's). Returns the changed option as command params.
|
||||
fn advanced_options(ui: &mut Ui, e: &ExportSetting, text: bool, file: ColorProfile) -> Option<Value> {
|
||||
let mut out = None;
|
||||
let mut option = |ui: &mut Ui, key: &str, on: bool, label: &str| {
|
||||
if widgets::checkbox(ui, on, label) {
|
||||
@@ -189,9 +191,28 @@ fn advanced_options(ui: &mut Ui, e: &ExportSetting, text: bool) -> Option<Value>
|
||||
}
|
||||
}
|
||||
}
|
||||
if e.format.is_raster() {
|
||||
ui.add_space(4.0);
|
||||
widgets::label(ui, tr("Color profile"), Tokens::get(ui.ctx()).text_secondary);
|
||||
for p in ExportColorProfile::ALL {
|
||||
let on = e.color_profile == p;
|
||||
if widgets::radio(ui, on, &color_profile_label(p, file)) && !on {
|
||||
out = Some(json!({"colorProfile": p.key()}));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// How the advanced options name an export color profile: "Same as file (Display P3)" names the
|
||||
/// profile of `file`, the document's.
|
||||
fn color_profile_label(p: ExportColorProfile, file: ColorProfile) -> String {
|
||||
match p {
|
||||
ExportColorProfile::SameAsFile => trf("Same as file ({profile})", &[("profile", &file.label())]),
|
||||
p => p.resolve(file).label().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF setting's print settings: a summary row that opens them.
|
||||
fn print_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &PrintSettings) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
|
||||
@@ -492,6 +492,8 @@ struct RowCache {
|
||||
expanded: u64,
|
||||
filter: String,
|
||||
rows: Arc<Vec<Row>>,
|
||||
/// The width the widest row needs to show its whole name ([`widest_row`]).
|
||||
width: f32,
|
||||
}
|
||||
|
||||
fn hash_expanded(expanded: &BTreeSet<NodeId>) -> u64 {
|
||||
@@ -504,15 +506,71 @@ fn hash_expanded(expanded: &BTreeSet<NodeId>) -> u64 {
|
||||
/// The rows to list: the expanded tree, or the layers matching `filter` (lowercase).
|
||||
pub(crate) fn rows_for(ui: &Ui, doc: &Arc<Document>, page: NodeId, expanded: &BTreeSet<NodeId>, filter: &str) -> Arc<Vec<Row>> {
|
||||
let ex = if filter.is_empty() { hash_expanded(expanded) } else { 0 };
|
||||
let id = Id::unique("layer_rows");
|
||||
let fresh = ui
|
||||
.data(|d| d.get_temp::<RowCache>(id))
|
||||
.filter(|c| c.doc.as_ref().is_some_and(|d| Arc::ptr_eq(d, doc)) && c.page == Some(page) && c.expanded == ex && c.filter == filter);
|
||||
if let Some(c) = fresh {
|
||||
return c.rows;
|
||||
}
|
||||
let rows = Arc::new(listed_rows(doc, page, expanded, filter));
|
||||
// Outside the context's data lock: laying out the names takes the context too.
|
||||
let width = widest_row(ui, doc, page, &rows);
|
||||
ui.data_mut(|d| {
|
||||
let c = d.get_temp_mut_or_default::<RowCache>(Id::unique("layer_rows"));
|
||||
let fresh = c.doc.as_ref().is_some_and(|d| Arc::ptr_eq(d, doc)) && c.page == Some(page) && c.expanded == ex && c.filter == filter;
|
||||
if !fresh {
|
||||
let rows = listed_rows(doc, page, expanded, filter);
|
||||
*c = RowCache { doc: Some(doc.clone()), page: Some(page), expanded: ex, filter: filter.to_string(), rows: Arc::new(rows) };
|
||||
d.insert_temp(id, RowCache { doc: Some(doc.clone()), page: Some(page), expanded: ex, filter: filter.to_string(), rows: rows.clone(), width })
|
||||
});
|
||||
rows
|
||||
}
|
||||
|
||||
/// The width the rows [`rows_for`] last listed need to show their names in full, from the
|
||||
/// panel's left edge (0 before any were listed).
|
||||
pub(crate) fn list_width(ui: &Ui) -> f32 {
|
||||
ui.data(|d| d.get_temp::<RowCache>(Id::unique("layer_rows"))).map_or(0.0, |c| c.width)
|
||||
}
|
||||
|
||||
/// A name's room on a row stops growing here: a name of thousands of characters (#1650) elides
|
||||
/// past it instead of making the list that wide.
|
||||
pub(crate) const NAME_ROOM: f32 = 1200.0;
|
||||
|
||||
/// A row's name: the layer's, or its kind's label when it has none.
|
||||
fn row_name(n: &figcraft_doc::Node) -> &str {
|
||||
if n.name.is_empty() { tr(n.kind.label()) } else { &n.name }
|
||||
}
|
||||
|
||||
/// The font a row's name is drawn in: top-level frames and sections stand out.
|
||||
fn name_font(doc: &Document, page: NodeId, n: &figcraft_doc::Node) -> egui::FontId {
|
||||
let top_level = doc.node(n.parent.unwrap_or(page)).is_some_and(|p| p.is_page());
|
||||
if top_level && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) { semibold(11.0) } else { regular(11.0) }
|
||||
}
|
||||
|
||||
/// The width the widest of `rows` needs to show its whole name, from the panel's left edge: the
|
||||
/// row's margins, its indent, the disclosure triangle and icon, the name in its font (up to
|
||||
/// [`NAME_ROOM`]) and the eye and padlock's room. The list is made this wide, so it scrolls
|
||||
/// sideways when a name doesn't fit the panel (#1904). A name's bytes at the widest glyph bound
|
||||
/// its width, and the rows are laid out in descending order of that bound until the bound falls
|
||||
/// under the widest found, so no name that could be the widest is skipped: a list with one long
|
||||
/// name lays out a handful, a list of names all alike lays them all out, through egui's galley
|
||||
/// cache. A name is laid out as it is painted: on one line, cut at [`NAME_ROOM`].
|
||||
fn widest_row(ui: &Ui, doc: &Document, page: NodeId, rows: &[Row]) -> f32 {
|
||||
// What sits left of the name (the row's margin, the indent's start, the triangle and icon)
|
||||
// and right of it (the eye and padlock, the margin): see the row loop in `layers`.
|
||||
const AROUND: f32 = 8.0 + 4.0 + 36.0 + 56.0 + 8.0;
|
||||
// No glyph of the 11 pt fonts is wider than this: a name's byte count bounds its width.
|
||||
const GLYPH: f32 = 12.0;
|
||||
let indent = |r: &Row| r.depth as f32 * 16.0;
|
||||
let mut bounds: Vec<(f32, usize)> =
|
||||
rows.iter().enumerate().filter_map(|(i, r)| doc.node(r.id).map(|n| (indent(r) + row_name(n).len() as f32 * GLYPH, i))).collect();
|
||||
bounds.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
let mut widest = 0.0f32;
|
||||
for (bound, i) in bounds {
|
||||
if bound <= widest {
|
||||
break;
|
||||
}
|
||||
c.rows.clone()
|
||||
})
|
||||
let Some((r, n)) = rows.get(i).and_then(|r| doc.node(r.id).map(|n| (r, n))) else { continue };
|
||||
let w = crate::bidi::name_galley(ui.painter(), row_name(n), name_font(doc, page, n), Color32::WHITE, NAME_ROOM).size().x;
|
||||
widest = widest.max(indent(r) + w);
|
||||
}
|
||||
widest + AROUND
|
||||
}
|
||||
|
||||
/// The rows to list, uncached: the expanded tree, or the layers matching `filter` (lowercase).
|
||||
@@ -1030,6 +1088,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||
let reveal = reveal_selection(app, &doc, page, &sel);
|
||||
let filter = app.ui.layer_filter.trim().to_lowercase();
|
||||
let rows = rows_for(ui, &doc, page, &app.ui.expanded, &filter);
|
||||
// The rows are as wide as the panel, or as the widest name needs: the list then scrolls
|
||||
// sideways too, so long and deeply nested names read in full without widening the sidebar,
|
||||
// as the reference app's Layers panel does with its horizontal scrollbar (#1904).
|
||||
let row_w = w.max(list_width(ui));
|
||||
let mut scroll_to = if reveal { sel.first().and_then(|s| rows.iter().position(|r| r.id == *s)) } else { None };
|
||||
// A rename in progress ends when its layer isn't listed any more; Tab / Shift+Tab commit it and
|
||||
// go on to the next / previous row.
|
||||
@@ -1071,13 +1133,17 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||
// The drag is over a row that takes no drop: the not-allowed cursor shows.
|
||||
let mut refused = false;
|
||||
// Only the rows in view are laid out and drawn; the rest is empty scroll space.
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show_viewport(ui, |ui, viewport| {
|
||||
egui::ScrollArea::both().auto_shrink([false, false]).show_viewport(ui, |ui, viewport| {
|
||||
let pitch = ROW + ui.spacing().item_spacing.y;
|
||||
let top = ui.max_rect().top();
|
||||
let left = ui.max_rect().left();
|
||||
let total = rows.len() as f32 * pitch;
|
||||
let row_w = row_w.max(viewport.width());
|
||||
ui.set_height(total.max(viewport.height()));
|
||||
ui.set_width(row_w);
|
||||
if let Some(i) = scroll_to {
|
||||
ui.scroll_to_rect(Rect::from_min_size(pos2(ui.max_rect().left(), top + i as f32 * pitch), vec2(w, ROW)), None);
|
||||
// The row at the width in view: revealing a layer scrolls up or down, not sideways.
|
||||
ui.scroll_to_rect(Rect::from_min_size(pos2(left + viewport.min.x, top + i as f32 * pitch), vec2(viewport.width(), ROW)), None);
|
||||
}
|
||||
if rows.is_empty() {
|
||||
ui.add_space(8.0);
|
||||
@@ -1095,14 +1161,14 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||
let selected_ids: BTreeSet<_> = sel.iter().copied().collect();
|
||||
let roots_start = first.saturating_sub(1);
|
||||
let roots: Vec<_> = rows.iter().take(last.saturating_add(1)).skip(roots_start).map(|r| selection_root(&doc, &selected_ids, r.id)).collect();
|
||||
let band = Rect::from_x_y_ranges(ui.max_rect().x_range(), (top + first as f32 * pitch)..=(top + last as f32 * pitch));
|
||||
let band = Rect::from_x_y_ranges(left..=(left + row_w), (top + first as f32 * pitch)..=(top + last as f32 * pitch));
|
||||
let mut band_ui = ui.new_child(egui::UiBuilder::new().max_rect(band));
|
||||
// Stable widget ids for a row wherever the list is scrolled.
|
||||
band_ui.skip_ahead_auto_ids(first);
|
||||
let ui = &mut band_ui;
|
||||
for (i, row) in rows.iter().enumerate().take(last).skip(first) {
|
||||
let Some(n) = doc.node(row.id) else { continue };
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), Sense::click_and_drag());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(row_w, ROW), Sense::click_and_drag());
|
||||
let selected = selected_ids.contains(&row.id);
|
||||
a11y::layer_row(&mut app.ui.nav, &resp);
|
||||
if sel.last() == Some(&row.id) {
|
||||
@@ -1158,12 +1224,11 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||
}
|
||||
} else {
|
||||
let col = row_color(is_comp, shown, t.text, t.text_tertiary);
|
||||
let top_level = doc.node(n.parent.unwrap_or(page)).is_some_and(|p| p.is_page());
|
||||
let font =
|
||||
if top_level && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) { semibold(11.0) } else { regular(11.0) };
|
||||
let name = if n.name.is_empty() { tr(n.kind.label()).to_string() } else { n.name.clone() };
|
||||
// Room left of the lock and eye buttons.
|
||||
let max_w = (rr.max.x - 56.0 - name_x).max(20.0);
|
||||
let font = name_font(&doc, page, n);
|
||||
let name = row_name(n).to_string();
|
||||
// Room left of the lock and eye buttons (the row is wide enough for the whole
|
||||
// name, up to `NAME_ROOM`: a row widened by a deeper name gives no more).
|
||||
let max_w = (rr.max.x - 56.0 - name_x).clamp(20.0, NAME_ROOM);
|
||||
crate::bidi::paint_name(ui.painter(), pos2(name_x, r.center().y), &name, font, col, max_w);
|
||||
}
|
||||
let (v, l) = eye_lock(ui, &t, [r, rr], row, n, over && !dev, dev, &mut sweep);
|
||||
@@ -1187,7 +1252,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||
if resp.drag_started() && !crate::menus::dev_mode_refuses(app, "node.reparent") {
|
||||
ui.data_mut(|d| d.insert_temp(drag_id, row.id));
|
||||
}
|
||||
let over = dragging.is_some() && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p));
|
||||
// In the row and in the list's view: a wide row runs on under the canvas (#1904), and a
|
||||
// drag released there lands nowhere.
|
||||
let clip = ui.clip_rect();
|
||||
let over = dragging.is_some() && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p) && clip.contains(p));
|
||||
// What is inside the dragged layers, and inside an instance, takes no drop.
|
||||
refused |= over
|
||||
&& (doc.ancestors(row.id).iter().any(|a| moving.contains(a))
|
||||
@@ -1196,6 +1264,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||
&& !blocked(row.id)
|
||||
&& let Some(p) = ui.input(|i| i.pointer.hover_pos())
|
||||
&& r.contains(p)
|
||||
&& clip.contains(p)
|
||||
{
|
||||
let collapsed = row.has_kids && !app.ui.expanded.contains(&row.id);
|
||||
let (zone, open, released) = drop_zone(ui, p, [r, rr], x0, row.id, collapsed, n);
|
||||
@@ -1211,7 +1280,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||
if let Some((i, id, depth)) = rename_row.filter(|(i, ..)| !(first..last).contains(i))
|
||||
&& let Some(n) = doc.node(id)
|
||||
{
|
||||
let r = Rect::from_min_size(pos2(band.min.x, top + i as f32 * pitch), vec2(w, ROW));
|
||||
let r = Rect::from_min_size(pos2(band.min.x, top + i as f32 * pitch), vec2(row_w, ROW));
|
||||
if let Some(new) = rename_field(app, ui, id, depth, &n.name, r) {
|
||||
rename_done = Some((id, new));
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::i18n::{tr, tr_noop};
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::right::{block, common, menu_item, menu_popup, row};
|
||||
use crate::theme::{ACCENT, Tokens, regular};
|
||||
use crate::widgets::{self, FIELD_H, Lead};
|
||||
use crate::widgets::{self, FIELD_H, FieldEdit, Lead};
|
||||
|
||||
/// Most tracks listed in the panel (the rest are still laid out; edit them through commands).
|
||||
const MAX_TRACK_ROWS: usize = 24;
|
||||
@@ -416,6 +416,15 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit a padding field's edit: a value typed after a Cmd/Ctrl-click goes to all four sides
|
||||
/// (Guide to auto layout); otherwise to the field's own side or axis.
|
||||
fn padding_field(app: &mut FigApp, ui: &Ui, ids: &[NodeId], key: &str, id: Id, e: Option<FieldEdit>) {
|
||||
match e {
|
||||
Some(FieldEdit::Set(v)) if widgets::edit_began_with_command(ui, id) => app.set_props(ids, json!({"padding": v.max(0.0)})),
|
||||
e => app.field(ids, key, e, |v| json!(v.max(0.0))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Spacing row: the H/V padding pair, or, with Individual padding on, two rows of one field
|
||||
/// per side (like Individual corners).
|
||||
fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
|
||||
@@ -439,9 +448,9 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
|
||||
for (r, pair) in sides.chunks(2).enumerate() {
|
||||
row(ui, 32.0, |ui| {
|
||||
for (key, name, icon, v, k) in pair {
|
||||
let e =
|
||||
crate::variable_ui::number_field(app, ui, ids, key, Id::unique(("padding", *k)), Lead::Icon(*icon, name), Some(*v), "", col);
|
||||
app.field(ids, key, e, |v| json!(v.max(0.0)));
|
||||
let id = Id::unique(("padding", *k));
|
||||
let e = crate::variable_ui::number_field(app, ui, ids, key, id, Lead::Icon(*icon, name), Some(*v), "", col);
|
||||
padding_field(app, ui, ids, key, id, e);
|
||||
}
|
||||
if r == 0 {
|
||||
toggle(ui);
|
||||
@@ -461,7 +470,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
|
||||
"",
|
||||
col,
|
||||
);
|
||||
app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0)));
|
||||
padding_field(app, ui, ids, "paddingHorizontal", Id::unique("al_padh"), e);
|
||||
let e = crate::variable_ui::number_field(
|
||||
app,
|
||||
ui,
|
||||
@@ -473,7 +482,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
|
||||
"",
|
||||
col,
|
||||
);
|
||||
app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0)));
|
||||
padding_field(app, ui, ids, "paddingVertical", Id::unique("al_padv"), e);
|
||||
toggle(ui);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Params the panels build at run time name only what their commands take: a name a command
|
||||
//! doesn't list is refused (#1413), which would break the control that sent it.
|
||||
|
||||
use figcraft_doc::{ExportFormat, ExportSetting, NodeId, PrintSettings, VdCompat};
|
||||
use figcraft_doc::{ExportColorProfile, ExportFormat, ExportSetting, NodeId, PrintSettings, VdCompat};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -30,6 +30,7 @@ fn export_settings_with_every_option_are_taken() {
|
||||
svg_id_attribute: true,
|
||||
svg_simplify_stroke: false,
|
||||
vd_compat: VdCompat::Exact,
|
||||
color_profile: ExportColorProfile::DisplayP3,
|
||||
quality: 70,
|
||||
..ExportSetting::default()
|
||||
};
|
||||
|
||||
@@ -186,6 +186,12 @@ pub fn number_field(ui: &mut Ui, id: Id, lead: Lead, value: Option<f64>, suffix:
|
||||
number_field_mixed(ui, id, lead, value, suffix, width, step)?.settled()
|
||||
}
|
||||
|
||||
/// Whether the edit of number field `id` began with a Cmd/Ctrl-click. A padding field edited
|
||||
/// that way sets every side (Guide to auto layout, Keyboard shortcut guide).
|
||||
pub fn edit_began_with_command(ui: &Ui, id: Id) -> bool {
|
||||
ui.data(|d| d.get_temp::<bool>(id.with("cmdstart"))).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// [`number_field`] over several layers: without a single value (`value` None, "Mixed") a relative
|
||||
/// edit comes back as one for each layer ([`Edit::Each`]).
|
||||
pub fn number_field_mixed(ui: &mut Ui, id: Id, lead: Lead, value: Option<f64>, suffix: &str, width: f32, step: f64) -> Option<Edit> {
|
||||
@@ -361,14 +367,28 @@ fn number_field_impl(
|
||||
// The text the field would commit to no effect: what it showed when the edit began, or the
|
||||
// value the arrows last stepped it to.
|
||||
let kept_id = id.with("kept");
|
||||
// Whether Cmd/Ctrl was held while the button was down on the field: a click's modifier is
|
||||
// read on its press, so letting go of the key before the release still counts.
|
||||
let cmd_press_id = id.with("cmdpress");
|
||||
let cmd_held_before = ui.data(|d| d.get_temp::<bool>(cmd_press_id)).unwrap_or(false);
|
||||
let (cmd_pressed, any_down) = ui.input(|i| (field_resp.is_pointer_button_down_on() && i.modifiers.command, i.pointer.any_down()));
|
||||
ui.data_mut(|d| d.insert_temp(cmd_press_id, cmd_pressed || (cmd_held_before && any_down)));
|
||||
if started {
|
||||
editing = Some(if typed_now { String::new() } else { value.map(fmt_num).unwrap_or_default() });
|
||||
// Whether Cmd/Ctrl was held on the click that began the edit: a padding field then sets
|
||||
// every side ([`edit_began_with_command`]). The flag lives as long as the edit.
|
||||
let with_command = !typed_now && (cmd_pressed || cmd_held_before || ui.input(|i| i.modifiers.command));
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(editing_id, editing.clone().unwrap_or_default());
|
||||
d.insert_temp(kept_id, value.map(fmt_num).unwrap_or_default());
|
||||
d.insert_temp(id.with("selectall"), !typed_now);
|
||||
d.insert_temp(id.with("cmdstart"), with_command);
|
||||
d.remove::<bool>(id.with("edited"));
|
||||
});
|
||||
ui.memory_mut(|m| m.request_focus(id.with("te")));
|
||||
} else if editing.is_none() {
|
||||
// No edit under way: an arrow step on the focused field is its own side's.
|
||||
ui.data_mut(|d| d.remove::<bool>(id.with("cmdstart")));
|
||||
}
|
||||
match editing {
|
||||
Some(mut text) => {
|
||||
@@ -382,6 +402,11 @@ fn number_field_impl(
|
||||
// top would move its baseline below the centered value painted while idle.
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(edit_rect));
|
||||
let r = child.add(te);
|
||||
if r.changed() {
|
||||
// Typing (the same value again included) is what lets a Cmd/Ctrl-begun edit commit
|
||||
// to every side; a field left as it was commits nothing.
|
||||
ui.data_mut(|d| d.insert_temp(id.with("edited"), true));
|
||||
}
|
||||
a11y::own_focus(&r);
|
||||
keep_focus(&r, started);
|
||||
crate::shortcuts::release_field_for_save(&r);
|
||||
@@ -408,6 +433,7 @@ fn number_field_impl(
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(kept_id, text.clone());
|
||||
d.insert_temp(id.with("selectall"), true);
|
||||
d.insert_temp(id.with("edited"), true);
|
||||
});
|
||||
out = Some(set(nv));
|
||||
}
|
||||
@@ -423,7 +449,11 @@ fn number_field_impl(
|
||||
// Enter, Tab or a click elsewhere commits what was typed (a field left as it was
|
||||
// commits nothing: no undo step, and a rounded display isn't written back).
|
||||
let kept: String = ui.data(|d| d.get_temp(kept_id)).unwrap_or_default();
|
||||
if text != kept
|
||||
// An edit begun with Cmd/Ctrl commits the value even retyped as it was: the other
|
||||
// sides are to take it ([`edit_began_with_command`]).
|
||||
let for_all = ui.data(|d| d.get_temp::<bool>(id.with("cmdstart"))).unwrap_or(false);
|
||||
let edited = ui.data(|d| d.get_temp::<bool>(id.with("edited"))).unwrap_or(false);
|
||||
if (text != kept || (for_all && edited))
|
||||
&& let Some(e) = typed(&text)
|
||||
{
|
||||
out = Some(e);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Auto layout and constraints parity
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** patch (Suggest auto layout is its own command, as the help center says: it adds as many auto layout frames as needed, splitting a frame's layers into rows or columns by the gaps between them, and keeps the placement of the design where the gaps are even (#1823); baseline alignment puts a layer without text with its bottom on the line, as the help center's figure shows an icon next to text (#1825); fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); double-click an edge of an auto layout frame's box for Hug contents, Alt+double-click an edge of a child's box for Fill container, as the Guide to auto layout says (#1838); a child resized to the full available space of its auto layout frame becomes Fill container on that axis, as the Guide to auto layout says; a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** patch (a Cmd/Ctrl-click on a padding field sets all four sides with the value typed, as the Guide to auto layout's shortcut guide says; Suggest auto layout is its own command, as the help center says: it adds as many auto layout frames as needed, splitting a frame's layers into rows or columns by the gaps between them, and keeps the placement of the design where the gaps are even (#1823); baseline alignment puts a layer without text with its bottom on the line, as the help center's figure shows an icon next to text (#1825); fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); double-click an edge of an auto layout frame's box for Hug contents, Alt+double-click an edge of a child's box for Fill container, as the Guide to auto layout says (#1838); a child resized to the full available space of its auto layout frame becomes Fill container on that axis, as the Guide to auto layout says; a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Auto layout is the most used layout feature in Figma and the one where small differences (a
|
||||
hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~98%,
|
||||
@@ -80,7 +80,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
|
||||
|---|---|---|
|
||||
| Horizontal, vertical, wrap (both directions) | done | `crates/layout` |
|
||||
| Grid auto layout: fixed / hug / `fr` tracks, cells, spans, gaps, cell alignment | done | `fr` like CSS `fr` |
|
||||
| Gap, auto gap (between / evenly / around), padding (per side), alignment 3×3 | done | |
|
||||
| Gap, auto gap (between / evenly / around), padding (per side), alignment 3×3 | done | a Cmd/Ctrl-click on a padding field (the pair or a side) makes the value typed go to all four sides in one undo step (Guide to auto layout, Keyboard shortcut guide; `padding_individual_tests`) |
|
||||
| Text baseline alignment | done | |
|
||||
| Hug / Fill / Fixed sizing with coherent rules (Fill turns a hugging parent Fixed) | done | `al_nesting_tests.rs`, `al_behaviour_tests.rs` |
|
||||
| Min/max width and height, redistribution | done | hugging text wraps at its max |
|
||||
@@ -102,6 +102,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-05 | patch | M2, #1823: Suggest auto layout is no longer Add auto layout under another name: `layout.suggest` splits a frame's layers into rows (Vertical) or else columns (Horizontal) by the gaps between their boxes, wraps each band of several layers in a new frame without fill or clipping that hugs it, and repeats inside it (16 levels, 1,000 layers per frame at most). The frame keeps its size; gap and padding come from the bands, so layers keep their boxes where the gaps are even and the bands start at the same offset; instances and layers inside them are refused; uneven gaps (smallest used), bands that start at different offsets across the flow (lined up at the smallest) and layers that overlap along both axes come back as `warnings`. A band frame hugs only when its own split succeeds: a band whose layers overlap along both axes gets no auto layout and stays Fixed, so it cannot reflow those children. `direction` sets the top level only; one undo step; `layout.add` is unchanged. Tests in `layout_suggest_tests.rs` |
|
||||
| 2026-11-02 | patch | M2: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. The modifier was ignored before. Test: `padding_individual_tests` |
|
||||
| 2026-11-03 | patch | M2, #1825: baseline alignment of a layer without text (an icon next to a text) puts its bottom on the line's baseline, as the help center's figure shows ("the bottoms of the icon and the word home are aligned on the red line"); it kept to the top of the line. A layer reaching below the text's baseline moves the text down, and a line with no text lines the layers' bottoms up (the documentation is silent; CSS baseline alignment does the same). Tests: `alignment_rules` (three cases), engine `baseline_alignment_puts_an_icons_bottom_on_the_text_baseline`. Numbers unchanged. |
|
||||
| 2026-11-02 | patch | M2, #1824: fill container children share the free space by their content area: each one's own insets (padding and an included inside stroke, from the same `insets` the frame uses) come off the free space before the equal split and go back on its share, so a child with more padding or a thicker inside stroke takes a little more and the content areas match ("Figma distributes space amongst fill container children by the children's content area instead of by their size, matching the CSS border-box model", Use auto layout with CSS Flexbox in mind). Min and max still clamp the outer sizes and the frozen-share loop is as it was; children without insets split as before. Test: `nested_tests::fill_children_share_by_content_area` |
|
||||
| 2026-11-02 | patch | M2, #1838: a double-click on an edge of an auto layout frame's bounding box sets Hug contents on that axis, Alt+double-click on an edge of a child's Fill container ("Double-click the vertical or horizontal edge of an auto layout object's bounding box to set it to Hug contents", "Hold ⌥ Option or Alt and double-click ... to set it to Fill container", Guide to auto layout); a vertical edge sizes the width, a horizontal one the height; the Keyboard shortcuts panel lists both gestures. Test: `double_click_tests::a_double_click_on_an_auto_layout_edge_hugs_and_with_alt_fills`. Numbers unchanged. |
|
||||
|
||||
@@ -546,10 +546,13 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
- Disconnected: while it reconnects, and after it can't go on, the tab shows a red mark and a strip
|
||||
across the top of the canvas says so, with **Retry now** (`collab.reconnect`). The tab can still
|
||||
be edited. A session you joined that ended stays on its tab with **Try again**, **Save a copy…**
|
||||
and **Dismiss** (`collab.leave`). **Try again** joins it in the same tab as someone new; if the
|
||||
tab changed since it lost the server, what changed opens beside it as an unsaved
|
||||
"offline copy" and the tab shows the server's version. A session that gets out of step does the
|
||||
same by itself. A reconnect resumes as the same
|
||||
and **Dismiss** (`collab.leave`). **Try again** joins it in the same tab, as the same client when
|
||||
the server still knows it, and what changed since it lost the server goes on top as one undoable
|
||||
edit, through the three-way merge of `crates/collab/src/merge.rs`. The same thing changed both
|
||||
ways keeps the other person's, and layers a new client can't make aren't sent; in those cases
|
||||
your whole version also opens beside it as an unsaved "offline copy", so nothing is lost. A
|
||||
session that gets out of step, or a server that no longer knows you, does the same by itself. A
|
||||
reconnect resumes as the same
|
||||
client: the guest's connection keeps the session id, client id and last sequence number from
|
||||
the messages it read and sends them in its next `Hello` (see Offline and reconnect).
|
||||
- The browser build joins and publishes to a server (see *Built (the browser client)*); hosting
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Collaboration parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (; a file shared on the local network can be shared to look at only: a view-only code next to the edit code, picked in the Share popover before or while sharing)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (M9: a joined live session reconnects until you leave and says when it is disconnected, and joining it again merges what you did while disconnected; ; a file shared on the local network can be shared to look at only: a view-only code next to the edit code, picked in the Share popover before or while sharing)
|
||||
|
||||
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
|
||||
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
|
||||
@@ -12,7 +12,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
|
||||
| Feature | Figma | FigCraft | Hours |
|
||||
|---|---|---|---|
|
||||
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, a live connection reconnecting until you leave), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/<key>` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 |
|
||||
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, a live connection reconnecting until you leave, and rejoining with offline edits merged), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/<key>` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 |
|
||||
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors (one name for live sessions and comments: Preferences ▸ Your name, also in the Share popover and the Join dialog, kept in the browser too; a rename shows at once in the sessions you're in, #1380), selection outlines, avatars (an AI agent attached over MCP also shows its avatar and an outline around the layers it last edited or selected while it works, in or out of a session, #1416), follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
|
||||
| Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 |
|
||||
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit, comment and view links with short codes; local network: the app hosts and the code carries its address, with an edit code and a view-only code, picked before or while sharing, whose holders see every change and can't make one; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit, comment or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; a commenter also adds, replies to, reacts to and resolves comment threads, with a "Can comment" badge and the comment tool, #1525; the server refuses their other edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer; a server only a team reaches can also list the files shared from it now for everyone (`team_files`): Home ▸ Online shows that team list for the server in Preferences, a row joins its file, and its links (`figcraft://join?server=…&file=…&team=1`, or the browser build's) carry no secret and stop working with the share. No accounts, teams or projects | 8–17 |
|
||||
@@ -26,6 +26,8 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-04 | minor | M9: a session you joined no longer turns into a local file by itself. A dropped connection reconnects until you leave (a minute's pause after each 12 attempts, a pause Retry now skips); only the host ending the session and refusals that are for good stop it; queued messages go out paced. While disconnected the tab shows a red mark and a strip across the canvas says so; a session that ended stays on its tab with Try again, Save a copy and Dismiss |
|
||||
| 2026-11-04 | minor | M9: joining a session again merges what you did while disconnected as one undoable edit, through the three-way merge branches use, as the same client when the server still knows it. What conflicts keeps the other person's, and your whole version opens beside it as an unsaved copy. A server that no longer knows you merges the same way instead of discarding unconfirmed work |
|
||||
| 2026-11-06 | patch | M9, #2026: one conflicting auto layout edit added a conflict for every size and place it moved (a padding changed on both sides of a file with one instance gave 10, with 15 instances 138), and a `layout` conflict took all auto layout fields together, so a gap changed only on the branch was lost if main won the padding. `merge::plan` now leaves out values both sides work out for themselves: a hugging frame's or a filling child's size, text sizing to its characters, an auto layout child's place (when both sides turn it the same way) and a value a layer in an instance takes from its main; merging keeps the file merged into's own value and normalising lays it out from the merged inputs. `layout` and `childLayout` merge field by field: a field changed on one side merges, each field changed both ways is its own conflict (`<id>/layout.padding`, shown as auto layout padding with both sides' values). Tests: `branch_tests::one_conflicting_auto_layout_edit_is_one_conflict`, `branch_tests::auto_layout_fields_changed_one_way_survive_a_conflict`, `branch_tests::sizes_auto_layout_works_out_never_conflict`; the merge fuzz test leaves worked-out values out of what each side must show |
|
||||
| 2026-11-06 | minor | M9: a file shared on the local network can be shared read-only. The session the app hosts has a second secret, so a view-only code next to the edit code (same address and port, a 44-bit secret of its own); whoever joins with it is welcomed as a viewer, sees every change and presence, gets the "View only" badge and only the moving tools, and the session refuses their edits. The Share popover offers Can edit / Can view under Local network before sharing starts (the edit code then never shows) and while sharing; `collab.share {via: "local", access: "view"}` does the same (its `link` and `url`, like its `code`, are the view-only way in), and `share` in `collab.peers` lists both codes and links. No comment code on the local network. Numbers unchanged. |
|
||||
| 2026-11-04 | minor | M9: a session you joined no longer turns into a local file by itself. A dropped connection reconnects until you leave (a minute's pause after each 12 attempts, a pause Retry now skips); only the host ending the session and refusals that are for good stop it; queued messages go out paced. While disconnected the tab shows a red mark and a strip across the canvas says so; a session that ended stays on its tab with Try again, Save a copy and Dismiss, and what changed offline opens beside it when it joins again |
|
||||
|
||||
File diff suppressed because one or more lines are too long
+10
-4
@@ -212,12 +212,17 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
overlay, the strings added since each catalog was drafted (1–5% of each), the Arabic draft's native review and mirrored rows inside the panels (the window mirrors since 2026-10-31), Hindi and
|
||||
Vietnamese, localized engine messages and number formatting; CJK UI text on the web without the
|
||||
craft-fonts CJK build.
|
||||
Korean, Simplified Chinese, Traditional Chinese, Indonesian and Czech drafts and the Latin
|
||||
American Spanish overlay, the strings added since each catalog was drafted (1–5% of each), the
|
||||
Arabic draft's native review and panel mirroring, Hindi and Vietnamese, localized engine
|
||||
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
|
||||
`crates/ui-egui/locales/` (measured 2026-10-17: 1,680 UI keys plus 561 command labels and 31
|
||||
menu wordings, 2,272 entries), Preferences ▸ Language, `pref.language`; `en.toml` is complete,
|
||||
`crates/ui-egui/locales/` (measured 2026-11-03: 1,975 UI keys plus 641 command labels and 31
|
||||
menu wordings, 2,647 entries), Preferences ▸ Language, `pref.language`; `en.toml` is complete,
|
||||
Spanish is held to completeness by the check, and the French, German, Brazilian Portuguese,
|
||||
Japanese, Korean and Simplified Chinese machine drafts cover 95–99% (strings added since they
|
||||
were drafted show English), with `es-419.toml` a Latin American overlay listing only what
|
||||
Japanese, Korean, Simplified Chinese and Traditional Chinese machine drafts cover 95–100%
|
||||
(the Traditional Chinese draft covers all 2,647 entries; strings added since the other drafts
|
||||
were written show English), with `es-419.toml` a Latin American overlay listing only what
|
||||
differs from Spanish. Russian (reviewed and approved by VanTan, one/few/many integer plurals)
|
||||
covers 98.8%; Indonesian and Czech drafts, a Turkish catalog whose header says a native speaker
|
||||
reviewed it, and a Swedish catalog (with its command labels since 2026-10-17) cover 98.7–99.6% ([localization-parity](localization-parity.md#languages)).
|
||||
@@ -588,6 +593,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged |
|
||||
| 2026-10-31 | minor | Gap 10: library analytics list the layers and files using each style and variable; their insertions, detachments and modes stay on the missing list. |
|
||||
| 2026-10-31 | minor | Gap 15: the Controller menu names buttons as PlayStation and Switch controllers do; that item leaves the missing list. Estimate unchanged. Test: `present::tail_tests`. |
|
||||
| 2026-10-30 | minor | Gap 7: text reads only the fallback fonts it draws with: a face per fallback family and style, a file's bytes shared between families, the faces worked out once per style (first text laid out: 41 font files and 265 MiB read down to 3 files and 20 MiB on a Linux machine with Noto Sans CJK). Numbers unchanged |
|
||||
|
||||
@@ -4,7 +4,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
|
||||
Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese, Bosnian and Czech drafts (machine-drafted,
|
||||
Portuguese, Japanese, Korean, Simplified Chinese, Traditional Chinese, Indonesian, Vietnamese, Bosnian and Czech drafts (machine-drafted,
|
||||
waiting for native speakers' review), plus Swedish and Turkish catalogs. This page lists the twelve key
|
||||
languages, then the other languages Figma ships. How to add a
|
||||
language: [`translating.md`](translating.md).
|
||||
@@ -32,15 +32,15 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
|
||||
channel stay English. Accessibility names come from the same strings.
|
||||
- **Language preference:** Preferences ▸ Language (System, then each catalog by its own name:
|
||||
English, Bosanski, Čeština, Deutsch, Español, Español (Latinoamérica), Français, Bahasa Indonesia, 日本語,
|
||||
한국어, Português (Brasil), Русский, Svenska, Türkçe, Tiếng Việt, 简体中文), applied at once without a
|
||||
한국어, Português (Brasil), Русский, Svenska, Türkçe, Tiếng Việt, 简体中文, 繁體中文(台灣)), applied at once without a
|
||||
restart; System takes the first of the OS's preferred languages that has a catalog (on Windows
|
||||
only the first preferred language counts, since that list also holds the languages added for a
|
||||
keyboard; macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional
|
||||
tag falls back to its language, pt-PT or pt to pt-BR, and es-419, es-MX, es-AR, es-CO and the
|
||||
other Latin American and US Spanish tags to es-419; regional ru, tr, cs and id tags (ru-RU,
|
||||
tr-TR, cs-CZ, id-ID) to their catalogs; zh, zh-CN, zh-SG and zh-Hans read zh-Hans,
|
||||
while Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and reads English or the
|
||||
next OS language, never Simplified) or the browser's language on the web.
|
||||
while zh-Hant, zh-TW, zh-HK and zh-MO read zh-Hant; an explicit Hans such as zh-Hans-TW still
|
||||
reads Simplified) or the browser's language on the web.
|
||||
`pref.language {language}` sets it from the CLI, MCP and the control channel.
|
||||
- **Regional overlays:** a regional catalog whose language has a catalog lists only what differs
|
||||
(`es-419.toml` over `es.toml`); its UI strings, command labels and menu wordings read the
|
||||
@@ -87,10 +87,15 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
|
||||
Linux and BSD: Noto Sans CJK, Source Han Sans, Nanum, WenQuanYi; the craft-fonts build's BIZ
|
||||
UDPGothic first), the UI language's script first; Han without kana outside a Japanese or Korean UI
|
||||
reads as Chinese, and a Chinese UI puts its Chinese face (Microsoft YaHei, PingFang SC, Noto Sans
|
||||
CJK SC) ahead of the built-in Japanese one, so Han takes Simplified Chinese shapes; changing the
|
||||
CJK SC) ahead of the built-in Japanese one, so Han takes Simplified Chinese shapes, while a
|
||||
Traditional Chinese UI puts an installed Noto Sans CJK TC first, then Microsoft JhengHei, PingFang
|
||||
TC and Heiti TC, so Han takes Traditional shapes; changing the
|
||||
UI language lays the faces out again. Only that face's file is read and egui shares its bytes, so English users load
|
||||
nothing (startup and memory measured unchanged); no CJK font installed means tofu, never a
|
||||
crash. The web build has no system fonts: built with craft-fonts and `FIGCRAFT_EMBED_CJK=1` it
|
||||
embeds Noto Sans CJK SC (+16 MB) and offers Japanese, Korean and Simplified Chinese; without a
|
||||
Noto Sans CJK TC face built in it leaves Traditional Chinese out too; otherwise the language picker and
|
||||
System leave them out. No font file is in this repo.
|
||||
embeds Noto Sans CJK SC (+16 MB) and offers Japanese, Korean and Simplified Chinese; otherwise the language picker and
|
||||
System leave them out. `FIGCRAFT_EMBED_SCRIPTS=Arab,Hebr,Thai,Deva` (one tag per script wanted, as
|
||||
the craft-fonts manifest lists them) embeds those scripts' faces as well, so an Arabic or Persian
|
||||
@@ -102,7 +107,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
|
||||
| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 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) |
|
||||
| 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 | draft | 1–2 (after native review) |
|
||||
| Traditional Chinese (Taiwan) | zh-Hant | 2,647 / 2,647 (100%: 1,975 UI strings, 641 command labels, 31 menu wordings; draft, counted 2026-11-03) | yes | CJK text and IME in documents; UI text in an installed Noto Sans CJK TC, then Microsoft JhengHei, PingFang TC or Heiti TC (web: only with a built-in TC face); zh-TW, zh-HK, zh-MO and zh-Hant read it, an explicit Hans stays Simplified | 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: only a build with `FIGCRAFT_EMBED_SCRIPTS=Arab` embeds Noto Sans Arabic (the font budget leaves it out otherwise, #1902); documents draw in it and the picker offers the Arabic UI then; panels do not mirror | no | draft | review + panel mirroring (not re-estimated) |
|
||||
@@ -136,13 +142,14 @@ mirrors since 2026-10-31). Total: **29–51 h** (kept on
|
||||
every string adds).
|
||||
|
||||
Priority: native reviews of the drafts and the es-419 overlay (every one of Figma's languages
|
||||
and variants has one, plus Simplified Chinese, Indonesian, Vietnamese and Czech, which Figma doesn't ship), then
|
||||
Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see English).
|
||||
and variants has one, plus Simplified Chinese, Traditional Chinese, Indonesian, Vietnamese and Czech, which Figma doesn't ship), then
|
||||
Hindi and Arabic.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`, 繁體中文(台灣)) draft catalog: every UI string (1,975), command label (641) and menu wording (31), translated clean-room from the English source and marked as a draft awaiting native review. `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant` read it; an explicit Hans still reads Simplified Chinese. The desktop UI prefers an installed Noto Sans CJK TC, then Microsoft JhengHei, PingFang TC and Heiti TC; no font is bundled. No plural forms. Localization grade and estimates unchanged |
|
||||
| 2026-11-03 | patch | M10, #1902: the web build drew Persian and Arabic text as missing-glyph boxes. The browser has no system fonts, and `crates/text/build.rs` keeps the web catalog to Inter, Noto Sans Georgian, the wordmark and (with `FIGCRAFT_EMBED_CJK`) Noto Sans CJK, so craft-fonts' Noto Sans Arabic never reached it. `FIGCRAFT_EMBED_SCRIPTS=Arab,Hebr,…` (script tags as the manifest's scripts column lists them) now embeds those scripts' faces in a web build; the default build and its size are unchanged, and with `Arab` embedded the picker offers the Arabic UI as well (`cjk::can_show`, as on the desktop). Noto Sans Hebrew, Devanagari and Georgian join Arabic and Thai among the document fallbacks, so an embedded face draws its script in an Inter document. Tests: `web_embeds_other_scripts_faces_only_when_asked` in the build script, `every_embeddable_script_face_is_a_document_fallback`. |
|
||||
| 2026-11-05 | patch | #2014: the panels drew a String variable's Amharic value as boxes: the UI took a system face for Hebrew and Arabic only. `bidi::script_fallbacks` now samples one letter of each of 16 scripts (Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as well as Hebrew and Arabic; Bengali and Thai keep `crate::cjk`'s faces) against the system's families for each (Windows, macOS, the common Linux fonts), the first installed one that draws the letter taken once. Desktop only: the web build has no system fonts. Test: `bidi::tests::other_scripts_take_an_installed_system_face` (skips scripts the machine has no face for). |
|
||||
| 2026-11-05 | minor | Documents: a paragraph's base direction can be forced left to right or right to left (Text ▸ Text direction, `text.direction`, `node.set textDirection`, the Type settings control, the `.fig` lines' `sourceDirectionality`), as Add right-to-left text says (#1830); detection stays the default. Four new UI strings (English only; other catalogs fall back). |
|
||||
|
||||
+2
-2
@@ -380,7 +380,7 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match.
|
||||
| `inspect_document {depth, allPages, fields?, outline?}` | The document as JSON (its `id`, layer tree, styles, selection, history); `fields` selects layer properties, `outline: true` gives a compact tree (below). The tools that act on a document also take `document` (see Naming the document below). |
|
||||
| `get_node {id, depth, fields?, outline?}` | One layer as JSON; takes the same `fields` and `outline` options; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. |
|
||||
| `select {ids}` | Replace the selection (`[]` deselects). |
|
||||
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?, vdCompat?, quality?}` | png, jpg, svg, webp, pdf, xml (an Android Vector Drawable: `svgIdAttribute` writes `android:name`, `vdCompat` is `bestEffort` (default: approximated with a warning), `exact` (left out with a warning) or `ignore` (no warnings: overlapping layer opacity dropped, even-odd fills kept as `fillType="evenOdd"`, diamond gradients left out), and the result lists `warnings` for what was changed or left out), or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`; `quality` is the JPG quality, 1 to 100 (default 90). The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps `<text>`, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text, XML as text followed by `{format, bytes, warnings}`. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `<layer><suffix>.<format>`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). |
|
||||
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?, vdCompat?, colorProfile?, quality?}` | png, jpg, svg, webp, pdf, xml (an Android Vector Drawable: `svgIdAttribute` writes `android:name`, `vdCompat` is `bestEffort` (default: approximated with a warning), `exact` (left out with a warning) or `ignore` (no warnings: overlapping layer opacity dropped, even-odd fills kept as `fillType="evenOdd"`, diamond gradients left out), and the result lists `warnings` for what was changed or left out), or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`; `quality` is the JPG quality, 1 to 100 (default 90). The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps `<text>`, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline; png, jpg and webp: `colorProfile` is `sameAsFile` (default: the document's profile), `srgb` or `displayP3`, the pixels converted (out-of-gamut colors clip) and the file tagged for it (SVG, PDF and XML are always sRGB). The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text, XML as text followed by `{format, bytes, warnings}`. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `<layer><suffix>.<format>`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). |
|
||||
| `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. |
|
||||
| `open_file {path, variables?, reload?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` or FigJam `.jam` (best effort, see below), `.sketch` (Sketch 48 and newer, best effort), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open gives its `index`, with `stale: true` when the file changed on disk since that tab read or saved it; `reload: true` reads it again into the tab, refused while the tab has unsaved changes) and the document's `document` outline. |
|
||||
| `undo` / `redo` | Step through history. |
|
||||
@@ -693,7 +693,7 @@ on; `key` resolves keys the way the canvas does. Commands behind keys:
|
||||
- `pref.startOnHome {on?}`: the desktop app starts on the Home screen instead of in a new file
|
||||
(off by default; files named on the command line still open, and `--control` starts in a new
|
||||
file anyway, so control callers have a document).
|
||||
- `pref.language {language: system|en|bs|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|sv|vi|zh-Hans|…}`: the app's interface language, applied live ("system":
|
||||
- `pref.language {language: system|en|bs|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|sv|vi|zh-Hans|zh-Hant|…}`: the app's interface language, applied live ("system":
|
||||
the OS or browser language; a language without a catalog shows English). Command ids, labels and
|
||||
every result stay English.
|
||||
|
||||
|
||||
+3
-2
@@ -16,9 +16,9 @@ are one Opus 5.5 agent; most milestones split across 4–6 agents on disjoint cr
|
||||
3. **Tool-by-tool interaction pass** against the running Figma ([`ui-parity.md`](ui-parity.md)):
|
||||
40–60 h, parallel by tool.
|
||||
4. **Languages** on the landed localization layer: native reviews of the es, fr, de, pt-BR, ja,
|
||||
ko, zh-Hans, id and cs drafts and the es-419 overlay (people; Russian and Turkish are reviewed),
|
||||
ko, zh-Hans, zh-Hant, id and cs drafts and the es-419 overlay (people; Russian and Turkish are reviewed),
|
||||
the entries added since each catalog was written, then the other key languages (Hindi,
|
||||
Vietnamese): 3–7 h per language plus native review. Arabic now has a complete draft; native review and panel mirroring remain.
|
||||
Vietnamese): 3–7 h per language plus native review. Arabic now has a complete draft; native review and panel mirroring remain. Traditional Chinese (Taiwan) has a complete draft too.
|
||||
5. **Multiplayer M9**: accounts and teams on top of the live sessions and the standalone server (the WebSocket transport, hosting from the app, invite links that open the app, document sync, live cursors, selections, text carets, avatars and follow, per-user undo, reconnect with offline edits, collision-free ids for styles, variables, collections and comments, joining from the browser, and `figcraft-server` with persistence, publishing from the app and share links to edit or view landed), per [`collab-protocol.md`](collab-protocol.md) and [`server.md`](server.md): 45–90 h.
|
||||
|
||||
## Alpha gate
|
||||
@@ -75,6 +75,7 @@ blockers.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged |
|
||||
| 2026-11-03 | patch | M4, #1747: a text layer still carrying the name its characters gave it is renamed as they are typed, so the Layers panel no longer shows the word a layer started with while the canvas shows another (`hello` typed into `hello there` read as `hello` in the panel). A name the author typed is kept, and only the editor follows the characters: commands leave the name alone, so the layer names `data.fill`, component property bindings and templates use as field keys stay put. Tests: `text_behaviour_tests::typing_renames_a_text_layer_that_still_carries_the_name_its_characters_gave_it`, `setting_characters_as_a_command_never_renames_the_layer`. Grade unchanged |
|
||||
| 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged |
|
||||
| 2026-10-31 | patch | M11: a trackpad or touch tap sends its press and release in one frame, so a plugin panel's field committed in the frame its button was clicked and the value was lost: the Counter's Set count… kept the old count. The plugin now gets both, the field's value first. The `window.figcraft` docs name `screenshot`'s `data`. Numbers unchanged |
|
||||
|
||||
@@ -652,6 +652,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged |
|
||||
| 2026-11-04 | patch | M4, #1970: justified Chinese, Japanese and Korean text, written without spaces between words, aligns its right edge: the room on each line but a paragraph's last goes between its characters, each a word of its own as for line breaking ("Justify aligns the first word in each line of text to the left-most edge, and the last to the right-most edge"), wherever a line could break there, so none opens before a full stop or closing bracket, nor after an opening one. Latin text widens its spaces as before; mixed text widens both. A gap is reordered with the line's runs as a space of no width would be, so one beside a Hebrew word in Chinese text, or beside a Chinese word within that Hebrew, opens beside the word, not inside it. `figcraft_text::breaks::justify_between`, beside `break_between`, says where. Evidence: `tests::justified_ideographs_spread_the_room_between_characters` (fails on main). |
|
||||
| 2026-11-04 | patch | M8, #1951 (#1506 again): a path broken open by deleting a point (Delete, not Shift+Delete) is filled closed by a straight line across the gap, as an open path's fill is; joining its two ends again (the pen onto the other end, Connect, ⌘J) left the loop without the new segment, so once the points were rounded the fill and the selection outline ran straight across while the stroke followed the segment and its arcs. Joining now closes the loop through the new segment ("Click to close the path."; "You can use the Paint tool to add fills to any closed regions of a vector network."): `ops::connect` puts it into the loop whose segments are the chain between the ends, at the gap and in the loop's own order, so a hole keeps its winding whichever end was clicked first; a loop listed out of walking order is left as it was. Evidence: `vectoredit::tests::a_rounded_chamfer_made_other_ways_fills_along_its_arcs` (fails on main: the loop kept 4 of 5 segments), `a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs` (Shift+Delete, with and without a region fill), `doc::tests::a_chamfered_rectangles_rounded_fill_is_cut_at_every_point` (fill, region path and stroke agree for every loop order and segment direction of a chamfered rectangle). |
|
||||
| 2026-11-06 | patch | M3: glass's docs say which centre `splay` bends away from: the centre of the layer's bounds, as the reference tool's, checked side by side on a ring and an L at Splay 0% and 100% (an inner edge bends towards the hole in both). And where a glass shape gets no core: past 4M px on the desktop and 1M on the web (`MAX_SHAPE_PX`) it bends along its edge normals, so a large glass text or vector can bend differently once zoomed into, in a 2× export, or on the web. Docs only. Grade unchanged. |
|
||||
|
||||
+31
-2
@@ -10,13 +10,14 @@ catalog reads English): a regional tag reads its language's catalog (`fr-CA` →
|
||||
with only a regional catalog reads that one (`pt-PT` → `pt-BR`), and Latin American and US Spanish
|
||||
(`es-MX`, `es-AR`, `es-CO`, `es-US`, `es-419`…) read `es-419`.
|
||||
Chinese (`zh`, `zh-CN`, `zh-SG`, `zh-Hans`) reads `zh-Hans`; Traditional Chinese (`zh-Hant`,
|
||||
`zh-TW`, `zh-HK`, `zh-MO`) has no catalog and reads English (or the next OS language), not
|
||||
`zh-TW`, `zh-HK`, `zh-MO`) reads `zh-Hant`, and an explicit `Hans` (`zh-Hans-TW`) still reads
|
||||
Simplified.
|
||||
`pref.language {language}` does the same from the CLI, MCP and the control channel.
|
||||
|
||||
The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay
|
||||
`es-419`), French (`fr`), German (`de`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`), Japanese
|
||||
(`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`), Finnish
|
||||
(`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Traditional Chinese (`zh-Hant`), Czech
|
||||
(`cs`), Indonesian (`id`), Finnish
|
||||
(`fi`), Bengali (`bn`), Italian (`it`), Vietnamese (`vi`), Thai (`th`) and Bosnian (`bs`), machine-drafted and waiting for native speakers' review. Polish (`pl`,
|
||||
Polski) was reviewed and approved by Lucas (@lukasz-sklad). Georgian (`ka`) was written with AI
|
||||
assistance and reviewed by a native speaker, and stays a draft until a maintainer changes its status.
|
||||
@@ -51,6 +52,9 @@ Enter for the key, control for a gamepad, resorte) and keeps Spanish's tú. Japa
|
||||
concise, without です/ます endings, with full-width ()「」:、。 where Japanese UI text has them;
|
||||
Korean labels end without 합니다 (instructions in -세요). Simplified Chinese is concise, with
|
||||
full-width ():,、。“” in Chinese text and a space between Chinese and Latin words or numbers.
|
||||
Traditional Chinese (Taiwan) is concise too, with full-width ():,、「」 and a space between
|
||||
Chinese and Latin words or numbers, and Taiwan design terms (畫布, 圖層, 框架, 元件, 原型, 約束,
|
||||
填色, 邊框, 效果, 匯出, 版本; Auto Layout stays English).
|
||||
None of the three has plural forms. Turkish uses bare imperatives for buttons and menu items
|
||||
(Kaydet, Sil) and the polite -in/-ın for instructions (seçin, tıklayın), puts the percent sign before
|
||||
the number (%50) and keeps a noun singular after a count, so its plural entries give only `other`.
|
||||
@@ -78,6 +82,12 @@ CJK and Thai text need their own fonts, which egui and Inter lack: the desktop a
|
||||
one when something on screen needs it, so layer and page names in those scripts show whatever the
|
||||
UI language (see `crates/ui-egui/src/cjk.rs`). The web build has no system fonts,
|
||||
so Japanese, Korean and Chinese appear there only in a build with craft-fonts and
|
||||
`FIGCRAFT_EMBED_CJK=1` (Noto Sans CJK SC, +16 MB); otherwise they are left out of the picker, and
|
||||
Traditional Chinese is left out unless that build has a Noto Sans CJK TC face. A
|
||||
Chinese UI puts its Chinese face first so Han takes Simplified Chinese shapes, and a Traditional
|
||||
Chinese UI puts an installed Noto Sans CJK TC first, then the system's Taiwan faces (Microsoft
|
||||
JhengHei, PingFang TC, Heiti TC), so Han takes Traditional shapes. No font is added to this
|
||||
repository. Georgian is drawn in
|
||||
`FIGCRAFT_EMBED_CJK=1` (Noto Sans CJK SC, +16 MB); otherwise they are left out of the picker. The other
|
||||
scripts' faces (Noto Sans Arabic, Hebrew, Thai, Devanagari) join a web build only with
|
||||
`FIGCRAFT_EMBED_SCRIPTS=Arab,Hebr,Thai,Deva` (one tag per script wanted, as the manifest lists them), for
|
||||
@@ -107,6 +117,25 @@ that face out, so Arabic is not offered on the web yet. No font is added to
|
||||
this repository. The interface's panels are not mirrored; this catalog does not claim a complete
|
||||
right-to-left interface. Native review and representative in-app checks remain required.
|
||||
|
||||
## Traditional Chinese (Taiwan) draft
|
||||
|
||||
`zh-Hant.toml` adds 繁體中文(台灣) as a draft: 1,975 UI entries, 641 command labels and 31 menu
|
||||
wordings (2,647 entries, measured 2026-11-03). It was translated clean-room from the English source
|
||||
in `en.toml`; the Simplified Chinese catalog was not used as a source. It covers every English key,
|
||||
so the completeness test keeps it that way.
|
||||
|
||||
Taiwan design terms: 畫布 (canvas), 圖層 (layer), 框架 (frame), 元件 (component), 原型 (prototype),
|
||||
Auto Layout (kept in English), 約束 (constraints), 填色 (fill), 邊框 (stroke), 效果 (effects),
|
||||
匯出 (export) and 版本 (version). Actions use concise Taiwan verbs (儲存, 刪除, 建立副本), and
|
||||
quotes are 「」. Placeholders, command ids, keyboard glyphs and product names stay as in English.
|
||||
|
||||
Traditional Chinese has no plural forms, so plural entries give the same text for `one` and
|
||||
`other`; `plural` never picks `one` for `zh-Hant`. The desktop UI adds an installed Noto Sans CJK
|
||||
TC first, then Microsoft JhengHei, PingFang TC, Heiti TC and the other Taiwan faces; no font is
|
||||
bundled. The web picker leaves Traditional Chinese out unless the build embeds a Noto Sans CJK TC
|
||||
face, as it does for the other CJK catalogs. Native review remains open (draft), as do the in-app
|
||||
checks of long labels in the right panel.
|
||||
|
||||
## Adding a language
|
||||
|
||||
1. Copy `crates/ui-egui/locales/es.toml` to `<code>.toml` (a BCP 47 code: `fr`, `pt-BR`, `ja`) and
|
||||
|
||||
+7
-4
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user