mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 12:56:47 -06:00
Merge main into #1202
This commit is contained in:
Generated
+1
@@ -1527,6 +1527,7 @@ dependencies = [
|
||||
"windows",
|
||||
"winit",
|
||||
"winresource",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -234,7 +234,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
|
||||
- **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust.
|
||||
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
|
||||
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
|
||||
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and Georgian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
|
||||
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian, Georgian and Vietnamese drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
|
||||
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
|
||||
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
|
||||
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
|
||||
|
||||
+5
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -110,11 +110,11 @@ native speakers' reviews of the nine language drafts need people.
|
||||
| Japanese | ja | 99% | draft (needs native review) |
|
||||
| German | de | 96% | draft (needs native review) |
|
||||
| Korean | ko | 95% | draft (needs native review) |
|
||||
| Vietnamese | vi | 0% | none |
|
||||
| Vietnamese | vi | 100% | draft (needs native review) |
|
||||
|
||||
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%) and Czech (`cs`, 99%, 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 and Indonesian drafts (Preferences ▸
|
||||
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Vietnamese drafts (Preferences ▸
|
||||
Language; Traditional Chinese has no catalog; 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,
|
||||
@@ -152,6 +152,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
- **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged.
|
||||
- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
|
||||
- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
|
||||
- **2026-10-16** · Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged.
|
||||
- **2026-10-15** · Dev Mode tail (M6, gap 16): generated layer trees start with the import lines of the connected components they call, once each per language; Compare changes takes any saved version as the before; a behaviour suite made the Dev Mode canvas inspect-only (no moving, resizing, drawing or editing; menus and shortcuts refuse design edits with a note), added Changed to the ready list and refused statuses on layers inside frames; read-only plugins show their panels from the Inspect tab. Dev Mode 97% / 76%.
|
||||
- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged.
|
||||
- **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged.
|
||||
@@ -401,6 +402,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
|
||||
| 2026-10-16 | minor | Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged |
|
||||
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. |
|
||||
| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. |
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<title>FigCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="figcraft-web" data-cargo-profile="web-release" data-wasm-opt="z" data-wasm-opt-params="--enable-bulk-memory --enable-bulk-memory-opt --enable-nontrapping-float-to-int --enable-sign-ext --enable-reference-types --enable-multivalue" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #f5f5f5; }
|
||||
#figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; overscroll-behavior: none; background: #f5f5f5; }
|
||||
#figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
|
||||
#figcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
color: #6b6b6b; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
||||
@@ -36,6 +36,12 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col
|
||||
craft-update = { workspace = true }
|
||||
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
|
||||
|
||||
# The system colour scheme (org.freedesktop.portal.Settings): winit reports no theme on Linux and
|
||||
# the BSDs, so the interface's "system" mode follows the portal's instead. zbus comes in through
|
||||
# accesskit already; this only makes it direct.
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
|
||||
|
||||
# Finder double-clicks, Open With, Dock drops and `open -a FigCraft file.figcraft` arrive as a
|
||||
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This crate (also used by GridCraft,
|
||||
# EffectCraft and PhotoCraft) registers the handler behind a safe API (Objective-C interop needs
|
||||
|
||||
+16
-12
@@ -170,21 +170,23 @@ fn main() -> eframe::Result {
|
||||
// System fonts: index them in the background, then wake the UI (an idle window draws no frames)
|
||||
// to lay text out again with them.
|
||||
let ctx_slot: Arc<Mutex<Option<egui::Context>>> = Arc::new(Mutex::new(None));
|
||||
let fonts_slot = ctx_slot.clone();
|
||||
// Wakes the window (once it is there) from another thread.
|
||||
let wake = |slot: &Arc<Mutex<Option<egui::Context>>>| {
|
||||
let slot = slot.clone();
|
||||
move || {
|
||||
if let Some(c) = slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
}
|
||||
};
|
||||
let fonts_wake = wake(&ctx_slot);
|
||||
std::thread::spawn(move || {
|
||||
figcraft_text::scan_system_fonts();
|
||||
if let Some(c) = fonts_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
fonts_wake();
|
||||
});
|
||||
let mut session = Session::empty();
|
||||
// Live sessions: the network threads wake the window when something arrives.
|
||||
let wake_slot = ctx_slot.clone();
|
||||
session.live.install(Box::new(figcraft_collab_net::NetLink::new(Arc::new(move || {
|
||||
if let Some(c) = wake_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
}))));
|
||||
session.live.install(Box::new(figcraft_collab_net::NetLink::new(Arc::new(wake(&ctx_slot)))));
|
||||
for f in &args.files {
|
||||
if let Err(e) = session.execute("file.open", &json!({"path": f})) {
|
||||
log::error!("{f}: {e}");
|
||||
@@ -196,6 +198,7 @@ fn main() -> eframe::Result {
|
||||
// The preferences first: they hold the online files a recovered document may be one of.
|
||||
let recovery = recovery::Shared::default();
|
||||
let svc = services::Desktop::load(recovery.clone());
|
||||
svc.follow_system_dark(wake(&ctx_slot));
|
||||
if let Some(prefs) = svc.prefs.clone() {
|
||||
session.prefs = prefs;
|
||||
}
|
||||
@@ -266,14 +269,15 @@ fn main() -> eframe::Result {
|
||||
let result = eframe::run_native(
|
||||
"FigCraft",
|
||||
options,
|
||||
Box::new(move |_cc| {
|
||||
Box::new(move |cc| {
|
||||
with_app(|fig| fig.services.set_egui_context(&cc.egui_ctx));
|
||||
Ok(Box::new(App {
|
||||
queue,
|
||||
ctx_slot,
|
||||
title: String::new(),
|
||||
recovery,
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events: apple_events.connect(&_cc.egui_ctx),
|
||||
apple_events: apple_events.connect(&cc.egui_ctx),
|
||||
}))
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_engine::{Prefs, Session};
|
||||
@@ -11,11 +11,14 @@ use serde_json::{Value, json};
|
||||
|
||||
pub struct Desktop {
|
||||
clipboard: Option<arboard::Clipboard>,
|
||||
egui_ctx: Option<egui::Context>,
|
||||
/// Where the preferences live ([`config_dir`]).
|
||||
dir: Option<PathBuf>,
|
||||
pub prefs: Option<Prefs>,
|
||||
/// The system's reduce-motion setting (read in the background at startup).
|
||||
reduce_motion: Arc<AtomicBool>,
|
||||
/// The system's colour scheme ([`watch_system_dark`]): 0 unknown, 1 dark, 2 light.
|
||||
dark: Arc<AtomicU8>,
|
||||
/// The system's interface language (read at startup).
|
||||
language: Option<String>,
|
||||
/// Recovery copies of unsaved files (written at once after a panic).
|
||||
@@ -56,16 +59,34 @@ impl Desktop {
|
||||
let reduce_motion = Arc::new(AtomicBool::new(false));
|
||||
let flag = reduce_motion.clone();
|
||||
std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed));
|
||||
let dark = Arc::new(AtomicU8::new(DARK_UNKNOWN));
|
||||
Self {
|
||||
clipboard: arboard::Clipboard::new().ok(),
|
||||
egui_ctx: None,
|
||||
dir,
|
||||
prefs,
|
||||
reduce_motion,
|
||||
dark,
|
||||
language: system_language(),
|
||||
recovery,
|
||||
last_folder: Arc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// winit reports no system theme on Linux and the BSDs: there the freedesktop colour scheme
|
||||
/// answers instead, read once and then watched in the background, calling `wake` when it
|
||||
/// changes (an idle window draws no frames). Elsewhere this does nothing.
|
||||
pub fn follow_system_dark(&self, wake: impl Fn() + Send + 'static) {
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if let Err(e) = std::thread::Builder::new().name("figcraft-theme".into()).spawn({
|
||||
let dark = self.dark.clone();
|
||||
move || watch_system_dark(&dark, &wake)
|
||||
}) {
|
||||
log::info!("colour scheme: can't start the watcher: {e}");
|
||||
}
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
let _ = wake;
|
||||
}
|
||||
}
|
||||
|
||||
/// The system's preferred interface languages, most preferred first, comma-separated
|
||||
@@ -146,6 +167,71 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool {
|
||||
.is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want)
|
||||
}
|
||||
|
||||
/// [`Desktop::dark`]: the colour scheme is unknown, dark, or light.
|
||||
const DARK_UNKNOWN: u8 = 0;
|
||||
const DARK_DARK: u8 = 1;
|
||||
const DARK_LIGHT: u8 = 2;
|
||||
|
||||
/// The desktop's colour scheme through the freedesktop settings portal
|
||||
/// (`org.freedesktop.appearance` / `color-scheme`: 1 dark, 2 light, 0 no preference, which is
|
||||
/// light), read once and then watched for changes, each one calling `wake`. Without a session bus
|
||||
/// or a portal the scheme stays unknown and the UI keeps its light fallback.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn watch_system_dark(dark: &AtomicU8, wake: &dyn Fn()) {
|
||||
use zbus::zvariant::Value;
|
||||
const PORTAL: &str = "org.freedesktop.portal.Desktop";
|
||||
const SETTINGS: &str = "org.freedesktop.portal.Settings";
|
||||
const APPEARANCE: &str = "org.freedesktop.appearance";
|
||||
// `Read` (portals before 1.17, which lack `ReadOne`) wraps the value in one more variant.
|
||||
let scheme = |v: &Value| match v {
|
||||
Value::U32(x) => *x,
|
||||
Value::Value(inner) => match **inner {
|
||||
Value::U32(x) => x,
|
||||
_ => 0,
|
||||
},
|
||||
_ => 0,
|
||||
};
|
||||
let store = |v: u32| {
|
||||
let new = match v {
|
||||
1 => DARK_DARK,
|
||||
2 => DARK_LIGHT,
|
||||
_ => DARK_UNKNOWN,
|
||||
};
|
||||
if dark.swap(new, Ordering::Relaxed) != new {
|
||||
wake();
|
||||
}
|
||||
};
|
||||
let Ok(conn) = zbus::blocking::connection::Builder::session().and_then(|b| b.build()) else {
|
||||
log::info!("colour scheme: no session bus: the system theme isn't followed");
|
||||
return;
|
||||
};
|
||||
let read = |method: &str| -> zbus::Result<u32> {
|
||||
let reply = conn.call_method(Some(PORTAL), "/org/freedesktop/portal/desktop", Some(SETTINGS), method, &(APPEARANCE, "color-scheme"))?;
|
||||
let body = reply.body();
|
||||
Ok(scheme(&body.deserialize::<Value>()?))
|
||||
};
|
||||
match read("ReadOne").or_else(|_| read("Read")) {
|
||||
Ok(v) => store(v),
|
||||
Err(e) => log::info!("colour scheme: can't read the system setting: {e}"),
|
||||
}
|
||||
let watch = || -> zbus::Result<()> {
|
||||
let rule =
|
||||
zbus::MatchRule::builder().msg_type(zbus::message::Type::Signal).sender(PORTAL)?.interface(SETTINGS)?.member("SettingChanged")?.build();
|
||||
for msg in zbus::blocking::MessageIterator::for_match_rule(rule, &conn, Some(8))? {
|
||||
let msg = msg?;
|
||||
let body = msg.body();
|
||||
let Ok((namespace, key, value)) = body.deserialize::<(String, String, Value<'_>)>() else { continue };
|
||||
if namespace == APPEARANCE && key == "color-scheme" {
|
||||
store(scheme(&value));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
if let Err(e) = watch() {
|
||||
log::info!("colour scheme: can't watch the system setting: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Show `path` in the file manager: selected in Finder and Explorer, its folder elsewhere.
|
||||
fn reveal(path: &Path) -> std::io::Result<()> {
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -325,10 +411,13 @@ impl Services for Desktop {
|
||||
remembering(self.last_folder.clone(), pick, true)
|
||||
}
|
||||
fn clipboard_set_text(&mut self, s: &str) {
|
||||
if let Some(c) = self.clipboard.as_mut() {
|
||||
let _ = c.set_text(s.to_string());
|
||||
if let Some(ctx) = &self.egui_ctx {
|
||||
ctx.copy_text(s.to_string());
|
||||
}
|
||||
}
|
||||
fn set_egui_context(&mut self, ctx: &egui::Context) {
|
||||
self.egui_ctx = Some(ctx.clone());
|
||||
}
|
||||
fn clipboard_get_text(&mut self) -> Option<String> {
|
||||
read_clipboard(self.clipboard.as_mut()?, |c| c.get_text().ok())
|
||||
}
|
||||
@@ -372,6 +461,13 @@ impl Services for Desktop {
|
||||
fn reduce_motion(&self) -> bool {
|
||||
self.reduce_motion.load(Ordering::Relaxed)
|
||||
}
|
||||
fn system_dark(&self) -> Option<bool> {
|
||||
match self.dark.load(Ordering::Relaxed) {
|
||||
DARK_DARK => Some(true),
|
||||
DARK_LIGHT => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn gamepad_held(&mut self) -> u32 {
|
||||
crate::gamepad::held()
|
||||
}
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
//! report it, instead of the app quitting silently (#261).
|
||||
|
||||
use figcraft_ui_egui::dialogs::window_failed as message;
|
||||
use figcraft_ui_egui::i18n::tr;
|
||||
|
||||
const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}";
|
||||
const BACKEND_HINT: &str = "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose.";
|
||||
const DRIVER_HINT: &str = if cfg!(windows) {
|
||||
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:"
|
||||
} else {
|
||||
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try."
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn the_message_carries_the_error_and_where_to_report_it() {
|
||||
@@ -15,9 +22,9 @@ fn the_message_carries_the_error_and_where_to_report_it() {
|
||||
#[test]
|
||||
fn without_wgpu_backend_it_suggests_the_driver_and_another_graphics_api() {
|
||||
let text = message(ERROR, false);
|
||||
assert!(text.contains("graphics driver"), "{text}");
|
||||
assert!(text.contains(tr(DRIVER_HINT)), "{text}");
|
||||
assert!(text.contains("WGPU_BACKEND"), "another graphics API to try: {text}");
|
||||
assert!(!text.contains("unsetting"), "{text}");
|
||||
assert!(!text.contains(tr(BACKEND_HINT)), "{text}");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -30,6 +37,6 @@ fn on_windows_it_shows_how_to_start_with_opengl() {
|
||||
#[test]
|
||||
fn with_wgpu_backend_set_it_suggests_unsetting_it() {
|
||||
let text = message(ERROR, true);
|
||||
assert!(text.contains("unsetting"), "{text}");
|
||||
assert!(!text.contains("graphics driver"), "the chosen backend is the likelier cause: {text}");
|
||||
assert!(text.contains(tr(BACKEND_HINT)), "{text}");
|
||||
assert!(!text.contains(tr(DRIVER_HINT)), "the chosen backend is the likelier cause: {text}");
|
||||
}
|
||||
|
||||
@@ -72,6 +72,9 @@ sync_github_name = true
|
||||
[people.AganzeFelicite]
|
||||
sync_github_name = true
|
||||
|
||||
[people.mojtabajafarzadeh]
|
||||
real_name = "Mojtaba Jafarzadeh"
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
display_name = "Gert Pellin"
|
||||
@@ -80,3 +83,6 @@ sync_github_name = false
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
[people.leandrodslv]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -24,6 +24,12 @@ pub struct NetLimits {
|
||||
pub hello_timeout: Duration,
|
||||
/// A connection with no traffic for this long is dead.
|
||||
pub idle_timeout: Duration,
|
||||
/// How long a one-shot request connection (hosting, a library, a plugin directory — and, most
|
||||
/// importantly, publishing a file) waits for the server's reply before giving up. Publishing
|
||||
/// uploads a whole file and a large one on a slow link takes the server a while to receive and
|
||||
/// answer, so this is longer than `idle_timeout` (a large project on a slow connection can
|
||||
/// easily need several minutes).
|
||||
pub request_reply_timeout: Duration,
|
||||
/// A quiet client pings this often (keeps it under `idle_timeout`).
|
||||
pub keepalive: Duration,
|
||||
/// Messages queued for one client before it counts as stuck and is disconnected.
|
||||
@@ -68,6 +74,7 @@ impl Default for NetLimits {
|
||||
burst: 400.0,
|
||||
hello_timeout: Duration::from_secs(10),
|
||||
idle_timeout: Duration::from_secs(45),
|
||||
request_reply_timeout: Duration::from_secs(300),
|
||||
keepalive: Duration::from_secs(15),
|
||||
queue: 1024,
|
||||
write_timeout: Duration::from_secs(10),
|
||||
@@ -85,3 +92,16 @@ impl Default for NetLimits {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_reply_wait_is_longer_than_idle() {
|
||||
let d = NetLimits::default();
|
||||
// Publishing uploads a whole file; a slow link must not cut it off at the session idle
|
||||
// timeout. Keep the reply wait comfortably above it.
|
||||
assert!(d.request_reply_timeout > d.idle_timeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,13 +196,15 @@ pub fn plugins(server: &Endpoint, key: &str, req: PluginReq, limits: &NetLimits,
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// A WebSocket to `server` that waits for answers up to the idle timeout (a large upload takes the
|
||||
/// server a while to keep).
|
||||
/// A WebSocket to `server` that waits for answers up to the request-reply timeout (a large upload
|
||||
/// takes the server a while to keep).
|
||||
fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result<Ws, String> {
|
||||
let (stream, _, _) = open(server, timeout, limits)?;
|
||||
let (ws, _) = tungstenite::client::client_with_config(server.url(), stream, Some(config(limits.max_server_message)))
|
||||
.map_err(|e| format!("{} doesn't answer as a FigCraft server: {e}", server.display()))?;
|
||||
ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).map_err(|e| e.to_string())?;
|
||||
// A publish uploads a whole file; a large project on a slow link can take several minutes to
|
||||
// receive and answer. The wait uses `request_reply_timeout`, not the session `idle_timeout`.
|
||||
ws.get_ref().set_read_timeout(Some(limits.request_reply_timeout)).map_err(|e| e.to_string())?;
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
|
||||
+13
-5
@@ -44,6 +44,10 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Deepest descendant [`Document::world_render_bounds`] follows: as deep as the renderer draws
|
||||
/// (`figcraft_render::MAX_EXPORT_DEPTH`), so a crafted chain can't overflow the stack (#1062).
|
||||
const MAX_RENDER_DEPTH: usize = 256;
|
||||
|
||||
/// Stable node identifier, unique within a document.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
@@ -703,12 +707,16 @@ impl Document {
|
||||
/// a clipping frame's own area, else that and what its visible children show (masked
|
||||
/// children only within their mask). A group is what its children show, grown by its effects.
|
||||
pub fn world_render_bounds(&self, id: NodeId) -> Option<Rect> {
|
||||
self.render_bounds_at(id, 0)
|
||||
}
|
||||
|
||||
fn render_bounds_at(&self, id: NodeId, depth: usize) -> Option<Rect> {
|
||||
let n = self.node(id)?;
|
||||
let own = n.own_paint_bounds(self.world_transform(id));
|
||||
if n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children() {
|
||||
if (n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children()) || depth >= MAX_RENDER_DEPTH {
|
||||
return Some(own);
|
||||
}
|
||||
Some(match (self.children_render_bounds(n), &n.kind) {
|
||||
Some(match (self.children_render_bounds(n, depth + 1), &n.kind) {
|
||||
(Some(kids), NodeKind::Group) => n.grow_by_effects(kids),
|
||||
(Some(kids), _) => own.union(kids),
|
||||
(None, _) => own,
|
||||
@@ -716,15 +724,15 @@ impl Document {
|
||||
}
|
||||
|
||||
/// What `n`'s visible children show, together: each mask's run within the mask's bounds.
|
||||
fn children_render_bounds(&self, n: &Node) -> Option<Rect> {
|
||||
fn children_render_bounds(&self, n: &Node, depth: usize) -> Option<Rect> {
|
||||
let shown = |i: &NodeId| self.node(*i).is_some_and(|c| c.visible && !c.is_mask);
|
||||
let mut out: Option<Rect> = None;
|
||||
for run in self.paint_runs(n) {
|
||||
let ids = n.children.get(run).unwrap_or(&[]);
|
||||
let mask = ids.first().copied().filter(|m| self.node(*m).is_some_and(|c| c.is_mask && c.visible));
|
||||
let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.world_render_bounds(*i)).reduce(|a, b| a.union(b));
|
||||
let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.render_bounds_at(*i, depth)).reduce(|a, b| a.union(b));
|
||||
let r = match mask {
|
||||
Some(m) => r.zip(self.world_render_bounds(m)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0),
|
||||
Some(m) => r.zip(self.render_bounds_at(m, depth)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0),
|
||||
None => r,
|
||||
};
|
||||
out = match (out, r) {
|
||||
|
||||
@@ -560,6 +560,24 @@ fn is_ancestor_holds_for_very_deep_chains() {
|
||||
assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_bounds_stop_where_drawing_stops_on_very_deep_chains() {
|
||||
// A crafted file nests groups far deeper than anything draws (#1062). The leaf counts while
|
||||
// it's within the renderer's depth and is left out past it, as the renderer leaves it out.
|
||||
let chain = |depth: usize| {
|
||||
let mut d = Document::new("x");
|
||||
let mut parent = page(&d);
|
||||
for _ in 0..depth {
|
||||
parent = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
}
|
||||
d.create(parent, NodeKind::Rectangle, "R", 100.0, 100.0, 10.0, 10.0).unwrap();
|
||||
let top = d.node(page(&d)).unwrap().children[0];
|
||||
d.world_render_bounds(top).unwrap()
|
||||
};
|
||||
assert_eq!(chain(MAX_RENDER_DEPTH).x1, 110.0);
|
||||
assert_eq!(chain(20_000).x1, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_insert_at_run_boundary_keeps_runs_disjoint() {
|
||||
let mut t = TextData::new("0123456789");
|
||||
|
||||
+20
-1
@@ -253,10 +253,17 @@ pub struct TextStyle {
|
||||
pub style_id: Option<String>,
|
||||
}
|
||||
|
||||
/// The font new text starts in. A build can name another (`FIGCRAFT_DEFAULT_FONT="Noto Sans Thai"`,
|
||||
/// for a distribution whose users mostly write a script Inter lacks); empty means Inter.
|
||||
pub const DEFAULT_FONT: &str = match option_env!("FIGCRAFT_DEFAULT_FONT") {
|
||||
Some(family) if !family.is_empty() => family,
|
||||
_ => "Inter",
|
||||
};
|
||||
|
||||
impl Default for TextStyle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
font_family: "Inter".into(),
|
||||
font_family: DEFAULT_FONT.into(),
|
||||
font_style: "Regular".into(),
|
||||
font_size: 12.0,
|
||||
line_height: LineHeight::Auto,
|
||||
@@ -790,6 +797,18 @@ mod case_tests {
|
||||
assert!(!valid_feature_tag("lig") && !valid_feature_tag("ligatures") && !valid_feature_tag("li\u{e9}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_text_starts_in_the_builds_default_font() {
|
||||
// Inter, unless the build names another with `FIGCRAFT_DEFAULT_FONT` (empty means Inter).
|
||||
let named = option_env!("FIGCRAFT_DEFAULT_FONT").filter(|f| !f.is_empty());
|
||||
assert_eq!(DEFAULT_FONT, named.unwrap_or("Inter"));
|
||||
let st = TextStyle::default();
|
||||
assert_eq!(st.font_family, DEFAULT_FONT);
|
||||
// The family is always written, so a saved file keeps it in a build with another default.
|
||||
let saved = serde_json::to_value(&st).unwrap();
|
||||
assert_eq!(saved["font_family"], DEFAULT_FONT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variations_are_backward_compatible_and_imply_weight() {
|
||||
// Files written before variable fonts load with no custom axes, and stay byte-identical.
|
||||
|
||||
@@ -258,3 +258,25 @@ fn a_nested_batch_has_its_own_names() {
|
||||
);
|
||||
assert!(msg.contains("`$ref` \"frame\" names no step"), "the outer names aren't visible inside: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failing_command_names_its_step_and_rolls_back() {
|
||||
let mut s = Session::new();
|
||||
let frame = id(&s.execute("node.create", &json!({"type": "frame", "width": 50, "height": 50})).unwrap());
|
||||
let text = id(&s.execute("node.create", &json!({"type": "text", "text": "orig"})).unwrap());
|
||||
let undo = s.ds().unwrap().history.undo.len();
|
||||
let name = s.doc().unwrap().node(s.page().unwrap()).unwrap().name.clone();
|
||||
let msg = batch_error(
|
||||
&mut s,
|
||||
json!([
|
||||
{"command": "text.set", "params": {"ids": [text.0], "text": "novo"}},
|
||||
{"command": "text.set", "params": {"ids": [frame.0], "text": "erro"}},
|
||||
{"command": "page.rename", "params": {"name": "Depois do erro"}}
|
||||
]),
|
||||
);
|
||||
// Still the command's own kind of error, its message saying which step.
|
||||
assert!(msg.starts_with("step 1: ") && msg.contains("is not text"), "{msg}");
|
||||
assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "orig"));
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, name);
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"image.place",
|
||||
"Place image",
|
||||
"{data (base64) | path, x?, y?, width?, height?, parent?, mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
|
||||
"{data (base64) | path, x?, y?, width?, height?, parent?, target? (an existing layer: the image is added on top of its fills instead of making a layer), mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
|
||||
true,
|
||||
place_image
|
||||
),
|
||||
@@ -394,9 +394,28 @@ fn read_path(path: &str) -> Result<Vec<u8>> {
|
||||
Err(crate::EngineError::Other(format!("{path}: files can't be read in the browser")))
|
||||
}
|
||||
|
||||
/// Layers that can show an image inside their own outline.
|
||||
pub fn accepts_image_fill(kind: &NodeKind) -> bool {
|
||||
kind.is_shape() || kind.is_frame_like() || matches!(kind, NodeKind::Text(_) | NodeKind::BooleanOp { .. })
|
||||
}
|
||||
|
||||
/// Add an image fill on top of the layer's fills: the others keep their place and variable
|
||||
/// bindings; a linked fill style no longer describes the list, so the link goes.
|
||||
fn add_image_fill(node: &mut Node, image: String, mode: ImageScaleMode) {
|
||||
node.fills.push(Paint::image(image, mode));
|
||||
node.styles.fill = None;
|
||||
}
|
||||
|
||||
/// Place an image as a rectangle with an image fill (Figma's image layer).
|
||||
pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "image.place";
|
||||
let target = opt_id(cmd, p, "target")?;
|
||||
if let Some(id) = target {
|
||||
let node = s.doc()?.node(id).ok_or(crate::EngineError::NoNode(id))?;
|
||||
if !accepts_image_fill(&node.kind) {
|
||||
return Err(bad(cmd, "`target` must be a layer that can have a fill"));
|
||||
}
|
||||
}
|
||||
let bytes = if let Some(b64) = string(p, "data") {
|
||||
use base64::Engine as _;
|
||||
let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64);
|
||||
@@ -412,6 +431,14 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"tile" => ImageScaleMode::Tile,
|
||||
_ => ImageScaleMode::Fill,
|
||||
};
|
||||
if let Some(id) = target {
|
||||
let doc = s.doc_mut()?;
|
||||
let (hash, _, _) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let profile = figcraft_render::images::profile_name(&hash, doc.images.get(&hash));
|
||||
let node = doc.get_mut(id)?;
|
||||
add_image_fill(node, hash.clone(), mode);
|
||||
return Ok(json!({"id": id, "image": hash, "width": node.width, "height": node.height, "imageProfile": profile}));
|
||||
}
|
||||
let page = s.page()?;
|
||||
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
|
||||
let ds = s.ds_mut()?;
|
||||
|
||||
@@ -456,16 +456,19 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
let closed = figcraft_doc::geometry::is_closed(&n);
|
||||
let Some(geom) = (match &n.kind {
|
||||
NodeKind::Text(_) | NodeKind::BooleanOp { .. } => figcraft_render::node_outline(doc, &n).map(|(p, _)| p),
|
||||
_ => figcraft_doc::geometry::stroke_geometry(&n),
|
||||
let Some((area, rule)) = (match &n.kind {
|
||||
NodeKind::Text(_) => figcraft_render::node_outline(doc, &n)
|
||||
.map(|(p, _)| (figcraft_render::draw::stroke_area(&n, &p, closed), figcraft_render::FillRule::Winding)),
|
||||
_ => figcraft_render::stroke_fill(doc, &n),
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let area = figcraft_render::draw::stroke_area(&n, &geom, closed);
|
||||
let mut path = (*area).clone();
|
||||
// Union the pieces so the outline is one clean shape.
|
||||
if let Ok(u) = figcraft_pathops::simplify(&path) {
|
||||
// Simplify non-zero strokes into even-odd contours. Inside/outside rings already use
|
||||
// even-odd: interpreting their same-direction contours as non-zero would fill the hole.
|
||||
if rule == figcraft_render::FillRule::Winding
|
||||
&& let Ok(u) = figcraft_pathops::simplify(&path)
|
||||
{
|
||||
path = u;
|
||||
}
|
||||
let bb = path.bounding_box();
|
||||
|
||||
@@ -1009,8 +1009,8 @@ fn bound_field(n: &Node, field: &str) -> Option<String> {
|
||||
let cl = &n.child_layout;
|
||||
let pad = |i: usize| layout.and_then(|l| l.padding.get(i).copied());
|
||||
Some(match field {
|
||||
"width" => format!("{}", n.width),
|
||||
"height" => format!("{}", n.height),
|
||||
"width" | "w" => format!("{}", n.width),
|
||||
"height" | "h" => format!("{}", n.height),
|
||||
"minWidth" => format!("{:?}", cl.min_w),
|
||||
"maxWidth" => format!("{:?}", cl.max_w),
|
||||
"minHeight" => format!("{:?}", cl.min_h),
|
||||
|
||||
@@ -427,7 +427,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"pref.language",
|
||||
"Interface language",
|
||||
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|zh-Hans|…; else English}",
|
||||
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|vi|zh-Hans|…; else English}",
|
||||
false,
|
||||
|s, p| {
|
||||
let lang = req_string("pref.language", p, "language")?;
|
||||
|
||||
@@ -117,6 +117,18 @@ impl From<figcraft_doc::DocError> for EngineError {
|
||||
}
|
||||
}
|
||||
|
||||
impl EngineError {
|
||||
/// This error from step `step` of a batch, saying so in its message; the kind stays (errors
|
||||
/// without a message stay as they are).
|
||||
fn at_step(self, step: usize) -> Self {
|
||||
match self {
|
||||
EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: format!("step {step}: {msg}") },
|
||||
EngineError::Other(msg) => EngineError::Other(format!("step {step}: {msg}")),
|
||||
e => e,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, EngineError>;
|
||||
|
||||
pub fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
@@ -1264,7 +1276,8 @@ impl Session {
|
||||
/// so its steps resolve references the same way, inside its own [`Session::grouped`].
|
||||
pub fn batch_step(&mut self, scope: &mut BatchScope, cmd: &str, params: &Value) -> Result<()> {
|
||||
let p = if cmd == "command.batch" { params.clone() } else { scope.resolve(params)? };
|
||||
let v = self.execute(cmd, &p)?;
|
||||
// Say which step failed; a nested batch names its own steps.
|
||||
let v = self.execute(cmd, &p).map_err(|e| if cmd == "command.batch" { e } else { e.at_step(scope.next_step()) })?;
|
||||
scope.push(v);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -272,6 +272,23 @@ fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() {
|
||||
assert_eq!((&r["status"], &r["code"], &r["share"]), (&json!("off"), &Value::Null, &Value::Null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_online_uses_a_separate_key_param() {
|
||||
// The Share popover sends the server key as its own field when the address (from Preferences,
|
||||
// which never holds a key) needs one. It must reach `publish` and not leak into the summary.
|
||||
let (mut s, script) = session();
|
||||
ex(&mut s, "pref.publishServer", json!({"url": "wss://share.example/"}));
|
||||
let r = ex(&mut s, "collab.share", json!({"key": "KeyKeyKeyKeyKeyKeyKey1"}));
|
||||
assert_eq!(r["status"], "connecting");
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
let [p] = sc.published.as_slice() else { panic!("published {}", sc.published.len()) };
|
||||
assert_eq!(p.server, "wss://share.example/");
|
||||
assert_eq!(p.key.as_deref(), Some("KeyKeyKeyKeyKeyKeyKey1"));
|
||||
}
|
||||
assert!(!r.to_string().contains("KeyKey"), "the server's key is nowhere in the summary");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_without_join_codes_leaves_the_links() {
|
||||
let (mut s, script) = session();
|
||||
|
||||
@@ -288,3 +288,25 @@ fn a_drag_with_the_text_tool_makes_a_fixed_size_box() {
|
||||
let (b3, _) = text_tool((100.0, 100.0), (180.0, 101.0), "A");
|
||||
assert_eq!((b3.width(), b3.height()), (80.0, line.height()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_text_starts_in_the_builds_default_font() {
|
||||
// `text.create` without a fontFamily and a click with the Text tool both start in
|
||||
// `figcraft_doc::DEFAULT_FONT` (Inter, unless the build names another with
|
||||
// `FIGCRAFT_DEFAULT_FONT`), and the family is written with the text.
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
let mut s = Session::new();
|
||||
let made = text(&mut s, "Text", json!({}));
|
||||
s.set_tool(ToolId::Text);
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, 300.0, 300.0, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, 300.0, 300.0, 1.0)).unwrap();
|
||||
crate::textedit::insert(&mut s, "Tool").unwrap();
|
||||
let typed = s.ds().unwrap().text_edit.as_ref().unwrap().id;
|
||||
assert_ne!(made, typed);
|
||||
let doc = s.doc().unwrap();
|
||||
for t in [made, typed] {
|
||||
let td = doc.node(t).unwrap().kind.text().unwrap();
|
||||
assert_eq!(td.style.font_family, figcraft_doc::DEFAULT_FONT);
|
||||
assert_eq!(serde_json::to_value(td).unwrap()["style"]["font_family"], figcraft_doc::DEFAULT_FONT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use figcraft_color::{ImageScaleMode, PaintKind};
|
||||
use figcraft_doc::geometry::select_target;
|
||||
use figcraft_doc::*;
|
||||
use figcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
@@ -1739,6 +1740,44 @@ impl Session {
|
||||
if flip_y {
|
||||
t *= Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, nh]);
|
||||
}
|
||||
// Text reflow and auto layout can recompute this box after the preview (a size bound
|
||||
// to a variable lets go of it: the resize is an explicit edit).
|
||||
let n = doc.get(*id)?;
|
||||
let crop_images = ev.cmd
|
||||
&& !matches!(n.kind, NodeKind::Text(_))
|
||||
&& !std::iter::once(*id).chain(doc.ancestors(*id)).any(|a| doc.node(a).is_some_and(|n| n.layout.is_some()));
|
||||
if crop_images {
|
||||
// Crop the layer's image fills without moving their pixels on the page.
|
||||
// Work from the interaction snapshot, just like the geometry on every move.
|
||||
let (w, h) = (n.width.max(1e-6), n.height.max(1e-6));
|
||||
let to_old =
|
||||
Affine::scale_non_uniform(1.0 / w, 1.0 / h) * world.inverse() * t * Affine::scale_non_uniform(nw.max(0.01), nh.max(0.01));
|
||||
let mut fills = n.fills.clone();
|
||||
for paint in &mut fills {
|
||||
let PaintKind::Image(img) = &mut paint.kind else { continue };
|
||||
// A repeating image cannot be represented by a single Crop placement.
|
||||
if img.scale_mode == ImageScaleMode::Tile {
|
||||
continue;
|
||||
}
|
||||
let Some(data) = doc.images.get(&img.image) else { continue };
|
||||
let (iw, ih) = (f64::from(data.width), f64::from(data.height));
|
||||
let crop = if img.scale_mode == ImageScaleMode::Crop {
|
||||
img.effective_crop(iw, ih, w, h)
|
||||
} else {
|
||||
let (rw, rh) = img.rotated_size(iw.max(1.0), ih.max(1.0));
|
||||
let s = if img.scale_mode == ImageScaleMode::Fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) };
|
||||
[w / (rw * s), 0.0, 0.0, h / (rh * s), (rw * s - w) / (2.0 * rw * s), (rh * s - h) / (2.0 * rh * s)]
|
||||
};
|
||||
// A degenerate (zero-scale) layer has no inverse: its image keeps its placement.
|
||||
let crop = (Affine::new(crop) * to_old).as_coeffs();
|
||||
if !crop.iter().all(|c| c.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
img.crop = Some(crop);
|
||||
img.scale_mode = ImageScaleMode::Crop;
|
||||
}
|
||||
doc.get_mut(*id)?.fills = fills;
|
||||
}
|
||||
doc.set_world_transform(*id, t)?;
|
||||
if kept.is_empty() {
|
||||
crate::cmd::props::resize_node(doc, *id, nw.max(0.01), nh.max(0.01))?;
|
||||
|
||||
@@ -98,6 +98,224 @@ const ALT: Mods = Mods { shift: false, alt: true, cmd: false };
|
||||
const BOTH: Mods = Mods { shift: true, alt: true, cmd: false };
|
||||
const CMD: Mods = Mods { shift: false, alt: false, cmd: true };
|
||||
|
||||
/// An asymmetric synthetic image: every column is different, so recentering is visible.
|
||||
fn image_layer(s: &mut Session) -> NodeId {
|
||||
use base64::Engine as _;
|
||||
let image = image::RgbaImage::from_fn(200, 100, |x, y| image::Rgba([x as u8, y as u8, 120, 255]));
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
image.write_to(&mut bytes, image::ImageFormat::Png).unwrap();
|
||||
id(&ex(
|
||||
s,
|
||||
"file.import",
|
||||
json!({"data": base64::engine::general_purpose::STANDARD.encode(bytes.into_inner()), "name": "crop.png", "x": 0, "y": 0}),
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_resize_crops_image_pixels_and_round_trips() {
|
||||
let mut s = session();
|
||||
let layer = image_layer(&mut s);
|
||||
let original = s.doc().unwrap().clone();
|
||||
let before = figcraft_render::render_node(&original, layer, 1.0).unwrap();
|
||||
let steps = undo_steps(&s);
|
||||
// Public crop documentation: Command (macOS) / Ctrl (elsewhere) quickly crops an image.
|
||||
// Moving the right edge must keep the left half's pixels, not recenter or scale them.
|
||||
s.pointer(&ev(PointerKind::Down, Point::new(200.0, 50.0), CMD)).unwrap();
|
||||
for x in [150.0, 100.0] {
|
||||
s.pointer(&ev(PointerKind::Move, Point::new(x, 50.0), CMD)).unwrap();
|
||||
}
|
||||
s.pointer(&ev(PointerKind::Up, Point::new(100.0, 50.0), CMD)).unwrap();
|
||||
assert_eq!(size(&s, layer), (100.0, 100.0));
|
||||
let after = figcraft_render::render_node(s.doc().unwrap(), layer, 1.0).unwrap();
|
||||
for y in 0..100usize {
|
||||
assert_eq!(&after.data()[y * 400..(y + 1) * 400], &before.data()[y * 800..y * 800 + 400], "row {y}: the image stays put");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().images, original.images, "cropping keeps the source bytes");
|
||||
assert_eq!(undo_steps(&s), steps + 1);
|
||||
let cropped = s.doc().unwrap().clone();
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc().unwrap(), &original);
|
||||
ex(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc().unwrap(), &cropped);
|
||||
let path = std::env::temp_dir().join(format!("figcraft-command-crop-{}.figcraft", std::process::id()));
|
||||
ex(&mut s, "file.saveAs", json!({"path": path}));
|
||||
let mut reopened = session();
|
||||
ex(&mut reopened, "file.open", json!({"path": path}));
|
||||
assert_eq!(reopened.doc().unwrap().node(layer), cropped.node(layer));
|
||||
assert_eq!(reopened.doc().unwrap().images, cropped.images);
|
||||
assert_eq!(figcraft_render::render_node(reopened.doc().unwrap(), layer, 1.0).unwrap().data(), after.data());
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_crop_keeps_image_placement_in_the_layers_axes() {
|
||||
use figcraft_doc::color::{ImageScaleMode, PaintKind};
|
||||
use figcraft_geom::Affine;
|
||||
// Each row exercises a distinct placement: Fill, Fit, and an existing affine Crop.
|
||||
// Geometry follows the existing resize rules; image pixels stay fixed in page space.
|
||||
let rows = [
|
||||
("fill", None, 0.0, Point::new(0.0, 50.0), Point::new(40.0, 50.0), CMD, (40.0, 0.0, 160.0, 150.0)),
|
||||
("fit", None, 30.0, Point::new(100.0, 0.0), Point::new(100.0, 20.0), CMD, (0.0, 20.0, 200.0, 130.0)),
|
||||
("fill", None, 0.0, Point::new(0.0, 50.0), Point::new(240.0, 50.0), CMD, (240.0, 0.0, 40.0, 150.0)),
|
||||
(
|
||||
"crop",
|
||||
Some([0.8, 0.1, -0.2, 0.9, 0.1, 0.05]),
|
||||
30.0,
|
||||
Point::new(200.0, 150.0),
|
||||
Point::new(150.0, 112.5),
|
||||
Mods { alt: true, ..CMD },
|
||||
(50.0, 37.5, 100.0, 75.0),
|
||||
),
|
||||
];
|
||||
for (mode, crop, angle, from, to, mods, (x, y, w, h)) in rows {
|
||||
let mut s = session();
|
||||
let layer = image_layer(&mut s);
|
||||
let mut fills = s.doc().unwrap().node(layer).unwrap().fills.clone();
|
||||
let PaintKind::Image(img) = &mut fills[0].kind else { panic!("image fill") };
|
||||
img.scale_mode = match mode {
|
||||
"fit" => ImageScaleMode::Fit,
|
||||
"crop" => ImageScaleMode::Crop,
|
||||
_ => ImageScaleMode::Fill,
|
||||
};
|
||||
img.crop = crop;
|
||||
set(&mut s, &[layer], json!({"height": 150, "rotation": angle, "fills": fills}));
|
||||
let doc = s.doc().unwrap();
|
||||
let world = doc.world_transform(layer);
|
||||
let n = doc.node(layer).unwrap();
|
||||
let old_crop = crop.unwrap_or(if mode == "fit" { [1.0, 0.0, 0.0, 1.5, 0.0, -0.25] } else { [2.0 / 3.0, 0.0, 0.0, 1.0, 1.0 / 6.0, 0.0] });
|
||||
let old_mapping = Affine::new(old_crop) * Affine::scale_non_uniform(1.0 / n.width, 1.0 / n.height) * world.inverse();
|
||||
drag(&mut s, world * from, world * to, mods);
|
||||
let doc = s.doc().unwrap();
|
||||
let n = doc.node(layer).unwrap();
|
||||
assert!(near(n.width, w) && near(n.height, h), "{mode}: crop box size");
|
||||
assert!((doc.world_transform(layer) * Point::ZERO - world * Point::new(x, y)).hypot() < 1e-6, "{mode}: crop box anchor");
|
||||
let PaintKind::Image(img) = &n.fills[0].kind else { panic!("image fill") };
|
||||
assert_eq!(img.scale_mode, ImageScaleMode::Crop, "{mode}");
|
||||
let mapping =
|
||||
Affine::new(img.crop.unwrap()) * Affine::scale_non_uniform(1.0 / n.width, 1.0 / n.height) * doc.world_transform(layer).inverse();
|
||||
for p in [Point::ZERO, Point::new(80.0, 60.0), Point::new(170.0, 90.0)] {
|
||||
assert!((mapping * p - old_mapping * p).hypot() < 1e-6, "{mode}: same page point samples the same source pixel");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_resize_and_scale_keep_image_fill_settings() {
|
||||
for (tool, cmd, ctrl) in [("move", false, false), ("move", false, true), ("scale", true, false)] {
|
||||
let mut s = session();
|
||||
let layer = image_layer(&mut s);
|
||||
let fills = s.doc().unwrap().node(layer).unwrap().fills.clone();
|
||||
ex(&mut s, "tool.set", json!({"tool": tool}));
|
||||
let to = Point::new(100.0, 50.0);
|
||||
for (kind, p) in [(PointerKind::Down, Point::new(200.0, 100.0)), (PointerKind::Move, to), (PointerKind::Up, to)] {
|
||||
let mut event = ev(kind, p, Mods { cmd, ..NONE });
|
||||
event.ctrl = ctrl;
|
||||
s.pointer(&event).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().node(layer).unwrap().fills, fills, "{tool}, cmd={cmd}, ctrl={ctrl}");
|
||||
assert_eq!(size(&s, layer), (100.0, 50.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_crop_preserves_each_image_fill_and_leaves_tiles() {
|
||||
use figcraft_doc::color::{Color, ImageScaleMode, Paint, PaintKind};
|
||||
let mut s = session();
|
||||
let layer = image_layer(&mut s);
|
||||
let image = s.doc().unwrap().node(layer).unwrap().fills[0].clone();
|
||||
let mut rotated = image.clone();
|
||||
rotated.opacity = 0.4;
|
||||
let PaintKind::Image(img) = &mut rotated.kind else { panic!("image fill") };
|
||||
img.rotation = 1;
|
||||
let mut tile = image.clone();
|
||||
let PaintKind::Image(img) = &mut tile.kind else { panic!("image fill") };
|
||||
img.scale_mode = ImageScaleMode::Tile;
|
||||
let mut expected = vec![Paint::solid(Color::from_rgba8(20, 40, 60, 255)), rotated, image, tile];
|
||||
set(&mut s, &[layer], json!({"fills": expected}));
|
||||
drag(&mut s, Point::new(200.0, 50.0), Point::new(100.0, 50.0), CMD);
|
||||
for (i, crop) in [(1, [0.5, 0.0, 0.0, 0.25, 0.0, 0.375]), (2, [0.5, 0.0, 0.0, 1.0, 0.0, 0.0])] {
|
||||
let PaintKind::Image(img) = &mut expected[i].kind else { panic!("image fill") };
|
||||
img.scale_mode = ImageScaleMode::Crop;
|
||||
img.crop = Some(crop);
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().node(layer).unwrap().fills, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_resize_leaves_fills_when_text_or_auto_layout_recomputes_the_box() {
|
||||
for case in ["text", "child", "frame"] {
|
||||
let mut s = session();
|
||||
let image = image_layer(&mut s);
|
||||
let fills = s.doc().unwrap().node(image).unwrap().fills.clone();
|
||||
let layer = match case {
|
||||
"text" => id(&ex(
|
||||
&mut s,
|
||||
"text.create",
|
||||
json!({"text": "hello world hello world hello world", "props": {
|
||||
"width": 200, "height": 100, "fontSize": 24, "textAutoResize": "none", "fills": fills
|
||||
}}),
|
||||
)),
|
||||
_ => {
|
||||
let frame = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100})));
|
||||
set(&mut s, &[frame], json!({"layout": "horizontal", "padding": 20}));
|
||||
if case == "child" {
|
||||
ex(&mut s, "node.reparent", json!({"ids": [image], "parent": frame}));
|
||||
set(&mut s, &[image], json!({"minWidth": 150}));
|
||||
image
|
||||
} else {
|
||||
set(&mut s, &[frame], json!({"fills": fills}));
|
||||
frame
|
||||
}
|
||||
}
|
||||
};
|
||||
ex(&mut s, "select.set", json!({"ids": [layer]}));
|
||||
let doc = s.doc().unwrap();
|
||||
let world = doc.world_transform(layer);
|
||||
let n = doc.node(layer).unwrap();
|
||||
let from = world * Point::new(n.width, n.height / 2.0);
|
||||
let to = world * Point::new(10.0, n.height / 2.0);
|
||||
drag(&mut s, from, to, CMD);
|
||||
assert_eq!(s.doc().unwrap().node(layer).unwrap().fills, fills, "{case}: keep the existing resize behaviour");
|
||||
}
|
||||
}
|
||||
|
||||
/// A resize is an explicit edit of the size: a size bound to a variable lets go of it (#809), so
|
||||
/// Cmd/Ctrl crops a bound layer's image as any other's; other bindings stay.
|
||||
#[test]
|
||||
fn image_crop_detaches_a_bound_size_and_crops() {
|
||||
for (field, value, from, to, (w, h)) in [
|
||||
("width", 200, Point::new(200.0, 50.0), Point::new(100.0, 50.0), (100usize, 100usize)),
|
||||
("height", 100, Point::new(100.0, 100.0), Point::new(100.0, 50.0), (200, 50)),
|
||||
("w", 200, Point::new(200.0, 50.0), Point::new(100.0, 50.0), (100, 100)),
|
||||
("h", 100, Point::new(100.0, 100.0), Point::new(100.0, 50.0), (200, 50)),
|
||||
("opacity", 100, Point::new(200.0, 50.0), Point::new(100.0, 50.0), (100, 100)),
|
||||
] {
|
||||
for mods in [CMD, NONE] {
|
||||
let mut s = session();
|
||||
let layer = image_layer(&mut s);
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Tokens"}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "bound", "type": "number", "value": value}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [layer], "field": field, "variable": "bound"}));
|
||||
let before = s.doc().unwrap().clone();
|
||||
let pixels = figcraft_render::render_node(&before, layer, 1.0).unwrap();
|
||||
drag(&mut s, from, to, mods);
|
||||
let doc = s.doc().unwrap();
|
||||
let n = doc.node(layer).unwrap();
|
||||
assert_eq!(n.bound.is_empty(), field != "opacity", "{field}: a size binding goes, others stay");
|
||||
assert_eq!(doc.variables, before.variables, "{field}: do not edit the variable");
|
||||
assert_eq!((n.width, n.height), (w as f64, h as f64), "{field}");
|
||||
if mods.cmd {
|
||||
let after = figcraft_render::render_node(doc, layer, 1.0).unwrap();
|
||||
for y in 0..h {
|
||||
assert_eq!(&after.data()[y * w * 4..(y + 1) * w * 4], &pixels.data()[y * 800..y * 800 + w * 4], "{field}: the image stays put");
|
||||
}
|
||||
} else {
|
||||
assert_eq!(n.fills, before.node(layer).unwrap().fills, "{field}: keep the existing resize behaviour");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_drags_resize_by_the_documented_rules() {
|
||||
// A 100×50 box; (handle, pointer in the box's axes, modifiers, new box x0 y0 x1 y1, derivation).
|
||||
|
||||
@@ -6,6 +6,8 @@ mod empty_text_box_undo;
|
||||
mod library_style_acceptance;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod list_indent_response;
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod outline_stroke;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod scope_reset;
|
||||
#[allow(clippy::unwrap_used)]
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Outline stroke keeps the renderer's holes and existing property ownership.
|
||||
|
||||
use figcraft_doc::{Document, NodeId, NodeKind};
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_render::{Options, View, render_view};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap()
|
||||
}
|
||||
|
||||
fn rectangle(s: &mut Session, opacity: f64) -> NodeId {
|
||||
NodeId(
|
||||
run(
|
||||
s,
|
||||
"shape.rectangle",
|
||||
json!({
|
||||
"x": 24, "y": 24, "width": 64, "height": 64,
|
||||
"props": {"fill": "#FF0000", "stroke": "#0000FF", "strokeWeight": 8, "opacity": opacity}
|
||||
}),
|
||||
)["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
fn pixels(doc: &Document) -> Vec<u8> {
|
||||
render_view(doc, doc.first_page().unwrap(), &View { origin: (0.0, 0.0), scale: 1.0, width: 112, height: 112 }, &Options::default())
|
||||
.unwrap()
|
||||
.data()
|
||||
.to_vec()
|
||||
}
|
||||
|
||||
fn outlined(s: &mut Session, id: NodeId) -> NodeId {
|
||||
NodeId(run(s, "vector.outlineStroke", json!({"ids": [id]}))["ids"][0].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn same_pixels(actual: &[u8], expected: &[u8]) {
|
||||
assert_eq!(actual.len(), expected.len());
|
||||
let differences: Vec<_> = actual.iter().zip(expected).enumerate().filter(|(_, (a, b))| a != b).take(8).collect();
|
||||
assert!(differences.is_empty(), "rendered channels changed (byte, actual/expected): {differences:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_keeps_rings_through_undo_and_native_reopen() {
|
||||
for vector in [false, true] {
|
||||
let mut s = Session::new();
|
||||
let mut id = rectangle(&mut s, 1.0);
|
||||
if vector {
|
||||
id = NodeId(run(&mut s, "vector.flatten", json!({"ids": [id]}))["id"].as_u64().unwrap());
|
||||
}
|
||||
let before = s.doc().unwrap().clone();
|
||||
let expected = pixels(&before);
|
||||
let depth = s.undo_depth();
|
||||
let group = outlined(&mut s, id);
|
||||
let after = s.doc().unwrap().clone();
|
||||
same_pixels(&pixels(&after), &expected);
|
||||
assert!(after.node(id).unwrap().strokes.is_empty());
|
||||
assert_eq!(s.selection(), vec![group]);
|
||||
assert_eq!(s.undo_depth(), (depth.0 + 1, 0));
|
||||
let (loaded, notes) = figcraft_format::load_bytes(&figcraft_format::save_bytes(&after, None).unwrap()).unwrap();
|
||||
assert!(notes.is_empty(), "{notes:?}");
|
||||
same_pixels(&pixels(&loaded), &expected);
|
||||
assert!(s.undo().unwrap());
|
||||
assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), serde_json::to_value(&before).unwrap());
|
||||
assert!(s.redo().unwrap());
|
||||
assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), serde_json::to_value(&after).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_only_keeps_holes_at_each_opacity() {
|
||||
for opacity in [0.0, 0.25, 0.5, 1.0] {
|
||||
let mut s = Session::new();
|
||||
let id = rectangle(&mut s, opacity);
|
||||
run(&mut s, "object.removeFill", json!({"ids": [id]}));
|
||||
let before = pixels(s.doc().unwrap());
|
||||
let vector = outlined(&mut s, id);
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(matches!(doc.node(vector).unwrap().kind, NodeKind::Vector { .. }));
|
||||
assert_eq!(doc.node(vector).unwrap().opacity, opacity as f32);
|
||||
same_pixels(&pixels(doc), &before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_keeps_stroke_holes_and_alignment() {
|
||||
for props in [
|
||||
json!({"strokeAlign": "inside"}),
|
||||
json!({"strokeAlign": "outside"}),
|
||||
json!({"strokeAlign": "center"}),
|
||||
json!({"strokeSides": [2, 4, 8, 16]}),
|
||||
json!({"strokeAlign": "center", "dashes": [8, 4]}),
|
||||
] {
|
||||
for filled in [false, true] {
|
||||
let mut s = Session::new();
|
||||
let id = rectangle(&mut s, 1.0);
|
||||
run(&mut s, "node.set", json!({"ids": [id], "props": props}));
|
||||
if !filled {
|
||||
run(&mut s, "object.removeFill", json!({"ids": [id]}));
|
||||
}
|
||||
let before = pixels(s.doc().unwrap());
|
||||
outlined(&mut s, id);
|
||||
same_pixels(&pixels(s.doc().unwrap()), &before);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outlining_an_instance_keeps_its_inherited_opacity_link() {
|
||||
let mut s = Session::new();
|
||||
let component = NodeId(
|
||||
run(
|
||||
&mut s,
|
||||
"frame.create",
|
||||
json!({
|
||||
"width": 64, "height": 64,
|
||||
"props": {"fill": "#FF0000", "stroke": "#0000FF", "strokeWeight": 8, "opacity": 0.5}
|
||||
}),
|
||||
)["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
run(&mut s, "component.create", json!({"ids": [component]}));
|
||||
let instance = NodeId(run(&mut s, "instance.create", json!({"component": component, "x": 200}))["id"].as_u64().unwrap());
|
||||
outlined(&mut s, instance);
|
||||
assert_eq!(s.doc().unwrap().node(instance).unwrap().opacity, 0.5);
|
||||
assert!(!s.doc().unwrap().node(instance).unwrap().overrides.contains("opacity"));
|
||||
run(&mut s, "node.set", json!({"id": component, "props": {"opacity": 0.25}}));
|
||||
let n = s.doc().unwrap().node(instance).unwrap();
|
||||
assert_eq!(n.opacity, 0.25, "the instance still follows its main");
|
||||
assert!(!n.overrides.contains("opacity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outlining_a_main_layer_keeps_its_instance_override() {
|
||||
let mut s = Session::new();
|
||||
let frame = NodeId(run(&mut s, "frame.create", json!({"width": 100, "height": 100}))["id"].as_u64().unwrap());
|
||||
let layer = NodeId(
|
||||
run(
|
||||
&mut s,
|
||||
"shape.rectangle",
|
||||
json!({
|
||||
"parent": frame, "x": 24, "y": 24, "width": 64, "height": 64,
|
||||
"props": {"fill": "#FF0000", "stroke": "#0000FF", "strokeWeight": 8}
|
||||
}),
|
||||
)["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
let component = run(&mut s, "component.create", json!({"ids": [frame]}))["id"].clone();
|
||||
run(&mut s, "instance.create", json!({"component": component, "x": 200}));
|
||||
let copy = s.doc().unwrap().nodes().find(|n| n.source == Some(layer)).unwrap().id;
|
||||
run(&mut s, "node.set", json!({"id": copy, "props": {"opacity": 0.25}}));
|
||||
let before = s.doc().unwrap().clone();
|
||||
let view = View { origin: (0.0, 0.0), scale: 1.0, width: 312, height: 112 };
|
||||
let expected = render_view(&before, s.page().unwrap(), &view, &Options::default()).unwrap();
|
||||
let group = outlined(&mut s, layer);
|
||||
let doc = s.doc().unwrap();
|
||||
let actual = render_view(doc, s.page().unwrap(), &view, &Options::default()).unwrap();
|
||||
same_pixels(actual.data(), expected.data());
|
||||
let instance_group = doc.node(copy).unwrap();
|
||||
assert!(matches!(instance_group.kind, NodeKind::Group));
|
||||
assert_eq!(instance_group.source, Some(group));
|
||||
assert_eq!(instance_group.opacity, 0.25);
|
||||
assert!(instance_group.overrides.contains("opacity"));
|
||||
assert!(s.undo().unwrap());
|
||||
assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), serde_json::to_value(&before).unwrap());
|
||||
assert!(s.redo().unwrap());
|
||||
let actual = render_view(s.doc().unwrap(), s.page().unwrap(), &view, &Options::default()).unwrap();
|
||||
same_pixels(actual.data(), expected.data());
|
||||
}
|
||||
@@ -265,6 +265,37 @@ fn hangul_comes_from_a_korean_font_when_one_is_installed() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thai_comes_from_a_thai_font_when_one_is_installed() {
|
||||
// The default (Latin) face has no Thai. With a Thai face (Noto Sans Thai from craft-fonts or
|
||||
// Linux, Leelawadee UI or Tahoma on Windows, Thonburi on macOS) the fallback draws every Thai
|
||||
// cluster, marks included, instead of the missing-glyph box. Skips on a machine without one.
|
||||
// "ภาษาไทย" (the Thai language) and "น้ำ" (water: a tone mark over SARA AM).
|
||||
let s = "Ab \u{0E20}\u{0E32}\u{0E29}\u{0E32}\u{0E44}\u{0E17}\u{0E22} \u{0E19}\u{0E49}\u{0E33}";
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let is_thai = |c: &char| ('\u{0E00}'..='\u{0E7F}').contains(c);
|
||||
let draws_all = |f: &Face| chars.iter().filter(|c| is_thai(c)).all(|c| f.has_char(*c));
|
||||
let Some(f) = face() else { return };
|
||||
if draws_all(&f) {
|
||||
return;
|
||||
}
|
||||
let thai = ["Noto Sans Thai", "Noto Sans Thai UI", "Leelawadee UI", "Thonburi", "Tahoma"];
|
||||
if !thai.iter().any(|t| find_face(t, "Regular").is_some_and(|t| draws_all(&t))) {
|
||||
return;
|
||||
}
|
||||
let l = layout(&text(s, TextAutoResize::WidthAndHeight), None, None);
|
||||
let glyphs: Vec<_> = l.glyphs.iter().filter(|g| chars.get(g.char_start).is_some_and(is_thai)).collect();
|
||||
assert!(!glyphs.is_empty());
|
||||
for g in &glyphs {
|
||||
assert!(g.gid != 0, "char {}: missing glyph in {} {}", g.char_start, g.face.family, g.face.style);
|
||||
let cluster = chars.get(g.char_start..g.char_end).unwrap_or(&[]);
|
||||
assert!(cluster.iter().all(|c| g.face.has_char(*c)), "chars {}..{}: drawn by {}, which lacks some", g.char_start, g.char_end, g.face.family);
|
||||
}
|
||||
let drawn: std::collections::BTreeSet<usize> = glyphs.iter().flat_map(|g| g.char_start..g.char_end).collect();
|
||||
let want: std::collections::BTreeSet<usize> = (3..chars.len()).filter(|i| chars[*i] != ' ').collect();
|
||||
assert_eq!(drawn, want, "every Thai char is in a drawn cluster");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_come_from_a_symbol_font_when_one_is_installed() {
|
||||
// ⌖ (U+2316) and ⤒ (U+2912) are in no Latin text face. With a symbol font that has them (Apple
|
||||
|
||||
@@ -408,7 +408,7 @@ fn names(f: &skrifa::FontRef) -> (Option<String>, Option<String>) {
|
||||
/// Families tried, in order, for characters a text's own font lacks. The symbol fonts come last:
|
||||
/// Apple Symbols (macOS), Segoe UI Symbol (Windows) and the Noto symbol and math fonts (Linux), for
|
||||
/// signs such as ⌖ and ⤒ that no text face has.
|
||||
const FALLBACK_FAMILIES: [&str; 24] = [
|
||||
const FALLBACK_FAMILIES: [&str; 29] = [
|
||||
"Inter",
|
||||
"Helvetica Neue",
|
||||
"Arial",
|
||||
@@ -427,6 +427,11 @@ const FALLBACK_FAMILIES: [&str; 24] = [
|
||||
"Noto Sans CJK KR",
|
||||
"Noto Sans KR",
|
||||
"Noto Sans Arabic",
|
||||
"Noto Sans Thai",
|
||||
"Noto Sans Thai UI",
|
||||
"Leelawadee UI",
|
||||
"Thonburi",
|
||||
"Tahoma",
|
||||
"Apple Symbols",
|
||||
"Segoe UI Symbol",
|
||||
"Noto Sans Symbols",
|
||||
@@ -737,6 +742,7 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
|
||||
/// The folders this platform keeps installed and activated fonts in, walked by the font scan.
|
||||
fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
||||
#[allow(unused_mut)]
|
||||
let mut v: Vec<std::path::PathBuf> = Vec::new();
|
||||
@@ -746,7 +752,11 @@ fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
||||
v.push("/System/Library/Fonts".into());
|
||||
v.push("/Library/Fonts".into());
|
||||
if let Some(h) = std::env::var_os("HOME") {
|
||||
v.push(std::path::PathBuf::from(h).join("Library/Fonts"));
|
||||
let h = std::path::PathBuf::from(h);
|
||||
v.push(h.join("Library/Fonts"));
|
||||
// Adobe Fonts activates its fonts from here (hidden files named by number), without
|
||||
// installing them into a font folder.
|
||||
v.push(h.join("Library/Application Support/Adobe/CoreSync/plugins/livetype"));
|
||||
}
|
||||
// Fonts the system downloads (PingFang and other CJK faces on current versions) live in
|
||||
// its asset store; `Assets` is the older versions' store.
|
||||
@@ -796,6 +806,9 @@ fn unix_user_font_dirs(home: Option<std::ffi::OsString>, data_home: Option<std::
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Adds the faces of every font file under `dir` (up to six folders deep) to `out`, keyed by
|
||||
/// lowercased family: files named `.ttf`, `.otf`, `.ttc` or `.otc`, and files without an extension
|
||||
/// that start with a font signature.
|
||||
fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap<String, (String, Vec<Pending>)>) {
|
||||
if depth > 6 {
|
||||
return;
|
||||
@@ -819,7 +832,7 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap<String, (Str
|
||||
continue;
|
||||
}
|
||||
let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).unwrap_or_default();
|
||||
if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") {
|
||||
if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") && !(path.extension().is_none() && starts_like_a_font(&path)) {
|
||||
continue;
|
||||
}
|
||||
// Very large files are indexed all the same (Apple Color Emoji is ~180 MB and is where the
|
||||
@@ -835,6 +848,14 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap<String, (Str
|
||||
}
|
||||
}
|
||||
|
||||
/// A file without an extension is a font when it starts with a font signature (font services such
|
||||
/// as Adobe Fonts keep their fonts in files named by number).
|
||||
fn starts_like_a_font(path: &std::path::Path) -> bool {
|
||||
use std::io::Read;
|
||||
let mut magic = [0u8; 4];
|
||||
std::fs::File::open(path).and_then(|mut f| f.read_exact(&mut magic)).is_ok() && matches!(&magic, &[0, 1, 0, 0] | b"OTTO" | b"true" | b"ttcf")
|
||||
}
|
||||
|
||||
/// The largest font file read (CJK collections run to ~120 MB, Apple Color Emoji ~180 MB).
|
||||
pub const MAX_FONT_FILE: u64 = 256 * 1024 * 1024;
|
||||
|
||||
|
||||
@@ -231,6 +231,35 @@ fn user_font_dirs_preserve_non_utf8_paths() {
|
||||
assert_eq!(dirs[1].as_os_str().as_bytes(), b"/data/\xfe/fonts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scan_reads_font_files_without_an_extension() {
|
||||
// #298: Adobe Fonts keeps the fonts it activates in hidden files named by number. Any installed
|
||||
// font stands in for one (none on CI); a file that isn't a font is left alone.
|
||||
let Some(font) = system_font_dirs().into_iter().find_map(|d| {
|
||||
std::fs::read_dir(d)
|
||||
.ok()?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.find(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("ttf") || x.eq_ignore_ascii_case("otf")))
|
||||
}) else {
|
||||
eprintln!("skipping: no installed font to stand in for an activated one");
|
||||
return;
|
||||
};
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-font-bare-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join(".r")).unwrap();
|
||||
std::fs::copy(&font, dir.join(".r/.12345")).unwrap();
|
||||
std::fs::write(dir.join(".r/entitlements"), b"<xml/>").unwrap();
|
||||
let mut found = BTreeMap::new();
|
||||
scan_dir(&dir, 0, &mut found);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let families: Vec<&String> = found.values().map(|(f, _)| f).collect();
|
||||
assert_eq!(found.values().map(|(_, v)| v.len()).sum::<usize>(), faces_in(&std::fs::read(&font).unwrap()).len(), "{families:?}");
|
||||
assert!(!families.is_empty(), "{} is read as a font", font.display());
|
||||
assert!(found.values().flat_map(|(_, v)| v).all(|p| p.path.ends_with(".r/.12345")));
|
||||
#[cfg(target_os = "macos")]
|
||||
assert!(system_font_dirs().iter().any(|d| d.ends_with("Adobe/CoreSync/plugins/livetype")), "Adobe Fonts' folder is scanned");
|
||||
}
|
||||
|
||||
/// A rescan offers new families and the styles installed since, and never a file twice.
|
||||
#[test]
|
||||
fn a_rescan_offers_fonts_installed_since() {
|
||||
@@ -276,3 +305,15 @@ fn font_bytes_load_into_the_db() {
|
||||
assert_eq!(db().read().unwrap().face_count(), faces);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_platform_has_a_thai_font_in_the_fallbacks() {
|
||||
// Thai typed into a Latin layer (new text starts in Inter) is drawn by the first fallback that
|
||||
// has it: Noto Sans Thai (craft-fonts, Linux), Leelawadee UI or Tahoma (Windows), Thonburi
|
||||
// (macOS). They come after the Latin faces, which go on drawing Latin text.
|
||||
let at = |family: &str| FALLBACK_FAMILIES.iter().position(|f| *f == family);
|
||||
let latin = at("Segoe UI").unwrap();
|
||||
for thai in ["Noto Sans Thai", "Leelawadee UI", "Tahoma", "Thonburi"] {
|
||||
assert!(at(thai).is_some_and(|i| i > latin), "{thai}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1263,6 +1263,7 @@ name = "English"
|
||||
"Server address (ws://host:47900/)" = "Server address (ws://host:47900/)"
|
||||
"Server address" = "Server address"
|
||||
"Server for online sharing" = "Server for online sharing"
|
||||
"Server key" = "Server key"
|
||||
"Set as default" = "Set as default"
|
||||
"Set corner smoothing to 60%" = "Set corner smoothing to 60%"
|
||||
"Set variable" = "Set variable"
|
||||
|
||||
@@ -1257,6 +1257,7 @@ status = "draft — needs native review"
|
||||
"Server address (ws://host:47900/)" = "Dirección del servidor (ws://host:47900/)"
|
||||
"Server address" = "Dirección del servidor"
|
||||
"Server for online sharing" = "Servidor para compartir en línea"
|
||||
"Server key" = "Clave del servidor"
|
||||
"Set as default" = "Establecer como predeterminado"
|
||||
"Set variable" = "Establecer variable"
|
||||
"Set variable mode" = "Establecer modo de variable"
|
||||
|
||||
@@ -154,7 +154,7 @@ status = "draft — needs native review"
|
||||
"Back to assets" = "Retour aux ressources"
|
||||
"Back to current" = "Revenir à l’actuelle"
|
||||
"Back to styles" = "Retour aux styles"
|
||||
"Back to the canvas from a focused region" = "Revenir au canevas depuis une région active"
|
||||
"Back to the canvas from a focused region" = "Revenir au canevas depuis une région qui a le focus"
|
||||
"Back to the current version" = "Revenir à la version actuelle"
|
||||
"Back to the values the font style implies" = "Revenir aux valeurs du style de police"
|
||||
"Background" = "Arrière-plan"
|
||||
@@ -191,7 +191,7 @@ status = "draft — needs native review"
|
||||
"Branch {name}" = "Branche {name}"
|
||||
"Branches" = "Branches"
|
||||
"Bring forward" = "Avancer"
|
||||
"Bring the main file's changes into the branch" = "Apporte dans la branche les modifications du fichier principal"
|
||||
"Bring the main file's changes into the branch" = "Apporter dans la branche les modifications du fichier principal"
|
||||
"Bring to front" = "Mettre au premier plan"
|
||||
"Bristle" = "Soies"
|
||||
"Browse" = "Parcourir"
|
||||
@@ -260,8 +260,8 @@ status = "draft — needs native review"
|
||||
"Collection" = "Collection"
|
||||
"Collections" = "Collections"
|
||||
"Color" = "Couleur"
|
||||
"Color burn" = "Brûlage de couleur"
|
||||
"Color dodge" = "Éclaircissement de couleur"
|
||||
"Color burn" = "Densité couleur +"
|
||||
"Color dodge" = "Densité couleur −"
|
||||
"Color format" = "Format de couleur"
|
||||
"Color profile" = "Profil colorimétrique"
|
||||
"Color styles" = "Styles de couleur"
|
||||
@@ -326,9 +326,9 @@ status = "draft — needs native review"
|
||||
"Corner radius" = "Rayon d’angle"
|
||||
"Corner smoothing" = "Lissage des angles"
|
||||
"Couldn't download the variables" = "Impossible de télécharger les variables"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient."
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient."
|
||||
"Couldn't read {name}." = "Impossible de lire {name}."
|
||||
"Couldn't save automatically: {error}" = "Impossible d’enregistrer automatiquement : {error}"
|
||||
"Couldn't save automatically: {error}" = "Impossible d’enregistrer automatiquement : {error}"
|
||||
"Count" = "Nombre"
|
||||
"Create" = "Créer"
|
||||
"Create a variable collection first" = "Créez d’abord une collection de variables"
|
||||
@@ -460,14 +460,14 @@ status = "draft — needs native review"
|
||||
"Empty" = "Vide"
|
||||
"End" = "Fin"
|
||||
"End point" = "Point final"
|
||||
"Ends the session; everyone keeps their copy" = "Met fin à la session ; chacun conserve sa copie"
|
||||
"Ends the session; everyone keeps their copy" = "Met fin à la session ; chacun conserve sa copie"
|
||||
"Enter" = "Entrée"
|
||||
"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Saisissez l’adresse d’un figcraft-server et appuyez sur Parcourir pour voir les plugins qu’il propose."
|
||||
"Enter to save · Esc to cancel" = "Entrée pour enregistrer · Échap pour annuler"
|
||||
"Eraser" = "Gomme"
|
||||
"Esc" = "Échap"
|
||||
"Essential" = "Essentiels"
|
||||
"Every conflict has a choice." = "Chaque conflit a un choix."
|
||||
"Every conflict has a choice." = "Un choix est fait pour chaque conflit."
|
||||
"Every top-level frame already starts a flow" = "Chaque cadre de premier niveau démarre déjà un flux"
|
||||
"Exact opacity (4 then 5: 45%)" = "Opacité exacte (4 puis 5 : 45 %)"
|
||||
"Exclude" = "Exclure"
|
||||
@@ -485,9 +485,9 @@ status = "draft — needs native review"
|
||||
"Export variables" = "Exporter les variables"
|
||||
"Export {name}" = "Exporter {name}"
|
||||
"Export {n} layers" = { one = "Exporter {n} calque", other = "Exporter {n} calques" }
|
||||
"Exported {file}" = "Exporté : {file}"
|
||||
"Exported {file}" = "{file} exporté"
|
||||
"Exported {name}" = "{name} exporté"
|
||||
"Exported {name}; {n} kinds of content were left out" = { one = "{name} exporté ; {n} type de contenu a été omis", other = "{name} exporté ; {n} types de contenu ont été omis" }
|
||||
"Exported {name}; {n} kinds of content were left out" = { one = "{name} exporté ; {n} type de contenu a été omis", other = "{name} exporté ; {n} types de contenu ont été omis" }
|
||||
"Exported {n} files" = { one = "{n} fichier exporté", other = "{n} fichiers exportés" }
|
||||
"Export…" = "Exporter…"
|
||||
"Expose properties to instances" = "Exposer les propriétés aux instances"
|
||||
@@ -507,7 +507,7 @@ status = "draft — needs native review"
|
||||
"File name" = "Nom du fichier"
|
||||
"File not found" = "Fichier introuvable"
|
||||
"File published" = "Fichier publié"
|
||||
"Files aren't end-to-end encrypted: the server's operator can read them." = "Les fichiers ne sont pas chiffrés de bout en bout : l’opérateur du serveur peut les lire."
|
||||
"Files aren't end-to-end encrypted: the server's operator can read them." = "Les fichiers ne sont pas chiffrés de bout en bout : l’opérateur du serveur peut les lire."
|
||||
"Files nobody opens for {n} days are deleted." = { one = "Les fichiers que personne n’ouvre pendant {n} jour sont supprimés.", other = "Les fichiers que personne n’ouvre pendant {n} jours sont supprimés." }
|
||||
"Fill" = "Remplissage"
|
||||
"Fill (fr)" = "Remplir (fr)"
|
||||
@@ -585,7 +585,7 @@ status = "draft — needs native review"
|
||||
"Groups" = "Groupes"
|
||||
"Gutter" = "Gouttière"
|
||||
"Hand tool" = "Outil Main"
|
||||
"Hanging list" = "Liste en retrait"
|
||||
"Hanging list" = "Liste à retrait suspendu"
|
||||
"Hanging punctuation" = "Ponctuation en débord"
|
||||
"Hard light" = "Lumière dure"
|
||||
"Heart" = "Cœur"
|
||||
@@ -593,7 +593,7 @@ status = "draft — needs native review"
|
||||
"Help" = "Aide"
|
||||
"Help and resources" = "Aide et ressources"
|
||||
"Help page" = "Page d’aide"
|
||||
"Here: {names}" = "Présents : {names}"
|
||||
"Here: {names}" = "Présents : {names}"
|
||||
"Hidden" = "Masqué"
|
||||
"Hide contents" = "Masquer le contenu"
|
||||
"Hide from publishing" = "Exclure de la publication"
|
||||
@@ -686,7 +686,7 @@ status = "draft — needs native review"
|
||||
"Joined" = "Connecté"
|
||||
"Joined {file}" = "Connecté à {file}"
|
||||
"Joining…" = "Connexion…"
|
||||
"Join|stroke" = "Jointure"
|
||||
"Join|stroke" = "Jonction"
|
||||
"Jul" = "juil."
|
||||
"Jun" = "juin"
|
||||
"Just now" = "À l’instant"
|
||||
@@ -712,7 +712,7 @@ status = "draft — needs native review"
|
||||
"Layer name" = "Nom du calque"
|
||||
"Layers" = "Calques"
|
||||
"Layers in an instance take their position from the main component" = "Les calques d’une instance prennent leur position du composant principal"
|
||||
"Layout" = "Disposition"
|
||||
"Layout" = "Mise en page"
|
||||
"Layout grid" = "Grille de mise en page"
|
||||
"Layout grid settings" = "Réglages de la grille de mise en page"
|
||||
"Layout grids" = "Grilles de mise en page"
|
||||
@@ -782,9 +782,9 @@ status = "draft — needs native review"
|
||||
"May" = "mai"
|
||||
"Measure to the selection" = "Mesurer jusqu’à la sélection"
|
||||
"Measurements" = "Mesures"
|
||||
"Merge into main" = "Fusionner dans le principal"
|
||||
"Merge into main" = "Fusionner dans le fichier principal"
|
||||
"Merge regions" = "Fusionner les régions"
|
||||
"Merge the branch into the main file (one undo step) and archive it" = "Fusionne la branche dans le fichier principal (une seule étape d’annulation) et l’archive"
|
||||
"Merge the branch into the main file (one undo step) and archive it" = "Fusionner la branche dans le fichier principal (une seule étape d’annulation) et l’archiver"
|
||||
"Merged" = "Fusionnée"
|
||||
"Merged PRs" = "PR fusionnées"
|
||||
"Min H" = "H min."
|
||||
@@ -805,7 +805,7 @@ status = "draft — needs native review"
|
||||
"Mobile app" = "Application mobile"
|
||||
"Mode" = "Mode"
|
||||
"Model" = "Modèle"
|
||||
"Models" = "Modèles"
|
||||
"Models" = "Modèles d’IA"
|
||||
"Mono" = "Mono"
|
||||
"More" = "Plus"
|
||||
"More instance actions" = "Autres actions d’instance"
|
||||
@@ -818,8 +818,8 @@ status = "draft — needs native review"
|
||||
"Move column left" = "Déplacer la colonne à gauche"
|
||||
"Move column right" = "Déplacer la colonne à droite"
|
||||
"Move down" = "Descendre"
|
||||
"Move in" = "Entrée"
|
||||
"Move out" = "Sortie"
|
||||
"Move in" = "Déplacer vers l’intérieur"
|
||||
"Move out" = "Déplacer vers l’extérieur"
|
||||
"Move tools" = "Outils de déplacement"
|
||||
"Move up" = "Monter"
|
||||
"Moved to {name}" = "Déplacé vers {name}"
|
||||
@@ -835,7 +835,7 @@ status = "draft — needs native review"
|
||||
"New design file" = "Nouveau fichier de design"
|
||||
"Newest first" = "Plus récent d’abord"
|
||||
"Next frame" = "Cadre suivant"
|
||||
"Next or previous control in the focused region" = "Contrôle suivant ou précédent dans la région active"
|
||||
"Next or previous control in the focused region" = "Contrôle suivant ou précédent dans la région qui a le focus"
|
||||
"Next ready for dev" = "Prêt pour le dev suivant"
|
||||
"Next size option" = "Option de taille suivante"
|
||||
"No assets: no export settings, icons or images. Add export settings in the Design tab." = "Aucune ressource : ni réglage d’exportation, ni icône, ni image. Ajoutez des réglages d’exportation dans l’onglet Design."
|
||||
@@ -854,7 +854,7 @@ status = "draft — needs native review"
|
||||
"No contributor data was built into this copy." = "Cette copie ne contient aucune donnée de contributeurs."
|
||||
"No device" = "Aucun appareil"
|
||||
"No fonts match" = "Aucune police ne correspond"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Pas de code d’accès ici (le serveur est antérieur aux codes d’accès, ou cette adresse ne tient pas dans un code) : partagez plutôt le lien."
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Pas de code d’accès ici (le serveur est antérieur aux codes d’accès, ou cette adresse ne tient pas dans un code) : partagez plutôt le lien."
|
||||
"No layer changes to show." = "Aucune modification de calque à afficher."
|
||||
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Aucune bibliothèque pour l’instant. Ajoutez un fichier .figcraft publié en tant que bibliothèque, ou collez le lien d’une bibliothèque d’équipe."
|
||||
"No list" = "Aucune liste"
|
||||
@@ -868,7 +868,7 @@ status = "draft — needs native review"
|
||||
"No number variables fit here." = "Aucune variable numérique ne convient ici."
|
||||
"No number variables yet. Create them in Local variables." = "Aucune variable numérique pour l’instant. Créez-en dans Variables locales."
|
||||
"No other pages" = "Aucune autre page"
|
||||
"No paints" = "Aucune peinture"
|
||||
"No paints" = "Aucun remplissage"
|
||||
"No plugins on {server}." = "Aucun plugin sur {server}."
|
||||
"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Aucun plugin pour l’instant. Importez un plugin en développement depuis son figcraft-plugin.json."
|
||||
"No preferred components" = "Aucun composant préféré"
|
||||
@@ -912,7 +912,7 @@ status = "draft — needs native review"
|
||||
"Numbered list" = "Liste numérotée"
|
||||
"Object" = "Objet"
|
||||
"Oct" = "oct."
|
||||
"Off" = "Non"
|
||||
"Off" = "Désactivé"
|
||||
"Offered first when filling the slot (any content is allowed)" = "Proposés en premier pour remplir l’emplacement (tout contenu est autorisé)"
|
||||
"Offered first when swapping the instance (any component is allowed)" = "Proposés en premier pour permuter l’instance (tout composant est autorisé)"
|
||||
"Offset" = "Décalage"
|
||||
@@ -923,7 +923,7 @@ status = "draft — needs native review"
|
||||
"One announcement in five common sizes: square, portrait, story, link and banner" = "Une annonce en cinq formats courants : carré, portrait, story, lien et bannière"
|
||||
"Online" = "En ligne"
|
||||
"Online files" = "Fichiers en ligne"
|
||||
"Online sharing is off: files are shared on the local network only." = "Le partage en ligne est désactivé : les fichiers ne sont partagés que sur le réseau local."
|
||||
"Online sharing is off: files are shared on the local network only." = "Le partage en ligne est désactivé : les fichiers ne sont partagés que sur le réseau local."
|
||||
"Only the first changed layers are listed." = "Seuls les premiers calques modifiés sont listés."
|
||||
"Only the first {n} changed layers are listed." = "Seuls les {n} premiers calques modifiés sont listés."
|
||||
"Only your threads" = "Vos fils uniquement"
|
||||
@@ -954,7 +954,7 @@ status = "draft — needs native review"
|
||||
"Order" = "Ordre"
|
||||
"Other" = "Autre"
|
||||
"Other apps may not open every .fig file exported here." = "D’autres applications risquent de ne pas ouvrir tous les fichiers .fig exportés ici."
|
||||
"Others lose access and keep their copy; the file stays on the server for you" = "Les autres perdent l’accès et conservent leur copie ; le fichier reste sur le serveur pour vous"
|
||||
"Others lose access and keep their copy; the file stays on the server for you" = "Les autres perdent l’accès et conservent leur copie ; le fichier reste sur le serveur pour vous"
|
||||
"Others type this under Share ▸ Join with a code" = "Les autres le saisissent dans Partager ▸ Rejoindre avec un code"
|
||||
"Outline stroke" = "Convertir le contour en tracé"
|
||||
"Outline text" = "Convertir le texte en tracés"
|
||||
@@ -999,7 +999,7 @@ status = "draft — needs native review"
|
||||
"Pen" = "Plume"
|
||||
"Pencil" = "Crayon"
|
||||
"People in this file" = "Personnes dans ce fichier"
|
||||
"People join from anywhere with a code. The file is kept on {server} while you share it." = "On vous rejoint de partout avec un code. Le fichier est conservé sur {server} tant que vous le partagez."
|
||||
"People join from anywhere with a code. The file is kept on {server} while you share it." = "Les personnes vous rejoignent de partout avec un code. Le fichier est conservé sur {server} tant que vous le partagez."
|
||||
"People on your network join with a code. Nothing goes through a server." = "Les personnes de votre réseau vous rejoignent avec un code. Rien ne passe par un serveur."
|
||||
"Phone" = "Téléphone"
|
||||
"Pick color" = "Prélever une couleur"
|
||||
@@ -1075,7 +1075,7 @@ status = "draft — needs native review"
|
||||
"Ratio" = "Rapport"
|
||||
"Reading the library…" = "Lecture de la bibliothèque…"
|
||||
"Ready for dev" = "Prêt pour le dev"
|
||||
"Real name" = "Vrai nom"
|
||||
"Real name" = "Nom réel"
|
||||
"Recent" = "Récents"
|
||||
"Recent files" = "Fichiers récents"
|
||||
"Recents" = "Récents"
|
||||
@@ -1110,9 +1110,9 @@ status = "draft — needs native review"
|
||||
"Remove text from path" = "Retirer le texte du tracé"
|
||||
"Remove the else and its actions" = "Retirer le « sinon » et ses actions"
|
||||
"Remove this condition and its actions" = "Retirer cette condition et ses actions"
|
||||
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Retirer ce fichier du serveur ? Il ne pourra plus être partagé ni ouvert depuis celui-ci. Votre copie reste ouverte ici."
|
||||
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Retirer ce fichier du serveur ? Il ne pourra plus être partagé ni ouvert depuis ce serveur. Votre copie reste ouverte ici."
|
||||
"Remove {name} from session" = "Retirer {name} de la session"
|
||||
"Remove {name} from {server}?" = "Retirer {name} de {server} ?"
|
||||
"Remove {name} from {server}?" = "Retirer {name} de {server} ?"
|
||||
"Removed" = "Supprimé"
|
||||
"Removed from the server" = "Retiré du serveur"
|
||||
"Removed since it was marked" = "Supprimé depuis le marquage"
|
||||
@@ -1144,7 +1144,7 @@ status = "draft — needs native review"
|
||||
"Reset to style" = "Revenir au style"
|
||||
"Reset to the inherited value" = "Revenir à la valeur héritée"
|
||||
"Resize by the nudge amount" = "Redimensionner du pas de décalage"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Redimensionne les cadres à l’écran de l’appareil (ou à la fenêtre sans appareil) : leurs contraintes et leur mise en page auto s’adaptent"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Redimensionner les cadres à l’écran de l’appareil (ou à la fenêtre sans appareil) : leurs contraintes et leur mise en page auto s’adaptent"
|
||||
"Resize to fit" = "Redimensionner au contenu"
|
||||
"Resizing" = "Redimensionnement"
|
||||
"Resolve" = "Résoudre"
|
||||
@@ -1218,7 +1218,7 @@ status = "draft — needs native review"
|
||||
"Select a frame to preview it" = "Sélectionnez un cadre pour le prévisualiser"
|
||||
"Select a layer to see its properties and code" = "Sélectionnez un calque pour voir ses propriétés et son code"
|
||||
"Select all" = "Tout sélectionner"
|
||||
"Select all with same" = "Sélectionner tout ce qui a le même"
|
||||
"Select all with same" = "Sélectionner par propriété commune"
|
||||
"Select layer" = "Sélectionner le calque"
|
||||
"Select layers and they space themselves as their content changes" = "Sélectionnez des calques et leur espacement s’ajuste quand leur contenu change"
|
||||
"Select layers to apply the style to" = "Sélectionnez les calques auxquels appliquer le style"
|
||||
@@ -1252,12 +1252,12 @@ status = "draft — needs native review"
|
||||
"Share with a code" = "Partager avec un code"
|
||||
"Share: live session, export" = "Partager : session en direct, exportation"
|
||||
"Shared" = "Partagé"
|
||||
"Shared: copy its code from the file's menu" = "Partagé : copiez son code depuis le menu du fichier"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Le partage d’un fichier nécessite l’application de bureau ; ici, vous pouvez en rejoindre un."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Partage sur le réseau local (le partage en ligne est désactivé : aucun serveur n’est défini dans les Préférences)."
|
||||
"Shared: copy its code from the file's menu" = "Partagé : copiez son code depuis le menu du fichier"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Le partage d’un fichier nécessite l’application de bureau ; ici, vous pouvez en rejoindre un."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Partage sur le réseau local (le partage en ligne est désactivé : aucun serveur n’est défini dans les Préférences)."
|
||||
"Sharing on your network · {people}" = "Partage sur votre réseau · {people}"
|
||||
"Sharing online · {people}" = "Partage en ligne · {people}"
|
||||
"Sharing stopped: the file stays on the server, open only to you" = "Partage arrêté : le fichier reste sur le serveur, accessible à vous seul"
|
||||
"Sharing stopped: the file stays on the server, open only to you" = "Partage arrêté : le fichier reste sur le serveur, accessible à vous seul"
|
||||
"Show" = "Afficher"
|
||||
"Show all templates" = "Afficher tous les modèles"
|
||||
"Show as grid" = "Afficher en grille"
|
||||
@@ -1315,7 +1315,7 @@ status = "draft — needs native review"
|
||||
"Spam, stolen work, abuse…" = "Spam, travail volé, abus…"
|
||||
"Speech bubble" = "Bulle"
|
||||
"Splay" = "Évasement"
|
||||
"Spotlight me" = "Me mettre sous les projecteurs"
|
||||
"Spotlight me" = "Faire suivre ma vue"
|
||||
"Spread" = "Étendue"
|
||||
"Square" = "Carré"
|
||||
"Stamp" = "Tampon"
|
||||
@@ -1343,7 +1343,7 @@ status = "draft — needs native review"
|
||||
"Stop following {name}" = "Ne plus suivre {name}"
|
||||
"Stop session" = "Arrêter la session"
|
||||
"Stop sharing" = "Arrêter le partage"
|
||||
"Stop spotlighting" = "Quitter les projecteurs"
|
||||
"Stop spotlighting" = "Arrêter de faire suivre ma vue"
|
||||
"Stop {n} {hex}" = "Arrêt {n} {hex}"
|
||||
"Straight" = "Droit"
|
||||
"Stretch" = "Étirer"
|
||||
@@ -1399,7 +1399,7 @@ status = "draft — needs native review"
|
||||
"The destination document is unavailable. Nothing was imported." = "Le document de destination est indisponible. Rien n’a été importé."
|
||||
"The destination document was closed. Nothing was imported." = "Le document de destination a été fermé. Rien n’a été importé."
|
||||
"The destination page was removed. Nothing was imported." = "La page de destination a été supprimée. Rien n’a été importé."
|
||||
"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Le fichier est supprimé du serveur : plus personne ne pourra l’ouvrir ni le rejoindre depuis celui-ci, et c’est irréversible. Les copies ouvertes restent à ceux qui les ont."
|
||||
"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Le fichier est supprimé du serveur : plus personne ne pourra l’ouvrir ni le rejoindre depuis ce serveur, et c’est irréversible. Les copies ouvertes restent à ceux qui les ont."
|
||||
"The file now holds another library" = "Le fichier contient désormais une autre bibliothèque"
|
||||
"The file now holds another library (\"{name}\")" = "Le fichier contient désormais une autre bibliothèque (« {name} »)"
|
||||
"The files you shared or published online from this computer. They stay on their server until you remove them." = "Les fichiers que vous avez partagés ou publiés en ligne depuis cet ordinateur. Ils restent sur leur serveur jusqu’à ce que vous les retiriez."
|
||||
@@ -1415,16 +1415,16 @@ status = "draft — needs native review"
|
||||
"This branch can't be reviewed: {e}" = "Impossible de relire cette branche : {e}"
|
||||
"This branch was merged or archived." = "Cette branche a été fusionnée ou archivée."
|
||||
"This computer only" = "Cet ordinateur uniquement"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Cet ordinateur mémorise le jeton de propriétaire : le fichier se gère depuis la liste En ligne de l’écran d’accueil. Copiez-le pour gérer le fichier depuis un autre endroit : c’est un mot de passe pour ce fichier et il ne sera plus affiché."
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Cet ordinateur mémorise le jeton de propriétaire : le fichier se gère depuis la liste En ligne de l’écran d’accueil. Copiez-le pour gérer le fichier depuis un autre endroit : c’est un mot de passe pour ce fichier et il ne sera plus affiché."
|
||||
"This condition is edited as an expression" = "Cette condition se modifie en tant qu’expression"
|
||||
"This file" = "Ce fichier"
|
||||
"This file has unsaved changes" = "Ce fichier contient des modifications non enregistrées"
|
||||
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Ce fichier est sur {server}, accessible à vous seul. Partagez-le pour laisser entrer d’autres personnes avec un nouveau code."
|
||||
"This frame is new on the branch." = "Ce cadre est nouveau dans la branche."
|
||||
"This frame isn't on the branch." = "Ce cadre n’est pas dans la branche."
|
||||
"This server is open to the public: no key needed." = "Ce serveur est ouvert au public : aucune clé nécessaire."
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Ce serveur exige sa clé : ajoutez ?key=… à l’adresse (`figcraft-server key` l’affiche sur la machine du serveur)."
|
||||
"This server takes files only with its key: see Advanced." = "Ce serveur n’accepte les fichiers qu’avec sa clé : voir Avancé."
|
||||
"This server is open to the public: no key needed." = "Ce serveur est ouvert au public : aucune clé nécessaire."
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Ce serveur exige sa clé : ajoutez ?key=… à l’adresse (`figcraft-server key` l’affiche sur la machine du serveur)."
|
||||
"This server takes files only with its key: see Advanced." = "Ce serveur n’accepte les fichiers qu’avec sa clé : voir Avancé."
|
||||
"Thumbs up" = "Pouce levé"
|
||||
"This version can't be read." = "Cette version est illisible."
|
||||
"This version doesn't have it: all of it is new." = "Cette version ne le contient pas : tout est nouveau."
|
||||
@@ -1475,7 +1475,7 @@ status = "draft — needs native review"
|
||||
"Unsaved changes: save first, or close again with {key} to discard" = "Modifications non enregistrées : enregistrez d’abord ou fermez à nouveau avec {key} pour les abandonner"
|
||||
"Up" = "Haut"
|
||||
"Update" = "Mettre à jour"
|
||||
"Update from main" = "Mettre à jour depuis le principal"
|
||||
"Update from main" = "Mettre à jour depuis le fichier principal"
|
||||
"Update from selection" = "Mettre à jour depuis la sélection"
|
||||
"Update plugin" = "Mettre à jour le plugin"
|
||||
"Updated {components} and {styles}" = "{components} et {styles} mis à jour"
|
||||
@@ -1538,7 +1538,7 @@ status = "draft — needs native review"
|
||||
"Weight" = "Graisse"
|
||||
"What changed since a saved version" = "Ce qui a changé depuis une version enregistrée"
|
||||
"What changed since it was marked ready for dev" = "Ce qui a changé depuis le marquage prêt pour le dev"
|
||||
"What is wrong with this file? The server's operator gets your report." = "Qu’est-ce qui ne va pas avec ce fichier ? L’opérateur du serveur reçoit votre signalement."
|
||||
"What is wrong with this file? The server's operator gets your report." = "Qu’est-ce qui ne va pas avec ce fichier ? L’opérateur du serveur reçoit votre signalement."
|
||||
"What it's for" = "À quoi il sert"
|
||||
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "L’état d’origine de cette modification est inconnu : elle date d’avant que FigCraft ne conserve une copie des cadres prêts pour le dev. Résolvez la modification (ou marquez-la à nouveau comme prête) pour comparer à partir de maintenant."
|
||||
"When marked ready" = "Au marquage prêt"
|
||||
@@ -1561,9 +1561,9 @@ status = "draft — needs native review"
|
||||
"Yesterday" = "Hier"
|
||||
"You" = "Vous"
|
||||
"You'll get the shared file as a new tab and edit it together." = "Vous recevrez le fichier partagé dans un nouvel onglet et le modifierez ensemble."
|
||||
"You're spotlighting: everyone follows your view" = "Vous êtes sous les projecteurs : tout le monde suit votre vue"
|
||||
"You're spotlighting: everyone follows your view" = "Vous faites suivre votre vue : tout le monde vous suit"
|
||||
"Your copy of the file stays open" = "Votre copie du fichier reste ouverte"
|
||||
"Your copy stays open; the file stays on the server" = "Votre copie reste ouverte ; le fichier reste sur le serveur"
|
||||
"Your copy stays open; the file stays on the server" = "Votre copie reste ouverte ; le fichier reste sur le serveur"
|
||||
"Your name" = "Votre nom"
|
||||
"Your name in comments" = "Votre nom dans les commentaires"
|
||||
"Your system asks for reduced motion: animations stay off." = "Votre système demande de réduire les animations : elles restent désactivées."
|
||||
@@ -1713,12 +1713,12 @@ status = "draft — needs native review"
|
||||
"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}) : {min} – {max}, par défaut {default}. Double-cliquez sur le curseur pour revenir à la valeur du style ; Échap pendant le glissement annule."
|
||||
"{name} cleared from the list" = "{name} effacé de la liste"
|
||||
"{name} from {path}" = "{name} depuis {path}"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} est oublié sur cet ordinateur ; le fichier reste sur {server}"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} est oublié sur cet ordinateur ; le fichier reste sur {server}"
|
||||
"{name} is on the server." = "{name} est sur le serveur."
|
||||
"{name} is spotlighting" = "{name} est sous les projecteurs"
|
||||
"{name} is spotlighting" = "{name} fait suivre sa vue"
|
||||
"{name} is too large to open in the browser (over 512 MB)." = "{name} est trop volumineux pour être ouvert dans le navigateur (plus de 512 Mo)."
|
||||
"{name} was removed from the server" = "{name} a été retiré du serveur"
|
||||
"{name} · deleted on main" = "{name} · supprimé dans le principal"
|
||||
"{name} · deleted on main" = "{name} · supprimé dans le fichier principal"
|
||||
"{name} · deleted on the branch" = "{name} · supprimé dans la branche"
|
||||
"{name} · not published" = "{name} · non publié"
|
||||
"{name} · on a server" = "{name} · sur un serveur"
|
||||
@@ -1828,7 +1828,7 @@ status = "draft — needs native review"
|
||||
"branch.merge" = "Fusionner la branche"
|
||||
"branch.open" = "Ouvrir la branche"
|
||||
"branch.review" = "Relire la branche"
|
||||
"branch.updateFromMain" = "Mettre à jour depuis le principal"
|
||||
"branch.updateFromMain" = "Mettre à jour depuis le fichier principal"
|
||||
"codeConnect.export" = "Exporter les liaisons au code"
|
||||
"codeConnect.import" = "Importer les liaisons au code"
|
||||
"codeConnect.remove" = "Délier du code"
|
||||
@@ -1852,7 +1852,7 @@ status = "draft — needs native review"
|
||||
"collab.report" = "Signaler le fichier à l’opérateur du serveur"
|
||||
"collab.serverInfo" = "Informations sur le serveur"
|
||||
"collab.share" = "Partager avec un code d’accès"
|
||||
"collab.spotlight" = "Me mettre sous les projecteurs"
|
||||
"collab.spotlight" = "Faire suivre ma vue"
|
||||
"collab.stop" = "Arrêter la session en direct"
|
||||
"command.batch" = "Exécuter plusieurs commandes"
|
||||
"comment.add" = "Ajouter un commentaire"
|
||||
@@ -2120,10 +2120,10 @@ status = "draft — needs native review"
|
||||
"select.inverse" = "Inverser la sélection"
|
||||
"select.layersAt" = "Calques en un point"
|
||||
"select.matching" = "Sélectionner les calques correspondants"
|
||||
"select.next" = "Sélectionner l’élément suivant"
|
||||
"select.next" = "Sélectionner l’élément suivant du même niveau"
|
||||
"select.none" = "Tout désélectionner"
|
||||
"select.parent" = "Sélectionner le parent"
|
||||
"select.previous" = "Sélectionner l’élément précédent"
|
||||
"select.previous" = "Sélectionner l’élément précédent du même niveau"
|
||||
"select.remove" = "Retirer de la sélection"
|
||||
"select.sameEffect" = "Sélectionner le même effet"
|
||||
"select.sameFill" = "Sélectionner le même remplissage"
|
||||
@@ -2212,7 +2212,7 @@ status = "draft — needs native review"
|
||||
"text.sizeDown" = "Réduire la taille de police"
|
||||
"text.sizeUp" = "Augmenter la taille de police"
|
||||
"text.strikethrough" = "Barré"
|
||||
"text.style" = "Styler une plage de texte"
|
||||
"text.style" = "Appliquer un style à une plage de texte"
|
||||
"text.trackingDown" = "Réduire l’interlettrage"
|
||||
"text.trackingUp" = "Augmenter l’interlettrage"
|
||||
"text.underline" = "Souligné"
|
||||
@@ -2302,7 +2302,7 @@ status = "draft — needs native review"
|
||||
"vector.edit.smooth" = "Lisser ou rendre anguleux les points"
|
||||
"vector.edit.state" = "État de la modification vectorielle"
|
||||
"vector.edit.strokeCap" = "Extrémité du contour au point"
|
||||
"vector.edit.strokeJoin" = "Jointure du contour au point"
|
||||
"vector.edit.strokeJoin" = "Jonction du contour au point"
|
||||
"vector.edit.tool" = "Outil de modification vectorielle"
|
||||
"vector.edit.transform" = "Transformer les points"
|
||||
"vector.edit.widthPoint" = "Point de largeur"
|
||||
@@ -2338,7 +2338,7 @@ status = "draft — needs native review"
|
||||
"view.multiplayerCursors" = "Curseurs des autres participants"
|
||||
"view.nextFrame" = "Zoomer sur le cadre suivant"
|
||||
"view.nextPage" = "Page suivante"
|
||||
"view.nextRegion" = "Activer la région suivante"
|
||||
"view.nextRegion" = "Placer le focus sur la région suivante"
|
||||
"view.outlines" = "Contours"
|
||||
"view.outlinesHiddenLayers" = "Inclure les calques masqués dans les contours"
|
||||
"view.outlinesObjectBounds" = "Inclure les limites des objets dans les contours"
|
||||
@@ -2349,7 +2349,7 @@ status = "draft — needs native review"
|
||||
"view.pixelPreview" = "Aperçu des pixels"
|
||||
"view.prevFrame" = "Zoomer sur le cadre précédent"
|
||||
"view.prevPage" = "Page précédente"
|
||||
"view.previousRegion" = "Activer la région précédente"
|
||||
"view.previousRegion" = "Placer le focus sur la région précédente"
|
||||
"view.proofSrgb" = "Épreuve écran sRGB"
|
||||
"view.fullscreen" = "Plein écran"
|
||||
"view.prototype" = "Panneau Prototype"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+131
-93
@@ -14,10 +14,10 @@ name = "Svenska"
|
||||
"+ Create variable" = "+ Skapa variabel"
|
||||
".fig (experimental)" = ".fig (experimentell)"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR:er, {commits} commits, +{added} / −{deleted} rader (Δ {delta}), +{bin_added} / −{bin_deleted} binära tillgångar, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "En samling i den här filen med dessa lägen och variabler: åsidosätt deras värden per läge och lägg till egna variabler"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR:er, {commits} commits, +{added} / −{deleted} rader (Δ {delta}), +{bin_added} / −{bin_deleted} binära resurser, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "En samling i den här filen med dessa lägen och variabler: ange overrides för deras värden per läge och lägg till egna variabler"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Det finns redan en .figcraft-fil med det här namnet. Välj dess fullständiga namn för att bekräfta att den ersätts."
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Ett ensamt lager justeras mot sin ram: markera fler lager, eller ett lager i en ram"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Ett ensamt lager justeras mot sin frame: markera fler lager, eller ett lager i en frame"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "En responsiv produktsida: toppsektion, funktioner, figurer, ett citat och en sidfot"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "En fjäder spelas tills den stannar: dess massa, styvhet och dämpning avgör hur länge"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "En titel, en agenda, en stor figur, ett diagram och en avslutande bild"
|
||||
@@ -34,14 +34,14 @@ name = "Svenska"
|
||||
"Add a category" = "Lägg till en kategori"
|
||||
"Add a comment" = "Lägg till en kommentar"
|
||||
"Add a description" = "Lägg till en beskrivning"
|
||||
"Add a frame (F) or a shape (R) to get started" = "Lägg till en ram (F) eller en form (R) för att komma igång"
|
||||
"Add a frame (F) or a shape (R) to get started" = "Lägg till en frame (F) eller en form (R) för att komma igång"
|
||||
"Add a layer or component to tile first" = "Lägg först till ett lager eller en komponent att lägga sida vid sida"
|
||||
"Add a note for developers" = "Lägg till en anteckning för utvecklare"
|
||||
"Add action" = "Lägg till åtgärd"
|
||||
"Add actions for when no condition holds" = "Lägg till åtgärder för när inget villkor uppfylls"
|
||||
"Add an annotation for developers" = "Lägg till en annotering för utvecklare"
|
||||
"Add another condition" = "Lägg till ett villkor till"
|
||||
"Add auto layout" = "Lägg till automatisk layout"
|
||||
"Add auto layout" = "Lägg till auto layout"
|
||||
"Add background behind overlay" = "Lägg till bakgrund bakom överlägg"
|
||||
"Add content to this slot" = "Lägg till innehåll i facket"
|
||||
"Add dev resource" = "Lägg till utvecklarresurs"
|
||||
@@ -92,6 +92,7 @@ name = "Svenska"
|
||||
"All components" = "Alla komponenter"
|
||||
"All fonts" = "Alla typsnitt"
|
||||
"All supported properties" = "Alla egenskaper som stöds"
|
||||
"All the fonts this file uses are installed" = "Alla typsnitt som filen använder är installerade"
|
||||
"All variables" = "Alla variabler"
|
||||
"Along the bottom" = "Längs nederkanten"
|
||||
"Along the left" = "Längs vänsterkanten"
|
||||
@@ -132,7 +133,7 @@ name = "Svenska"
|
||||
"Arrow at the end" = "Pil i slutet"
|
||||
"As typed" = "Som inskrivet"
|
||||
"Asking the server…" = "Frågar servern…"
|
||||
"Assets" = "Tillgångar"
|
||||
"Assets" = "Resurser"
|
||||
"Aug" = "aug"
|
||||
"Auto" = "Auto"
|
||||
"Auto (space around)" = "Auto (utrymme runt om)"
|
||||
@@ -142,15 +143,15 @@ name = "Svenska"
|
||||
"Auto columns" = "Automatiska kolumner"
|
||||
"Auto gap" = "Automatiskt mellanrum"
|
||||
"Auto height" = "Automatisk höjd"
|
||||
"Auto layout" = "Automatisk layout"
|
||||
"Auto layout" = "Auto layout"
|
||||
"Auto rows" = "Automatiska rader"
|
||||
"Auto width" = "Automatisk bredd"
|
||||
"Back to the FigCraft server" = "Tillbaka till FigCraft-servern"
|
||||
"Back to the font's value" = "Tillbaka till teckensnittets värde"
|
||||
"Back to the font's value" = "Tillbaka till typsnittets värde"
|
||||
"Back" = "Tillbaka"
|
||||
"Back to Ready for dev" = "Tillbaka till Redo för utveckling"
|
||||
"Back to Ready for dev: the next change compares with how it is now" = "Tillbaka till Redo för utveckling: nästa ändring jämförs med hur det ser ut nu"
|
||||
"Back to assets" = "Tillbaka till tillgångar"
|
||||
"Back to assets" = "Tillbaka till resurser"
|
||||
"Back to current" = "Tillbaka till aktuell"
|
||||
"Back to styles" = "Tillbaka till formatmallar"
|
||||
"Back to the canvas from a focused region" = "Tillbaka till arbetsytan från en fokuserad region"
|
||||
@@ -178,6 +179,7 @@ name = "Svenska"
|
||||
"Both directions" = "Båda riktningarna"
|
||||
"Bottom" = "Nederkant"
|
||||
"Bottom left radius" = "Radie nere till vänster"
|
||||
"Bottom padding" = "Nedre padding"
|
||||
"Bottom right radius" = "Radie nere till höger"
|
||||
"Branch" = "Gren"
|
||||
"Branch archived" = "Grenen har arkiverats"
|
||||
@@ -205,6 +207,7 @@ name = "Svenska"
|
||||
"CMYK colour" = "CMYK-färg"
|
||||
"Can view" = "Kan visa"
|
||||
"Check" = "Bock"
|
||||
"Checking the fonts installed on this computer…" = "Kontrollerar vilka typsnitt som är installerade på den här datorn…"
|
||||
"Checking the server…" = "Kontrollerar servern…"
|
||||
"Chevron" = "Vinkel"
|
||||
"Clear from this list" = "Ta bort från listan"
|
||||
@@ -296,7 +299,7 @@ name = "Svenska"
|
||||
"Connection lost, reconnecting (attempt {attempt})…" = "Anslutningen bröts, återansluter (försök {attempt})…"
|
||||
"Connector" = "Koppling"
|
||||
"Constrain proportions" = "Behåll proportioner"
|
||||
"Constraints" = "Begränsningar"
|
||||
"Constraints" = "Constraints"
|
||||
"Content" = "Innehåll"
|
||||
"Contents hidden" = "Innehållet är dolt"
|
||||
"Contextual alternates" = "Kontextuella alternativ"
|
||||
@@ -304,6 +307,8 @@ name = "Svenska"
|
||||
"Contributors" = "Bidragsgivare"
|
||||
"Contributors: {people} · Commits: {commits}" = "Bidragsgivare: {people} · Commits: {commits}"
|
||||
"Controller" = "Handkontroll"
|
||||
"Convert to frame" = "Konvertera till frame"
|
||||
"Convert to section" = "Konvertera till avsnitt"
|
||||
"Cool" = "Kall"
|
||||
"Copied as CSS" = "Kopierat som CSS"
|
||||
"Copied as PNG" = "Kopierat som PNG"
|
||||
@@ -420,12 +425,12 @@ name = "Svenska"
|
||||
"Drafts" = "Utkast"
|
||||
"Drag the stroke to add a width point" = "Dra i konturen för att lägga till en breddpunkt"
|
||||
"Draw" = "Rita"
|
||||
"Draw a frame" = "Rita en ram"
|
||||
"Draw a frame" = "Rita en frame"
|
||||
"Drawing tools" = "Ritverktyg"
|
||||
"Drop a .figcraft, .fig, .json, .svg or image file." = "Släpp en .figcraft-, .fig-, .json- eller .svg-fil eller en bildfil."
|
||||
"Drop to import" = "Släpp för att importera"
|
||||
"Drop to open" = "Släpp för att öppna"
|
||||
"Drop shadow" = "Släppskugga"
|
||||
"Drop shadow" = "Slagskugga"
|
||||
"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Dubbellicensierad under MIT OR Apache-2.0. Skapad av ArtCraft-teamet och gemenskapen."
|
||||
"Duo" = "Duo"
|
||||
"Duplicate" = "Duplicera"
|
||||
@@ -457,13 +462,13 @@ name = "Svenska"
|
||||
"End point" = "Slutpunkt"
|
||||
"Ends the session; everyone keeps their copy" = "Avslutar sessionen; alla behåller sin kopia"
|
||||
"Enter" = "Enter"
|
||||
"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Ange adressen till en figcraft-server och tryck på Bläddra för att se vilka plugin-program den erbjuder."
|
||||
"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Ange adressen till en figcraft-server och tryck på Bläddra för att se vilka plugins den erbjuder."
|
||||
"Enter to save · Esc to cancel" = "Enter för att spara · Esc för att avbryta"
|
||||
"Eraser" = "Suddgummi"
|
||||
"Esc" = "Esc"
|
||||
"Essential" = "Grundläggande"
|
||||
"Every conflict has a choice." = "Alla konflikter har ett val."
|
||||
"Every top-level frame already starts a flow" = "Varje ram på översta nivån startar redan ett flöde"
|
||||
"Every top-level frame already starts a flow" = "Varje frame på översta nivån startar redan ett flöde"
|
||||
"Exact opacity (4 then 5: 45%)" = "Exakt opacitet (4 och sedan 5: 45 %)"
|
||||
"Exclude" = "Uteslut"
|
||||
"Excluded" = "Utesluten"
|
||||
@@ -474,7 +479,7 @@ name = "Svenska"
|
||||
"Expand" = "Expandera"
|
||||
"Export" = "Exportera"
|
||||
"Export all" = "Exportera alla"
|
||||
"Export frames and layers as PNG, JPG, SVG or PDF" = "Exportera ramar och lager som PNG, JPG, SVG eller PDF"
|
||||
"Export frames and layers as PNG, JPG, SVG or PDF" = "Exportera frames och lager som PNG, JPG, SVG eller PDF"
|
||||
"Export page as PNG" = "Exportera sidan som PNG"
|
||||
"Export settings" = "Exportinställningar"
|
||||
"Export variables" = "Exportera variabler"
|
||||
@@ -542,17 +547,17 @@ name = "Svenska"
|
||||
"Follow {name}" = "Följ {name}"
|
||||
"Following" = "Följer"
|
||||
"Following {name} · {key} to stop" = "Följer {name} · {key} för att sluta"
|
||||
"Font" = "Teckensnitt"
|
||||
"Font not installed" = "Teckensnittet är inte installerat"
|
||||
"Font" = "Typsnitt"
|
||||
"Font not installed" = "Typsnittet är inte installerat"
|
||||
"Font size" = "Teckenstorlek"
|
||||
"Font style" = "Teckenstil"
|
||||
"Forget on this computer" = "Glöm på den här datorn"
|
||||
"Four screens of a trail app with components and a prototype flow" = "Fyra skärmar i en vandringsapp med komponenter och ett prototypflöde"
|
||||
"Fractions" = "Bråk"
|
||||
"Frame" = "Ram"
|
||||
"Frame" = "Frame"
|
||||
"Frame selection" = "Rama in markering"
|
||||
"Frame tools" = "Ramverktyg"
|
||||
"Frames" = "Ramar"
|
||||
"Frame tools" = "Frame-verktyg"
|
||||
"Frames" = "Frames"
|
||||
"From the library \"{name}\": read-only" = "Från biblioteket \"{name}\": skrivskyddad"
|
||||
"From {name}" = "Från {name}"
|
||||
"From {server}" = "Från {server}"
|
||||
@@ -609,7 +614,7 @@ name = "Svenska"
|
||||
"Horizontal" = "Horisontell"
|
||||
"Horizontal align" = "Horisontell justering"
|
||||
"Horizontal hex" = "Horisontell hex"
|
||||
"Horizontal padding" = "Horisontell utfyllnad"
|
||||
"Horizontal padding" = "Horisontell padding"
|
||||
"Horizontal spacing" = "Horisontellt avstånd"
|
||||
"Hosted by {operator}" = "Värd: {operator}"
|
||||
"Hover a layer to see its properties" = "Hovra över ett lager för att se dess egenskaper"
|
||||
@@ -622,7 +627,7 @@ name = "Svenska"
|
||||
"If it keeps happening, please report it at {link} with this message." = "Om det fortsätter att hända kan du rapportera det på {link} och bifoga det här meddelandet."
|
||||
"If" = "Om"
|
||||
"If {condition}" = "Om {condition}"
|
||||
"Ignore auto layout (absolute position)" = "Ignorera automatisk layout (absolut position)"
|
||||
"Ignore auto layout (absolute position)" = "Ignorera auto layout (absolut position)"
|
||||
"Ignore overlapping layers" = "Ignorera överlappande lager"
|
||||
"Image" = "Bild"
|
||||
"Images" = "Bilder"
|
||||
@@ -643,6 +648,7 @@ name = "Svenska"
|
||||
"Included" = "Inkluderad"
|
||||
"Included in layout" = "Inkluderad i layouten"
|
||||
"Individual corners" = "Enskilda hörn"
|
||||
"Individual padding" = "Individuell padding"
|
||||
"Individual strokes" = "Enskilda konturer"
|
||||
"Ink" = "Bläck"
|
||||
"Inner shadow" = "Inre skugga"
|
||||
@@ -664,6 +670,7 @@ name = "Svenska"
|
||||
"Interaction" = "Interaktion"
|
||||
"Interactions" = "Interaktioner"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Gränssnittsdesign och prototyper. Ett fritt clean-room-designverktyg med öppen källkod, skrivet från grunden i Rust och en del av ArtCraft Crafting Apps."
|
||||
"Interface size" = "Gränssnittsstorlek"
|
||||
"Intersect" = "Skärning"
|
||||
"Intersects" = "Skär"
|
||||
"Invalid color" = "Ogiltig färg"
|
||||
@@ -714,6 +721,7 @@ name = "Svenska"
|
||||
"Leaves" = "Löv"
|
||||
"Left" = "Vänster"
|
||||
"Left & right" = "Vänster och höger"
|
||||
"Left padding" = "Vänster padding"
|
||||
"Left side" = "Vänster sida"
|
||||
"Left side width" = "Bredd på vänster sida"
|
||||
"Letter case" = "Skiftläge"
|
||||
@@ -739,7 +747,7 @@ name = "Svenska"
|
||||
"Link" = "Länk"
|
||||
"Link copied" = "Länken har kopierats"
|
||||
"Link docs, code or tickets for developers." = "Länka dokumentation, kod eller ärenden för utvecklare."
|
||||
"Link frames on the Prototype tab, then present the flow" = "Länka ramar på fliken Prototyp och presentera sedan flödet"
|
||||
"Link frames on the Prototype tab, then present the flow" = "Länka frames på fliken Prototyp och presentera sedan flödet"
|
||||
"Link this component to its code component: instances then show your real snippet with their values." = "Länka den här komponenten till dess kodkomponent: instanserna visar då ditt riktiga kodavsnitt med sina värden."
|
||||
"List spacing" = "Listavstånd"
|
||||
"List style" = "Liststil"
|
||||
@@ -788,6 +796,7 @@ name = "Svenska"
|
||||
"Mirror angle and length" = "Spegla vinkel och längd"
|
||||
"Mirroring" = "Spegling"
|
||||
"Missing" = "Saknas"
|
||||
"Missing fonts" = "Saknade typsnitt"
|
||||
"Missing layer" = "Lager saknas"
|
||||
"Missing style" = "Stil saknas"
|
||||
"Missing variable" = "Variabel saknas"
|
||||
@@ -828,7 +837,7 @@ name = "Svenska"
|
||||
"New board" = "Ny whiteboard"
|
||||
"New design file" = "Ny designfil"
|
||||
"Newest first" = "Nyaste först"
|
||||
"Next frame" = "Nästa ram"
|
||||
"Next frame" = "Nästa frame"
|
||||
"Next or previous control in the focused region" = "Nästa eller föregående kontroll i den fokuserade regionen"
|
||||
"Next ready for dev" = "Nästa redo för utveckling"
|
||||
"Next size option" = "Nästa storleksalternativ"
|
||||
@@ -847,6 +856,7 @@ name = "Svenska"
|
||||
"No conflicts." = "Inga konflikter."
|
||||
"No contributor data was built into this copy." = "Inga uppgifter om bidragsgivare finns inbyggda i den här kopian."
|
||||
"No device" = "Ingen enhet"
|
||||
"No files or templates match your search" = "Inga filer eller mallar matchar sökningen"
|
||||
"No fonts match" = "Inga typsnitt matchar"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Ingen anslutningskod här (servern är äldre än anslutningskoder, eller så kan den här adressen inte göras om till en): dela länken i stället."
|
||||
"No layer changes to show." = "Inga lagerändringar att visa."
|
||||
@@ -861,9 +871,10 @@ name = "Svenska"
|
||||
"No model credits were built into this copy." = "Inga modellkrediteringar finns inbyggda i den här kopian."
|
||||
"No number variables fit here." = "Inga nummervariabler passar här."
|
||||
"No number variables yet. Create them in Local variables." = "Inga nummervariabler än. Skapa dem i Lokala variabler."
|
||||
"No other pages" = "Inga andra sidor"
|
||||
"No paints" = "Inga färger"
|
||||
"No plugins on {server}." = "Inga plugin-program på {server}."
|
||||
"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Inga plugin-program än. Importera ett plugin-program under utveckling från dess figcraft-plugin.json."
|
||||
"No plugins on {server}." = "Inga plugins på {server}."
|
||||
"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Inga plugins än. Importera ett plugin under utveckling från dess figcraft-plugin.json."
|
||||
"No preferred components" = "Inga föredragna komponenter"
|
||||
"No preset" = "Ingen förinställning"
|
||||
"No saved versions yet. Save a version to mark a point in your work you can come back to." = "Inga sparade versioner än. Spara en version för att markera en punkt i arbetet som du kan återvända till."
|
||||
@@ -885,19 +896,19 @@ name = "Svenska"
|
||||
"Normal" = "Normal"
|
||||
"Not bound" = "Inte kopplad"
|
||||
"Not in a live session" = "Inte i en livesession"
|
||||
"Not in this font" = "Finns inte i det här teckensnittet"
|
||||
"Not in this font" = "Finns inte i det här typsnittet"
|
||||
"Not properties, kept as written: {names}" = "Inte egenskaper, behölls som de skrevs: {names}"
|
||||
"Not read yet" = "Inte läst än"
|
||||
"Not shared" = "Inte delad"
|
||||
"Note {n}" = "Anteckning {n}"
|
||||
"Nothing changed since this was marked ready for dev." = "Inget har ändrats sedan detta markerades som klart för utveckling."
|
||||
"Nothing here yet. Set a frame's status to list it." = "Inget här än. Ange en rams status för att lista den."
|
||||
"Nothing here yet. Set a frame's status to list it." = "Inget här än. Ange en frames status för att lista den."
|
||||
"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Inget online än. Använd Dela på en fil för att lägga ut den online, så visas den här."
|
||||
"Nothing to export" = "Inget att exportera"
|
||||
"Nov" = "nov"
|
||||
"Now" = "Nu"
|
||||
"Nudge (Shift: big nudge)" = "Knuffa (Shift: stor knuff)"
|
||||
"Nudge amount" = "Knuffavstånd"
|
||||
"Nudge (Shift: big nudge)" = "Flytta stegvis (Shift: stort steg)"
|
||||
"Nudge amount" = "Steglängd"
|
||||
"Number" = "Nummer"
|
||||
"Number ↑" = "Nummer ↑"
|
||||
"Number ↓" = "Nummer ↓"
|
||||
@@ -944,6 +955,7 @@ name = "Svenska"
|
||||
"Options" = "Alternativ"
|
||||
"Orange" = "Orange"
|
||||
"Order" = "Ordning"
|
||||
"Other" = "Annat"
|
||||
"Other apps may not open every .fig file exported here." = "Andra appar kanske inte kan öppna alla .fig-filer som exporteras här."
|
||||
"Others lose access and keep their copy; the file stays on the server for you" = "Andra förlorar åtkomst och behåller sin kopia; filen ligger kvar på servern åt dig"
|
||||
"Others type this under Share ▸ Join with a code" = "Andra skriver in den under Dela ▸ Anslut med en kod"
|
||||
@@ -955,12 +967,12 @@ name = "Svenska"
|
||||
"Overlay" = "Överlägg"
|
||||
"Overlay position" = "Överläggsposition"
|
||||
"Overridden here (the collection it extends has another value)" = "Åsidosatt här (samlingen som den utökar har ett annat värde)"
|
||||
"Overrides" = "Åsidosättningar"
|
||||
"Overrides" = "Overrides"
|
||||
"Owner token" = "Ägartoken"
|
||||
"Owner token copied" = "Ägartoken kopierad"
|
||||
"PDF print options" = "PDF-utskriftsalternativ"
|
||||
"PDF print settings" = "PDF-utskriftsinställningar"
|
||||
"Padding" = "Utfyllnad"
|
||||
"Padding" = "Padding"
|
||||
"Page" = "Sida"
|
||||
"Page deleted" = "Sidan har raderats"
|
||||
"Pages" = "Sidor"
|
||||
@@ -994,18 +1006,20 @@ name = "Svenska"
|
||||
"Phone" = "Telefon"
|
||||
"Pick color" = "Hämta färg"
|
||||
"Pick color (I)" = "Hämta färg (I)"
|
||||
"Pick one font family first" = "Välj en teckensnittsfamilj först"
|
||||
"Pick the Frame tool, then drag on the canvas or choose a device size" = "Välj ramverktyget och dra sedan på arbetsytan eller välj en enhetsstorlek"
|
||||
"Pick one font family first" = "Välj en typsnittsfamilj först"
|
||||
"Pick the Frame tool, then drag on the canvas or choose a device size" = "Välj verktyget Frame och dra sedan på arbetsytan eller välj en enhetsstorlek"
|
||||
"Pin a property" = "Fäst en egenskap"
|
||||
"Pinch" = "Nyp"
|
||||
"Pixel grid" = "Pixelrutnät"
|
||||
"Pixel preview 1x" = "Pixelförhandsgranskning 1x"
|
||||
"Pixel preview 2x" = "Pixelförhandsgranskning 2x"
|
||||
"Place a shape or frame (its tool chosen)" = "Placera en form eller ram (när dess verktyg är valt)"
|
||||
"Place a shape or frame (its tool chosen)" = "Placera en form eller frame (när dess verktyg är valt)"
|
||||
"Place automatically" = "Placera automatiskt"
|
||||
"Place image" = "Placera bild"
|
||||
"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Plugin-program körs i FigCrafts sandlåda, utan filer, nätverk eller klocka. Installera bara plugin-program du litar på."
|
||||
"Plugins" = "Plugin-program"
|
||||
"Plugin" = "Plugin"
|
||||
"Plugins & widgets" = "Plugins och widgetar"
|
||||
"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Plugins körs i FigCrafts sandlåda, utan filer, nätverk eller klocka. Installera bara plugins du litar på."
|
||||
"Plugins" = "Plugins"
|
||||
"Plus darker" = "Plus mörkare"
|
||||
"Plus lighter" = "Plus ljusare"
|
||||
"Plus one" = "Plus ett"
|
||||
@@ -1026,8 +1040,8 @@ name = "Svenska"
|
||||
"Presenting: comments" = "Presentation: kommentarer"
|
||||
"Presenting: cycle the size options" = "Presentation: växla mellan storleksalternativen"
|
||||
"Presenting: full screen" = "Presentation: helskärm"
|
||||
"Presenting: next frame" = "Presentation: nästa ram"
|
||||
"Presenting: previous frame" = "Presentation: föregående ram"
|
||||
"Presenting: next frame" = "Presentation: nästa frame"
|
||||
"Presenting: previous frame" = "Presentation: föregående frame"
|
||||
"Presenting: restart" = "Presentation: starta om"
|
||||
"Preserve scroll position" = "Bevara rullningsposition"
|
||||
"Presets" = "Förinställningar"
|
||||
@@ -1035,8 +1049,8 @@ name = "Svenska"
|
||||
"Playground" = "Testyta"
|
||||
"Preview" = "Förhandsgranskning"
|
||||
"Preview this flow" = "Förhandsgranska det här flödet"
|
||||
"Preview this frame" = "Förhandsgranska den här ramen"
|
||||
"Previous frame" = "Föregående ram"
|
||||
"Preview this frame" = "Förhandsgranska den här framen"
|
||||
"Previous frame" = "Föregående frame"
|
||||
"Previous ready for dev" = "Föregående redo för utveckling"
|
||||
"Progressive" = "Progressiv"
|
||||
"Properties" = "Egenskaper"
|
||||
@@ -1075,10 +1089,11 @@ name = "Svenska"
|
||||
"Reduce motion" = "Minska rörelse"
|
||||
"Refraction" = "Brytning"
|
||||
"Regular expression" = "Reguljärt uttryck"
|
||||
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relativt systemets skalning av skärmen (100 % är systemets egen storlek). Dokumentets zoom är separat."
|
||||
"Remove" = "Ta bort"
|
||||
"Remove all" = "Ta bort alla"
|
||||
"Remove annotation" = "Ta bort anteckning"
|
||||
"Remove auto layout" = "Ta bort automatisk layout"
|
||||
"Remove auto layout" = "Ta bort auto layout"
|
||||
"Remove dev resource" = "Ta bort utvecklarresurs"
|
||||
"Remove export setting" = "Ta bort exportinställning"
|
||||
"Remove from recents" = "Ta bort från Senaste"
|
||||
@@ -1114,6 +1129,7 @@ name = "Svenska"
|
||||
"Rename on the server…" = "Byt namn på servern…"
|
||||
"Rename page" = "Byt namn på sida"
|
||||
"Rename to" = "Nytt namn"
|
||||
"Reorder selection" = "Ändra ordning på markeringen"
|
||||
"Replace all" = "Ersätt alla"
|
||||
"Replace mixed export settings" = "Ersätt blandade exportinställningar"
|
||||
"Replace with" = "Ersätt med"
|
||||
@@ -1124,14 +1140,14 @@ name = "Svenska"
|
||||
"Reset" = "Återställ"
|
||||
"Reset adjustments" = "Återställ justeringar"
|
||||
"Reset all changes" = "Återställ alla ändringar"
|
||||
"Reset all overrides on this layer" = "Återställ alla åsidosättningar på det här lagret"
|
||||
"Reset overrides" = "Återställ åsidosättningar"
|
||||
"Reset all overrides on this layer" = "Återställ alla overrides på det här lagret"
|
||||
"Reset overrides" = "Återställ overrides"
|
||||
"Reset slot" = "Återställ plats"
|
||||
"Reset this override" = "Återställ den här åsidosättningen"
|
||||
"Reset this override" = "Återställ den här overriden"
|
||||
"Reset to style" = "Återställ till formatmall"
|
||||
"Reset to the inherited value" = "Återställ till ärvt värde"
|
||||
"Resize by the nudge amount" = "Ändra storlek med knuffavståndet"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Ändra storlek på ramar efter enhetens skärm (eller fönstret utan enhet): deras begränsningar och automatiska layout anpassas"
|
||||
"Resize by the nudge amount" = "Ändra storlek med steglängden"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Ändra storlek på frames efter enhetens skärm (eller fönstret utan enhet): deras constraints och auto layout anpassas"
|
||||
"Resize to fit" = "Anpassa storlek"
|
||||
"Resizing" = "Storleksändring"
|
||||
"Resolve" = "Lös"
|
||||
@@ -1149,6 +1165,7 @@ name = "Svenska"
|
||||
"Review and merge" = "Granska och sammanfoga"
|
||||
"Ribbon" = "Verktygspanel"
|
||||
"Right" = "Höger"
|
||||
"Right padding" = "Höger padding"
|
||||
"Right side" = "Höger sida"
|
||||
"Right side width" = "Bredd på höger sida"
|
||||
"Rotate 90°" = "Rotera 90°"
|
||||
@@ -1170,6 +1187,9 @@ name = "Svenska"
|
||||
"Run" = "Kör"
|
||||
"Run {label}" = "Kör {label}"
|
||||
"Save files automatically" = "Spara filer automatiskt"
|
||||
"Search files and templates" = "Sök efter filer och mallar"
|
||||
"Search pages" = "Sök sidor"
|
||||
"Search results" = "Sökresultat"
|
||||
"Search templates" = "Sök mallar"
|
||||
"See all templates" = "Se alla mallar"
|
||||
"Select layers and they space themselves as their content changes" = "Markera lager så fördelar de avståndet själva när innehållet ändras"
|
||||
@@ -1191,10 +1211,10 @@ name = "Svenska"
|
||||
"Scroll to" = "Rulla till"
|
||||
"Scroll to layer" = "Rulla till lager"
|
||||
"Scroll with parent" = "Rulla med överordnat element"
|
||||
"Search plugins" = "Sök plugin-program"
|
||||
"Search plugins" = "Sök plugins"
|
||||
"Search" = "Sök"
|
||||
"Search actions, layers and tools" = "Sök åtgärder, lager och verktyg"
|
||||
"Search assets" = "Sök tillgångar"
|
||||
"Search assets" = "Sök resurser"
|
||||
"Search comments" = "Sök i kommentarer"
|
||||
"Search fonts" = "Sök typsnitt"
|
||||
"Search layers" = "Sök lager"
|
||||
@@ -1202,8 +1222,8 @@ name = "Svenska"
|
||||
"Section" = "Avsnitt"
|
||||
"Section color" = "Avsnittsfärg"
|
||||
"Sections" = "Avsnitt"
|
||||
"Select a frame and add a starting point to present it as a flow." = "Markera en ram och lägg till en startpunkt för att presentera den som ett flöde."
|
||||
"Select a frame to preview it" = "Markera en ram för att förhandsgranska den"
|
||||
"Select a frame and add a starting point to present it as a flow." = "Markera en frame och lägg till en startpunkt för att presentera den som ett flöde."
|
||||
"Select a frame to preview it" = "Markera en frame för att förhandsgranska den"
|
||||
"Select a layer to see its properties and code" = "Markera ett lager för att se dess egenskaper och kod"
|
||||
"Select all" = "Markera alla"
|
||||
"Select all with same" = "Markera alla med samma"
|
||||
@@ -1350,16 +1370,16 @@ name = "Svenska"
|
||||
"Subtract" = "Subtrahera"
|
||||
"Subtracts" = "Subtraktioner"
|
||||
"Suffix" = "Suffix"
|
||||
"Suspend snapping while dragging" = "Stäng av fästning medan du drar"
|
||||
"Suspend snapping while dragging" = "Stäng av fästfunktionen medan du drar"
|
||||
"Swap instance" = "Byt instans"
|
||||
"Swap library: use another library's assets of the same names" = "Byt bibliotek: använd tillgångar med samma namn från ett annat bibliotek"
|
||||
"Swap library: use another library's assets of the same names" = "Byt bibliotek: använd resurser med samma namn från ett annat bibliotek"
|
||||
"Swap orientation" = "Växla orientering"
|
||||
"Swap overlay" = "Byt överlägg"
|
||||
"Swap to {name}" = "Byt till {name}"
|
||||
"Swap width and height" = "Byt plats på bredd och höjd"
|
||||
"Swapped instance" = "Utbytt instans"
|
||||
"Swapped {n} assets" = { one = "Bytte {n} tillgång", other = "Bytte {n} tillgångar" }
|
||||
"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "Bytte {n} tillgång; {missed} saknade motsvarighet och använder fortfarande det gamla biblioteket", other = "Bytte {n} tillgångar; {missed} saknade motsvarighet och använder fortfarande det gamla biblioteket" }
|
||||
"Swapped {n} assets" = { one = "Bytte {n} resurs", other = "Bytte {n} resurser" }
|
||||
"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "Bytte {n} resurs; {missed} saknade motsvarighet och använder fortfarande det gamla biblioteket", other = "Bytte {n} resurser; {missed} saknade motsvarighet och använder fortfarande det gamla biblioteket" }
|
||||
"Sweep" = "Svep"
|
||||
"System" = "Enligt systemet"
|
||||
"Tab / Shift+Tab" = "Tab / Shift+Tab"
|
||||
@@ -1395,6 +1415,7 @@ name = "Svenska"
|
||||
"The selection uses several text styles: click to apply one" = "Markeringen använder flera textstilar: klicka för att använda en"
|
||||
"The server deletes this file if nobody opens it before {date}." = "Servern raderar filen om ingen öppnar den före {date}."
|
||||
"The whole file" = "Hela filen"
|
||||
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "Fönstret är för litet för {size} %, så gränssnittet ritas i {drawn} %. Gör fönstret större för att använda {size} %."
|
||||
"Theme" = "Tema"
|
||||
"There's no destination for that action" = "Det finns inget mål för den åtgärden"
|
||||
"Thickness" = "Tjocklek"
|
||||
@@ -1406,8 +1427,10 @@ name = "Svenska"
|
||||
"This file" = "Den här filen"
|
||||
"This file has unsaved changes" = "Filen har osparade ändringar"
|
||||
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Filen ligger på {server} och är bara öppen för dig. Dela den för att släppa in andra med en ny kod."
|
||||
"This frame is new on the branch." = "Den här ramen är ny på grenen."
|
||||
"This frame isn't on the branch." = "Den här ramen finns inte på grenen."
|
||||
"This file is too large to join here (up to {mb} MB)" = "Den här filen är för stor för att ansluta till här (högst {mb} MB)"
|
||||
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Den här filen använder typsnitt som inte är installerade på den här datorn. Deras text ritas med ett reservtypsnitt tills du installerar dem."
|
||||
"This frame is new on the branch." = "Den här framen är ny på grenen."
|
||||
"This frame isn't on the branch." = "Den här framen finns inte på grenen."
|
||||
"This server is open to the public: no key needed." = "Servern är öppen för alla: ingen nyckel behövs."
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Servern kräver sin nyckel: lägg till ?key=… i adressen (`figcraft-server key` skriver ut den på serverns dator)."
|
||||
"This server takes files only with its key: see Advanced." = "Servern tar bara emot filer med sin nyckel: se Avancerat."
|
||||
@@ -1427,6 +1450,7 @@ name = "Svenska"
|
||||
"Top" = "Överkant"
|
||||
"Top & bottom" = "Över- och nederkant"
|
||||
"Top left radius" = "Radie uppe till vänster"
|
||||
"Top padding" = "Övre padding"
|
||||
"Top right radius" = "Radie uppe till höger"
|
||||
"Transform" = "Transformera"
|
||||
"Triangle" = "Triangel"
|
||||
@@ -1437,6 +1461,7 @@ name = "Svenska"
|
||||
"Truncate with an ellipsis" = "Korta av med ellips"
|
||||
"Try again" = "Försök igen"
|
||||
"Try another search or filter." = "Prova en annan sökning eller ett annat filter."
|
||||
"Try another search, or browse templates." = "Prova en annan sökning eller bläddra bland mallar."
|
||||
"Try another word, or look at every kind." = "Prova ett annat ord, eller visa alla typer."
|
||||
"Turn a layer into a component, reuse it, and change every copy at once" = "Gör om ett lager till en komponent, återanvänd den och ändra alla kopior på en gång"
|
||||
"Two digits" = "Två siffror"
|
||||
@@ -1471,10 +1496,11 @@ name = "Svenska"
|
||||
"Use a fixed value" = "Använd ett fast värde"
|
||||
"Use an expression, e.g. count + 1" = "Använd ett uttryck, t.ex. count + 1"
|
||||
"Use as mask" = "Använd som mask"
|
||||
"Use auto layout" = "Använd automatisk layout"
|
||||
"Use auto layout" = "Använd auto layout"
|
||||
"Use number keys for opacity" = "Använd siffertangenterna för opacitet"
|
||||
"Use the .figcraft or .fig extension." = "Använd filändelsen .figcraft eller .fig."
|
||||
"Use the layer's fill" = "Använd lagrets fyllning"
|
||||
"Use your browser's zoom to change the interface size." = "Använd webbläsarens zoom för att ändra gränssnittets storlek."
|
||||
"Used in {files} · {instances}" = "Används i {files} · {instances}"
|
||||
"Username" = "Användarnamn"
|
||||
"Value" = "Värde"
|
||||
@@ -1496,7 +1522,7 @@ name = "Svenska"
|
||||
"Vertical" = "Vertikal"
|
||||
"Vertical align" = "Vertikal justering"
|
||||
"Vertical hex" = "Vertikal hex"
|
||||
"Vertical padding" = "Vertikal utfyllnad"
|
||||
"Vertical padding" = "Vertikal padding"
|
||||
"Vertical spacing" = "Vertikalt avstånd"
|
||||
"Vertical trim" = "Vertikal trimning"
|
||||
"View" = "Visa"
|
||||
@@ -1523,12 +1549,13 @@ name = "Svenska"
|
||||
"What changed since it was marked ready for dev" = "Vad som har ändrats sedan den markerades som klar för utveckling"
|
||||
"What is wrong with this file? The server's operator gets your report." = "Vad är fel med den här filen? Serverns operatör får din rapport."
|
||||
"What it's for" = "Vad den är till för"
|
||||
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Vad detta ändrades från är okänt: ändringen gjordes innan FigCraft började spara en kopia av ramar som är klara för utveckling. Lös ändringen (eller markera den som klar igen) för att kunna jämföra från och med nu."
|
||||
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Vad detta ändrades från är okänt: ändringen gjordes innan FigCraft började spara en kopia av frames som är klara för utveckling. Lös ändringen (eller markera den som klar igen) för att kunna jämföra från och med nu."
|
||||
"While hovering" = "Vid hovring"
|
||||
"While pressing" = "Vid nedtryckning"
|
||||
"White" = "Vit"
|
||||
"Who runs it, its terms and how long it keeps files" = "Vem som driver den, dess villkor och hur länge den sparar filer"
|
||||
"Why it changed" = "Varför det ändrades"
|
||||
"Widget" = "Widget"
|
||||
"Widgets" = "Widgetar"
|
||||
"Width" = "Bredd"
|
||||
"Width point" = "Breddpunkt"
|
||||
@@ -1579,7 +1606,7 @@ name = "Svenska"
|
||||
"connector label was skipped: text style data is missing" = "etiketten på en kopplingslinje hoppades över: textens stildata saknas"
|
||||
"connector label was skipped: text transform is invalid" = "etiketten på en kopplingslinje hoppades över: textens transformering är ogiltig"
|
||||
"connector labels were skipped" = "etiketter på kopplingslinjer hoppades över"
|
||||
"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "kopplingslinjelager importeras som statiska ögonblicksbilder av ramar eller vektorer, med etiketter om de finns; dynamiska bindningar av ändpunkterna behålls inte"
|
||||
"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "kopplingslinjelager importeras som statiska ögonblicksbilder av frames eller vektorer, med etiketter om de finns; dynamiska bindningar av ändpunkterna behålls inte"
|
||||
"connector snapshot vector data is missing; connector was left empty" = "vektordata för kopplingslinjens ögonblicksbild saknas; kopplingslinjen lämnades tom"
|
||||
"connectors whose ends can't be placed are empty" = "kopplingslinjer vars ändar inte kan placeras är tomma"
|
||||
"crop marks" = "skärmärken"
|
||||
@@ -1604,11 +1631,11 @@ name = "Svenska"
|
||||
"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "bildfiltren är tvetydiga eftersom båda filterbehållarna finns; filterkonverteringen hoppades över"
|
||||
"image filters use FigCraft's approximate renderer" = "bildfilter använder FigCrafts ungefärliga rendering"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "egenskaper för instansbyte vars standardkomponent inte finns i filen hoppades över"
|
||||
"instances inside their own main component were imported as frames" = "instanser inuti sin egen huvudkomponent importerades som ramar"
|
||||
"instances inside their own main component were imported as frames" = "instanser inuti sin egen huvudkomponent importerades som frames"
|
||||
"instances of components that aren't in the file show no layers" = "instanser av komponenter som inte finns i filen visar inga lager"
|
||||
"instances of components that aren't in the file were imported as frames" = "instanser av komponenter som inte finns i filen importerades som ramar"
|
||||
"instances that would nest a component inside itself were imported as frames" = "instanser som skulle nästla en komponent i sig själv importerades som ramar"
|
||||
"instances without a main component were imported as empty frames" = "instanser utan huvudkomponent importerades som tomma ramar"
|
||||
"instances of components that aren't in the file were imported as frames" = "instanser av komponenter som inte finns i filen importerades som frames"
|
||||
"instances that would nest a component inside itself were imported as frames" = "instanser som skulle nästla en komponent i sig själv importerades som frames"
|
||||
"instances without a main component were imported as empty frames" = "instanser utan huvudkomponent importerades som tomma frames"
|
||||
"invalid internal .fig text path data was ignored" = "ogiltiga interna .fig-data för text på bana ignorerades"
|
||||
"invalid or oversized vector networks import as outlines" = "ogiltiga eller för stora vektornätverk importeras som former"
|
||||
"invalid or oversized vector path data was skipped" = "ogiltiga eller för stora vektorbanedata hoppades över"
|
||||
@@ -1626,6 +1653,7 @@ name = "Svenska"
|
||||
"nobody else here yet" = "ingen annan här än"
|
||||
"non-default slot settings are not represented and were skipped" = "fackinställningar som avviker från standard återges inte och hoppades över"
|
||||
"now" = "nu"
|
||||
"only in a text style or variable" = "endast i en textstil eller variabel"
|
||||
"or" = "eller"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "punkternas hörntyper behölls; streckade konturer och konturer med variabel bredd ritas med lagrets hörntyp"
|
||||
"preferred slot component keys cannot be resolved to file nodes and were skipped" = "nycklar för föredragna komponenter i fack kan inte kopplas till lager i filen och hoppades över"
|
||||
@@ -1648,13 +1676,14 @@ name = "Svenska"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "åtgärder för att ange variabelläge på samlingar eller lägen som inte finns i variabel-JSON:en hoppades över"
|
||||
"slot content assignment target is missing or is not slot content" = "målet för tilldelningen av fackinnehåll saknas eller är inte fackinnehåll"
|
||||
"slot content assignment uses the empty GUID sentinel and was skipped" = "tilldelningen av fackinnehåll använder det tomma GUID:t som markörvärde och hoppades över"
|
||||
"slot layers without a matching slot property were imported as frames" = "facklager utan en motsvarande fackegenskap importerades som frames"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "vissa vektorpunkter hänvisar till punktinställningar som saknas i filen; de använder lagrets hörntyper och vanliga hörn"
|
||||
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "vissa vektorsegment hänvisar till stilinställningar som saknas i filen; de använder lagrets kontur"
|
||||
"stale or invalid grid track sizing entries were ignored" = "inaktuella eller ogiltiga storleksposter för rutnätsspår ignorerades"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "stjärnor och polygoner utan antal punkter eller geometri använder standardantalet punkter"
|
||||
"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "stilinställningar för vissa vektorsegment stöds inte; de segmenten använder lagrets kontur"
|
||||
"text path underline or strikethrough decoration is not rendered" = "understrykning eller genomstrykning av text på bana ritas inte"
|
||||
"text style overrides refer to missing table entries" = "åsidosättningar av textstilar hänvisar till tabellposter som saknas"
|
||||
"text style overrides refer to missing table entries" = "overrides av textstilar hänvisar till tabellposter som saknas"
|
||||
"the file has no pages" = "filen har inga sidor"
|
||||
"the plugin" = "pluginet"
|
||||
"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "variabel-JSON:en har inga `variableCollections` (svaret med lokala variabler väntades) och ignorerades"
|
||||
@@ -1734,6 +1763,7 @@ name = "Svenska"
|
||||
"{n} replies" = { one = "{n} svar", other = "{n} svar" }
|
||||
"{n} rows" = { one = "{n} rad", other = "{n} rader" }
|
||||
"{n} styles" = { one = "{n} formatmall", other = "{n} formatmallar" }
|
||||
"{n} text layers" = { one = "{n} textlager", other = "{n} textlager" }
|
||||
"{n} variables" = { one = "{n} variabel", other = "{n} variabler" }
|
||||
"{n} weeks ago" = { one = "för {n} vecka sedan", other = "för {n} veckor sedan" }
|
||||
"{n} years ago" = { one = "för {n} år sedan", other = "för {n} år sedan" }
|
||||
@@ -1742,6 +1772,7 @@ name = "Svenska"
|
||||
"{n}m" = "{n} min"
|
||||
"{n}w" = "{n} v"
|
||||
"{page}, nothing selected" = "{page}, inget markerat"
|
||||
"{percent}%" = "{percent} %"
|
||||
"{profile} (current)" = "{profile} (aktuell)"
|
||||
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} är dess värde; {property ? \"a\" : \"b\"} väljer en sträng. Andra {…} lämnas som de är skrivna."
|
||||
"{server} can't say what became of your files (it is older than this app)." = "{server} kan inte säga vad som hände med dina filer (den är äldre än den här appen)."
|
||||
@@ -1785,6 +1816,7 @@ name = "Svenska"
|
||||
"arrange.front" = "Flytta längst fram"
|
||||
"arrange.packH" = "Packa horisontellt"
|
||||
"arrange.packV" = "Packa vertikalt"
|
||||
"arrange.reorder" = "Ändra ordning på markeringen"
|
||||
"arrange.roundToPixels" = "Avrunda till pixlar"
|
||||
"arrange.spacing" = "Avstånd"
|
||||
"arrange.tidy" = "Snygga till"
|
||||
@@ -1868,7 +1900,7 @@ name = "Svenska"
|
||||
"component.pushChanges" = "Skicka ändringar till huvudkomponenten"
|
||||
"component.removeSlot" = "Ta bort plats"
|
||||
"component.resetInstance" = "Återställ alla ändringar"
|
||||
"component.resetOverride" = "Återställ åsidosättning"
|
||||
"component.resetOverride" = "Återställ override"
|
||||
"component.restoreMain" = "Återställ huvudkomponent"
|
||||
"dev.compare" = "Jämför ändringar"
|
||||
"dev.focus" = "Fokusvy"
|
||||
@@ -1920,7 +1952,7 @@ name = "Svenska"
|
||||
"file.colorProfile" = "Färgprofil"
|
||||
"file.duplicate" = "Duplicera fil"
|
||||
"file.export" = "Exportera"
|
||||
"file.exportFramesPdf" = "Exportera ramar som PDF"
|
||||
"file.exportFramesPdf" = "Exportera frames som PDF"
|
||||
"file.exportLayers" = "Exportera lager"
|
||||
"file.exportSvg" = "Exportera som SVG"
|
||||
"file.import" = "Importera"
|
||||
@@ -1943,8 +1975,8 @@ name = "Svenska"
|
||||
"fill.pattern" = "Mönster"
|
||||
"fill.remove" = "Ta bort fyllning"
|
||||
"fill.set" = "Ange fyllning"
|
||||
"frame.create" = "Skapa ram"
|
||||
"frame.presets" = "Ramförinställningar"
|
||||
"frame.create" = "Skapa frame"
|
||||
"frame.presets" = "Förinställningar för frames"
|
||||
"frame.swapOrientation" = "Växla orientering"
|
||||
"grid.add" = "Lägg till rutnät"
|
||||
"grid.move" = "Flytta rutnät"
|
||||
@@ -1963,7 +1995,7 @@ name = "Svenska"
|
||||
"image.place" = "Placera bild"
|
||||
"image.presets" = "Bildförinställningar"
|
||||
"image.savePreset" = "Spara förinställning"
|
||||
"inspect.assets" = "Tillgångar"
|
||||
"inspect.assets" = "Resurser"
|
||||
"inspect.code" = "Kod"
|
||||
"inspect.componentCode" = "Komponentkod"
|
||||
"inspect.properties" = "Egenskaper"
|
||||
@@ -1977,8 +2009,8 @@ name = "Svenska"
|
||||
"instance.swap" = "Byt instans"
|
||||
"instance.swapAt" = "Byt instans vid position"
|
||||
"layers.collapseAll" = "Fäll ihop alla"
|
||||
"layout.add" = "Lägg till automatisk layout"
|
||||
"layout.remove" = "Ta bort automatisk layout"
|
||||
"layout.add" = "Lägg till auto layout"
|
||||
"layout.remove" = "Ta bort auto layout"
|
||||
"layout.setTrack" = "Ange spår"
|
||||
"layout.suggest" = "Föreslå layout"
|
||||
"libraries.open" = "Öppna bibliotek"
|
||||
@@ -2002,15 +2034,16 @@ name = "Svenska"
|
||||
"library.updates" = "Biblioteksuppdateringar"
|
||||
"library.usage" = "Biblioteksanvändning"
|
||||
"measure.distances" = "Mät avstånd"
|
||||
"measure.padding" = "Mät utfyllnad"
|
||||
"measure.padding" = "Mät padding"
|
||||
"node.create" = "Skapa nod"
|
||||
"node.get" = "Hämta nod"
|
||||
"node.reorder" = "Ändra ordning"
|
||||
"node.reparent" = "Byt överordnad nod"
|
||||
"node.set" = "Ange nodegenskaper"
|
||||
"object.batchRename" = "Byt namn på flera"
|
||||
"object.convertToFrame" = "Konvertera till ram"
|
||||
"object.convertToFrame" = "Konvertera till frame"
|
||||
"object.convertToGroup" = "Konvertera till grupp"
|
||||
"object.convertToSection" = "Konvertera till avsnitt"
|
||||
"object.flipH" = "Vänd vågrätt"
|
||||
"object.flipV" = "Vänd lodrätt"
|
||||
"object.frameSelection" = "Rama in markering"
|
||||
@@ -2022,7 +2055,7 @@ name = "Svenska"
|
||||
"object.rasterize" = "Rastrera"
|
||||
"object.releaseMask" = "Frigör mask"
|
||||
"object.removeFill" = "Ta bort fyllning"
|
||||
"object.removeFrame" = "Ta bort ram"
|
||||
"object.removeFrame" = "Ta bort frame"
|
||||
"object.removeStroke" = "Ta bort kontur"
|
||||
"object.rename" = "Byt namn"
|
||||
"object.resizeToFit" = "Anpassa storlek"
|
||||
@@ -2051,23 +2084,24 @@ name = "Svenska"
|
||||
"plugin.directory.unpublish" = "Avpublicera plugin från katalogen"
|
||||
"plugin.event" = "Använd kontroll i plugin-panelen"
|
||||
"plugin.import" = "Importera plugin från manifest…"
|
||||
"plugin.list" = "Lista plugin-program"
|
||||
"plugin.list" = "Lista plugins"
|
||||
"plugin.remove" = "Ta bort plugin"
|
||||
"plugin.run" = "Kör plugin"
|
||||
"plugin.ui" = "Plugin-panel"
|
||||
"plugins.manage" = "Hantera plugin-program…"
|
||||
"plugins.manage" = "Hantera plugins…"
|
||||
"plugins.runLast" = "Kör senaste plugin"
|
||||
"pref.autoSave" = "Spara filer automatiskt"
|
||||
"pref.highlightHover" = "Framhäv lager vid hovring"
|
||||
"pref.keepTool" = "Behåll verktyget markerat efter användning"
|
||||
"pref.language" = "Gränssnittsspråk"
|
||||
"pref.nudge" = "Knuffavstånd"
|
||||
"pref.nudge" = "Steglängd"
|
||||
"pref.numberKeysOpacity" = "Använd siffertangenterna för opacitet"
|
||||
"pref.publishServer" = "Server för delning online"
|
||||
"pref.reduceMotion" = "Minska rörelse"
|
||||
"pref.renameDuplicates" = "Byt namn på duplicerade lager"
|
||||
"pref.showDimensions" = "Visa dimensioner på objekt"
|
||||
"pref.startOnHome" = "Börja på Start"
|
||||
"pref.uiScale" = "Gränssnittsstorlek"
|
||||
"pref.userName" = "Ditt namn i kommentarer"
|
||||
"proto.action.add" = "Lägg till prototypåtgärd"
|
||||
"proto.action.move" = "Ändra ordning på prototypåtgärd"
|
||||
@@ -2105,7 +2139,7 @@ name = "Svenska"
|
||||
"select.remove" = "Ta bort från markering"
|
||||
"select.sameEffect" = "Markera samma effekt"
|
||||
"select.sameFill" = "Markera samma fyllning"
|
||||
"select.sameFont" = "Markera samma teckensnitt"
|
||||
"select.sameFont" = "Markera samma typsnitt"
|
||||
"select.sameInstance" = "Markera samma instans"
|
||||
"select.sameProperties" = "Markera samma egenskaper"
|
||||
"select.sameStroke" = "Markera samma kontur"
|
||||
@@ -2168,7 +2202,7 @@ name = "Svenska"
|
||||
"text.edit.selectWord" = "Markera ord"
|
||||
"text.edit.selection" = "Textmarkering"
|
||||
"text.endPreview" = "Avsluta förhandsgranskning av text"
|
||||
"text.fonts" = "Lista teckensnitt"
|
||||
"text.fonts" = "Lista typsnitt"
|
||||
"text.italic" = "Kursiv"
|
||||
"text.lineHeightDown" = "Minska radhöjden"
|
||||
"text.lineHeightUp" = "Öka radhöjden"
|
||||
@@ -2176,17 +2210,21 @@ name = "Svenska"
|
||||
"text.listIndent" = "Öka listindrag"
|
||||
"text.listOutdent" = "Minska listindrag"
|
||||
"text.listStyle" = "Liststil"
|
||||
"text.loadFont" = "Läs in typsnittsfil"
|
||||
"text.maxLines" = "Max antal rader"
|
||||
"text.missingFonts" = "Saknade typsnitt"
|
||||
"text.numberList" = "Numrerad lista"
|
||||
"text.onPath" = "Text på bana"
|
||||
"text.paragraphIndent" = "Styckeindrag"
|
||||
"text.paragraphSpacing" = "Styckeavstånd"
|
||||
"text.pathOptions" = "Alternativ för textbana"
|
||||
"text.preview" = "Förhandsgranska textändring"
|
||||
"text.refit" = "Anpassa text till typsnitt"
|
||||
"text.removeFromPath" = "Ta bort text från bana"
|
||||
"text.rescanFonts" = "Sök igenom installerade typsnitt igen"
|
||||
"text.set" = "Ange text"
|
||||
"text.setFeatures" = "OpenType-funktioner"
|
||||
"text.setVariations" = "Axlar för variabla teckensnitt"
|
||||
"text.setVariations" = "Axlar för variabla typsnitt"
|
||||
"text.sizeDown" = "Minska teckenstorleken"
|
||||
"text.sizeUp" = "Öka teckenstorleken"
|
||||
"text.strikethrough" = "Genomstruken"
|
||||
@@ -2201,7 +2239,7 @@ name = "Svenska"
|
||||
"tool.connector" = "Koppling"
|
||||
"tool.ellipse" = "Ellips"
|
||||
"tool.eraser" = "Suddgummi"
|
||||
"tool.frame" = "Ram"
|
||||
"tool.frame" = "Frame"
|
||||
"tool.hand" = "Hand"
|
||||
"tool.highlighter" = "Överstrykningspenna"
|
||||
"tool.line" = "Linje"
|
||||
@@ -2226,8 +2264,8 @@ name = "Svenska"
|
||||
"tool.text" = "Text"
|
||||
"transform.move" = "Flytta"
|
||||
"transform.moveTo" = "Flytta till"
|
||||
"transform.nudge" = "Knuffa"
|
||||
"transform.nudgeSize" = "Ändra storlek genom att knuffa"
|
||||
"transform.nudge" = "Flytta stegvis"
|
||||
"transform.nudgeSize" = "Ändra storlek stegvis"
|
||||
"transform.resize" = "Ändra storlek"
|
||||
"transform.rotate" = "Rotera"
|
||||
"transform.rotationOrigin" = "Flytta rotationspunkt"
|
||||
@@ -2252,7 +2290,7 @@ name = "Svenska"
|
||||
"variable.setMode" = "Ange variabelläge"
|
||||
"variable.update" = "Redigera variabel"
|
||||
"vector.create" = "Vektor"
|
||||
"vector.deleteHeal" = "Radera och läk markering"
|
||||
"vector.deleteHeal" = "Radera och retuschera markering"
|
||||
"vector.edit.addVertex" = "Lägg till punkt"
|
||||
"vector.edit.begin" = "Redigera vektor"
|
||||
"vector.edit.bend" = "Böj segment"
|
||||
@@ -2267,7 +2305,7 @@ name = "Svenska"
|
||||
"vector.edit.mirroring" = "Handtagsspegling"
|
||||
"vector.edit.moveHandles" = "Flytta handtag"
|
||||
"vector.edit.moveVertices" = "Flytta punkter"
|
||||
"vector.edit.nudge" = "Knuffa punkter"
|
||||
"vector.edit.nudge" = "Flytta punkter stegvis"
|
||||
"vector.edit.paint" = "Färghink"
|
||||
"vector.edit.pen" = "Pennpunkt"
|
||||
"vector.edit.penEnd" = "Avsluta pennbana"
|
||||
@@ -2301,13 +2339,13 @@ name = "Svenska"
|
||||
"version.preview" = "Visa version"
|
||||
"version.rename" = "Byt namn på version"
|
||||
"version.restore" = "Återställ version"
|
||||
"view.assets" = "Panelen Tillgångar"
|
||||
"view.assets" = "Panelen Resurser"
|
||||
"view.comments" = "Kommentarer"
|
||||
"view.contextMenu" = "Visa snabbmeny"
|
||||
"view.design" = "Panelen Design"
|
||||
"view.designPrototype" = "Växla mellan Design och Prototyp"
|
||||
"view.devMode" = "Utvecklarläge"
|
||||
"view.frameOutlines" = "Konturlinjer för ramar"
|
||||
"view.frameOutlines" = "Konturlinjer för frames"
|
||||
"view.fullscreen" = "Helskärm"
|
||||
"view.get" = "Hämta vy"
|
||||
"view.layers" = "Panelen Lager"
|
||||
@@ -2315,7 +2353,7 @@ name = "Svenska"
|
||||
"view.leftSidebar" = "Visa/dölj vänster sidofält"
|
||||
"view.maskOutlines" = "Konturlinjer för masker"
|
||||
"view.multiplayerCursors" = "Samarbetsmarkörer"
|
||||
"view.nextFrame" = "Zooma till nästa ram"
|
||||
"view.nextFrame" = "Zooma till nästa frame"
|
||||
"view.nextPage" = "Nästa sida"
|
||||
"view.nextRegion" = "Fokusera nästa region"
|
||||
"view.outlines" = "Konturlinjer"
|
||||
@@ -2326,7 +2364,7 @@ name = "Svenska"
|
||||
"view.panelWidths" = "Återställ panelbredder"
|
||||
"view.pixelGrid" = "Pixelrutnät"
|
||||
"view.pixelPreview" = "Pixelförhandsgranskning"
|
||||
"view.prevFrame" = "Zooma till föregående ram"
|
||||
"view.prevFrame" = "Zooma till föregående frame"
|
||||
"view.prevPage" = "Föregående sida"
|
||||
"view.previousRegion" = "Fokusera föregående region"
|
||||
"view.proofSrgb" = "Skärmkorrektur i sRGB"
|
||||
@@ -2368,7 +2406,7 @@ name = "Svenska"
|
||||
"libraries.open" = "Öppna bibliotek"
|
||||
"select.sameEffect" = "Effekt"
|
||||
"select.sameFill" = "Fyllning"
|
||||
"select.sameFont" = "Teckensnitt"
|
||||
"select.sameFont" = "Typsnitt"
|
||||
"select.sameInstance" = "Instans"
|
||||
"select.sameProperties" = "Egenskaper"
|
||||
"select.sameStroke" = "Kontur"
|
||||
@@ -2377,7 +2415,7 @@ name = "Svenska"
|
||||
"text.alignJustify" = "Marginaljustera text"
|
||||
"text.alignLeft" = "Vänsterjustera text"
|
||||
"text.alignRight" = "Högerjustera text"
|
||||
"view.assets" = "Tillgångar"
|
||||
"view.assets" = "Resurser"
|
||||
"view.design" = "Design"
|
||||
"view.layers" = "Lager"
|
||||
"view.outlines" = "Visa konturlinjer"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,17 +38,18 @@ fn pill(u: &Ui) -> Rect {
|
||||
crate::toolbar::pill_rect(u.app.canvas.rect, crate::toolbar::bar_width(&u.app))
|
||||
}
|
||||
|
||||
/// The left edge of board toolbar group `i` (button 32 wide, a 10-wide caret after the groups with
|
||||
/// flyouts, 4 between): move, ink, sticky, shapes, connector, text, section, stamp, comment.
|
||||
/// The left edge of board toolbar group `i` (button 32 wide, a 16-wide caret 2 after it on the
|
||||
/// groups with flyouts, 4 between): move, ink, sticky, shapes, connector, text, section, stamp,
|
||||
/// comment.
|
||||
fn group_x(u: &Ui, i: usize) -> f32 {
|
||||
const WIDTHS: [f32; 9] = [46.0, 46.0, 46.0, 46.0, 36.0, 36.0, 36.0, 46.0, 36.0];
|
||||
const WIDTHS: [f32; 9] = [54.0, 54.0, 54.0, 54.0, 36.0, 36.0, 36.0, 54.0, 36.0];
|
||||
pill(u).min.x + 8.0 + WIDTHS.iter().take(i).sum::<f32>()
|
||||
}
|
||||
|
||||
/// Open group `i`'s flyout from its caret and click option `n` in it (a row of 32-wide cells, 4
|
||||
/// apart, 8 in from the flyout's edge, the flyout's bottom 8 above the pill).
|
||||
fn pick_option(u: &mut Ui, i: usize, n: usize) {
|
||||
let caret = pos2(group_x(u, i) + 37.0, pill(u).center().y);
|
||||
let caret = pos2(group_x(u, i) + 42.0, pill(u).center().y);
|
||||
click(u, caret);
|
||||
let at = pos2(group_x(u, i) + 8.0 + n as f32 * 36.0 + 16.0, pill(u).min.y - 8.0 - 24.0);
|
||||
click(u, at);
|
||||
@@ -78,7 +79,7 @@ fn the_board_toolbar_picks_tools_with_their_colours_shapes_and_stamps() {
|
||||
let connector = pos2(group_x(&u, 4) + 16.0, pill(&u).center().y);
|
||||
click(&mut u, connector);
|
||||
assert_eq!(u.app.session.tool.tool, ToolId::Connector);
|
||||
let ink_caret = pos2(group_x(&u, 1) + 37.0, pill(&u).center().y);
|
||||
let ink_caret = pos2(group_x(&u, 1) + 42.0, pill(&u).center().y);
|
||||
click(&mut u, ink_caret);
|
||||
assert_eq!(u.app.ui.tool_flyout, Some("ink"));
|
||||
}
|
||||
|
||||
@@ -513,8 +513,11 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
|
||||
return true;
|
||||
}
|
||||
let ctx = ui.ctx().clone();
|
||||
let (mods, scroll, zoom_delta, hover_pos, space) =
|
||||
ctx.input(|i| (i.modifiers, i.smooth_scroll_delta, i.zoom_delta(), i.pointer.hover_pos(), i.key_down(egui::Key::Space)));
|
||||
// Two fingers pan as well as pinch (a touch screen has no scroll wheel).
|
||||
let (mods, scroll, zoom_delta, hover_pos, space) = ctx.input(|i| {
|
||||
let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |m| m.translation_delta);
|
||||
(i.modifiers, i.smooth_scroll_delta + pan, i.zoom_delta(), i.pointer.hover_pos(), i.key_down(egui::Key::Space))
|
||||
});
|
||||
let editing_text = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false);
|
||||
let typing_elsewhere = crate::a11y::field_focused(&ctx);
|
||||
app.canvas.space_pan = space && !editing_text && !typing_elsewhere;
|
||||
|
||||
@@ -157,7 +157,7 @@ mod tests {
|
||||
assert_eq!(language("zh-Hans"), HAN);
|
||||
assert_eq!(language("bn"), BENGALI);
|
||||
assert_eq!(language("fr"), 0);
|
||||
assert!(["en", "es", "es-419", "fr", "de", "pt-BR", "tr", "id"].iter().all(|c| can_show(c)));
|
||||
assert!(["en", "es", "es-419", "fr", "de", "pt-BR", "tr", "id", "vi"].iter().all(|c| can_show(c)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -45,6 +45,34 @@ fn paste_takes_layers_copied_in_another_design_app() {
|
||||
assert_eq!(selected_names(&app), ["Copied layer"]);
|
||||
}
|
||||
|
||||
struct HtmlAndImage(String);
|
||||
|
||||
impl Services for HtmlAndImage {
|
||||
fn clipboard_get_html(&mut self) -> Option<String> {
|
||||
Some(self.0.clone())
|
||||
}
|
||||
fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec<u8>)> {
|
||||
Some((2, 2, vec![255; 16]))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editable_design_layers_take_priority_over_a_png_representation() {
|
||||
let mut session = Session::new();
|
||||
let selected = session.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap();
|
||||
let mut app = FigApp::new(session, Box::new(HtmlAndImage(copied_html())));
|
||||
let before = app.session.doc().unwrap().node_count();
|
||||
|
||||
crate::menus::paste(&mut app, None, None);
|
||||
|
||||
assert_eq!(selected_names(&app), ["Copied layer"]);
|
||||
assert_eq!(app.session.doc().unwrap().node_count(), before + 1);
|
||||
assert!(matches!(
|
||||
app.session.doc().unwrap().node(figcraft_doc::NodeId(selected)).unwrap().fills[0].kind,
|
||||
figcraft_color::PaintKind::Solid { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_here_puts_copied_design_layers_at_the_point() {
|
||||
let html = copied_html();
|
||||
|
||||
@@ -582,6 +582,11 @@ fn the_intensity_label_fits_whole_in_every_language() {
|
||||
let label = lang.ui.iter().find(|(k, _)| *k == "Intensity").map_or("Intensity", |(_, t)| match t {
|
||||
Text::Plain(s) | Text::Plural { other: s, .. } => s,
|
||||
});
|
||||
// The test's context has only egui's own font: a label it can't draw (Vietnamese
|
||||
// diacritics, CJK) can't be measured here, and the app draws it with a system font.
|
||||
if !ui.ctx().fonts_mut(|f| f.has_glyphs(&crate::theme::regular(11.0), label)) {
|
||||
continue;
|
||||
}
|
||||
let lead = crate::widgets::text_lead_width(ui, label, GLASS_FIELD_W);
|
||||
assert!(lead >= 12.0 + width(label), "{}: {label:?} would end in \"…\"", lang.code);
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ fn plural(code: &str, n: usize) -> Plural {
|
||||
"ru" if n % 10 == 1 && n % 100 != 11 => Plural::One,
|
||||
"ru" | "pl" if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) => Plural::Few,
|
||||
"ru" | "pl" => Plural::Many,
|
||||
"ja" | "ko" | "zh-Hans" => Plural::Other,
|
||||
"ja" | "ko" | "zh-Hans" | "vi" => Plural::Other,
|
||||
"fr" | "pt-BR" if n <= 1 => Plural::One,
|
||||
_ if n == 1 => Plural::One,
|
||||
_ => Plural::Other,
|
||||
@@ -187,7 +187,7 @@ fn plural(code: &str, n: usize) -> Plural {
|
||||
// "other" wording. Czech supplies separate few forms; Russian and Polish supply few/many forms.
|
||||
|
||||
/// Whether count `n` takes the "one" form: 0 and 1 in French and Brazilian Portuguese, never in
|
||||
/// Japanese, Korean and Chinese (no plural forms: always "other"), 1 elsewhere (CLDR's integer rules).
|
||||
/// Japanese, Korean, Chinese and Vietnamese (no plural forms: always "other"), 1 elsewhere (CLDR's integer rules).
|
||||
#[cfg(test)]
|
||||
fn plural_one(lang: usize, n: usize) -> bool {
|
||||
plural(LANGS.get(lang).map_or("en", |l| l.code), n) == Plural::One
|
||||
|
||||
@@ -369,6 +369,7 @@ fn system_languages_pick_the_first_one_with_a_catalog() {
|
||||
assert_eq!(code("system", Some("pt-BR")), "pt-BR");
|
||||
assert_eq!(code("system", Some("tr_TR.UTF-8")), "tr");
|
||||
assert_eq!(code("system", Some("id_ID.UTF-8")), "id");
|
||||
assert_eq!(code("system", Some("vi_VN.UTF-8")), "vi");
|
||||
// Portugal's Portuguese and a bare "pt" read the Brazilian catalog.
|
||||
assert_eq!(code("system", Some("pt-PT")), "pt-BR");
|
||||
assert_eq!(code("pt", None), "pt-BR");
|
||||
@@ -381,6 +382,18 @@ fn system_languages_pick_the_first_one_with_a_catalog() {
|
||||
assert_eq!(code("fr", Some("de-DE")), "fr", "a chosen language beats the system's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn italian_is_picked_for_italian_locales() {
|
||||
let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code;
|
||||
for tag in ["it", "it-IT", "it-CH", "it_IT.UTF-8"] {
|
||||
assert_eq!(code("system", Some(tag)), "it", "{tag}");
|
||||
}
|
||||
let it = LANGS.iter().position(|l| l.code == "it").unwrap();
|
||||
assert_eq!(LANGS[it].name, "Italiano");
|
||||
assert!(LANGS[it].draft);
|
||||
assert!(!plural_one(it, 0) && plural_one(it, 1) && !plural_one(it, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swedish_is_picked_for_swedish_locales() {
|
||||
let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code;
|
||||
@@ -436,12 +449,15 @@ fn french_and_portuguese_count_zero_as_singular() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_korean_and_chinese_have_no_plural_forms() {
|
||||
fn japanese_korean_chinese_and_vietnamese_have_no_plural_forms() {
|
||||
let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code;
|
||||
assert_eq!(code("system", Some("ja-JP")), "ja");
|
||||
assert_eq!(code("system", Some("ko_KR.UTF-8")), "ko");
|
||||
assert_eq!(code("system", Some("vi-VN")), "vi");
|
||||
assert_eq!(code("system", Some("ja,en-US")), "ja");
|
||||
for code in ["ja", "ko", "zh-Hans"] {
|
||||
assert_eq!(LANGS[resolve("vi", None)].name, "Tiếng Việt");
|
||||
assert!(LANGS[resolve("vi", None)].draft);
|
||||
for code in ["ja", "ko", "zh-Hans", "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 { one: Some(_), .. })).map(|(k, _)| k).collect();
|
||||
|
||||
@@ -118,10 +118,10 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) {
|
||||
Some(tb) => {
|
||||
// Leaving the version history shows the tab rather than collapsing it.
|
||||
if active {
|
||||
app.ui.left_collapsed = true;
|
||||
app.ui.close_left_panel();
|
||||
} else {
|
||||
app.ui.left_tab = tb;
|
||||
app.ui.left_collapsed = false;
|
||||
app.ui.open_left_panel();
|
||||
}
|
||||
app.ui.versions = Default::default();
|
||||
}
|
||||
@@ -163,7 +163,7 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) {
|
||||
}
|
||||
icons::paint(ui.painter(), hr.shrink(5.0), Icon::Panels, t.icon);
|
||||
if hresp.clicked() {
|
||||
app.ui.left_collapsed = true;
|
||||
app.ui.close_left_panel();
|
||||
}
|
||||
ui.painter().line_segment([head.left_bottom(), head.right_bottom()], Stroke::new(1.0, t.border));
|
||||
match app.ui.left_tab {
|
||||
@@ -637,7 +637,7 @@ pub(crate) fn start_rename(app: &mut FigApp, ids: &[NodeId]) {
|
||||
let rows = listed_rows(&doc, page, &app.ui.expanded, &app.ui.layer_filter.trim().to_lowercase());
|
||||
let Some(id) = rows.iter().map(|r| r.id).find(|r| ids.contains(r)) else { return };
|
||||
app.ui.left_tab = LeftTab::File;
|
||||
app.ui.left_collapsed = false;
|
||||
app.ui.open_left_panel();
|
||||
rename_layer(app, id);
|
||||
}
|
||||
|
||||
|
||||
+107
-16
@@ -292,6 +292,8 @@ pub trait Services {
|
||||
Box::pin(std::future::ready(None))
|
||||
}
|
||||
fn clipboard_set_text(&mut self, _s: &str) {}
|
||||
/// Give native services access to the window context for platform clipboard output.
|
||||
fn set_egui_context(&mut self, _ctx: &egui::Context) {}
|
||||
fn clipboard_get_text(&mut self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
@@ -311,6 +313,11 @@ pub trait Services {
|
||||
fn reduce_motion(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// The system asks for the dark interface (the freedesktop colour scheme): for platforms whose
|
||||
/// windowing stack doesn't report a theme (winit gives none on Linux and the BSDs).
|
||||
fn system_dark(&self) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
/// After a panic in a frame: keep recovery copies of unsaved files.
|
||||
fn emergency_save(&mut self, _session: &Session) {}
|
||||
/// Keep the preferences now (the installed plugins or their settings changed), as the app
|
||||
@@ -407,9 +414,8 @@ impl Wake for RepaintWaker {
|
||||
pub struct NoServices;
|
||||
impl Services for NoServices {}
|
||||
|
||||
/// Put `text` on the system clipboard through egui's output, which the browser shell writes while
|
||||
/// it handles the copy event (the only time a page may write it). For shells without a clipboard
|
||||
/// of their own.
|
||||
/// Put `text` on the system clipboard through egui's platform output. Native integrations handle
|
||||
/// it directly; the browser shell writes it while handling the copy event.
|
||||
pub fn egui_copy_text(ctx: &egui::Context, text: &str) {
|
||||
ctx.copy_text(text.to_string());
|
||||
}
|
||||
@@ -520,6 +526,34 @@ impl FrameClock {
|
||||
pub const LEFT_PANEL: std::ops::RangeInclusive<f32> = 200.0..=480.0;
|
||||
pub const RIGHT_PANEL: std::ops::RangeInclusive<f32> = 240.0..=480.0;
|
||||
pub const PANEL_DEFAULT: f32 = 240.0;
|
||||
/// Windows narrower than this (logical points: phones, portrait tablets) show one side panel at a
|
||||
/// time over the canvas, opened from two buttons, instead of both beside it.
|
||||
pub const NARROW_WIDTH: f32 = 760.0;
|
||||
/// What an open side panel leaves of a narrow window: the column of panel buttons.
|
||||
const MOBILE_STRIP: f32 = 48.0;
|
||||
|
||||
/// The side panel a narrow window shows.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum MobilePanel {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
/// Opens the left panel; a narrow window shows it in place of the right one.
|
||||
pub fn open_left_panel(&mut self) {
|
||||
self.left_collapsed = false;
|
||||
self.mobile_panel = Some(MobilePanel::Left);
|
||||
}
|
||||
|
||||
/// Closes the left panel.
|
||||
pub fn close_left_panel(&mut self) {
|
||||
self.left_collapsed = true;
|
||||
if self.mobile_panel == Some(MobilePanel::Left) {
|
||||
self.mobile_panel = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub const CANVAS_MIN: f32 = 160.0;
|
||||
|
||||
/// The widths the side panels are drawn at in `avail` points (the window less the icon rail), from
|
||||
@@ -674,6 +708,10 @@ pub struct UiState {
|
||||
/// The Rename layers dialog (several layers), while open.
|
||||
pub batch_rename: Option<dialogs::BatchRenameUi>,
|
||||
pub left_collapsed: bool,
|
||||
/// The side panel open in a narrow window (a phone), where only one shows at a time.
|
||||
pub mobile_panel: Option<MobilePanel>,
|
||||
/// The window was narrow (one side panel at a time) at the last frame.
|
||||
pub narrow: bool,
|
||||
pub comments: comments::CommentUi,
|
||||
pub versions: versions::VersionUi,
|
||||
/// The branch review dialog, while open.
|
||||
@@ -819,6 +857,8 @@ impl Default for UiState {
|
||||
find: None,
|
||||
batch_rename: None,
|
||||
left_collapsed: false,
|
||||
mobile_panel: None,
|
||||
narrow: false,
|
||||
comments: comments::CommentUi::default(),
|
||||
versions: versions::VersionUi::default(),
|
||||
branch_review: None,
|
||||
@@ -1255,7 +1295,14 @@ impl FigApp {
|
||||
"panel.devMode" => {
|
||||
self.ui.right_tab = if self.ui.right_tab == RightTab::Inspect { RightTab::Design } else { RightTab::Inspect };
|
||||
}
|
||||
"panel.leftSidebar" => self.ui.left_collapsed = !self.ui.left_collapsed,
|
||||
"panel.leftSidebar" => {
|
||||
let shown = if self.ui.narrow { self.ui.mobile_panel == Some(MobilePanel::Left) } else { !self.ui.left_collapsed };
|
||||
if shown {
|
||||
self.ui.close_left_panel();
|
||||
} else {
|
||||
self.ui.open_left_panel();
|
||||
}
|
||||
}
|
||||
"panel.widths" => {
|
||||
let width = |k: &str| r.data.get(k).and_then(Value::as_f64).map(|w| w as f32);
|
||||
let (left, right) = (width("left"), width("right"));
|
||||
@@ -1270,7 +1317,7 @@ impl FigApp {
|
||||
"panel.layers" => self.ui.left_tab = LeftTab::File,
|
||||
"panel.assets" => {
|
||||
self.ui.left_tab = LeftTab::Assets;
|
||||
self.ui.left_collapsed = false;
|
||||
self.ui.open_left_panel();
|
||||
self.ui.versions = Default::default();
|
||||
}
|
||||
"panel.versionHistory" => versions::open(self),
|
||||
@@ -1279,7 +1326,7 @@ impl FigApp {
|
||||
}
|
||||
"panel.libraries" => {
|
||||
self.ui.left_tab = LeftTab::Assets;
|
||||
self.ui.left_collapsed = false;
|
||||
self.ui.open_left_panel();
|
||||
self.ui.libraries = true;
|
||||
}
|
||||
"home" => self.ui.home = true,
|
||||
@@ -1726,9 +1773,10 @@ impl FigApp {
|
||||
}
|
||||
let dark = match self.session.prefs.theme.as_str() {
|
||||
"dark" => true,
|
||||
// The active visuals were set by us last frame (possibly for a manual theme).
|
||||
// Read the integration's OS theme instead; without one, keep the light fallback.
|
||||
"system" => ctx.system_theme() == Some(egui::Theme::Dark),
|
||||
// The active visuals were set by us last frame (possibly for a manual theme). Read the
|
||||
// integration's OS theme; winit reports none on Linux and the BSDs, where the platform
|
||||
// services' colour scheme answers instead. Without either, keep the light fallback.
|
||||
"system" => ctx.system_theme().map(|t| t == egui::Theme::Dark).or_else(|| self.services.system_dark()).unwrap_or(false),
|
||||
_ => false,
|
||||
};
|
||||
self.ui.dark = dark;
|
||||
@@ -1901,6 +1949,32 @@ impl FigApp {
|
||||
!hidden && (!board || self.ui.board_panel)
|
||||
}
|
||||
|
||||
/// A narrow window's two buttons for the side panels (touch-sized); the open one closes again.
|
||||
fn mobile_buttons(&mut self, ctx: &egui::Context, at: egui::Pos2) {
|
||||
use a11y::Tip as _;
|
||||
let t = theme::Tokens::get(ctx);
|
||||
egui::Area::new(egui::Id::unique("mobile_panel_buttons")).order(egui::Order::Foreground).fixed_pos(at).show(ctx, |ui| {
|
||||
for (side, icon, name) in
|
||||
[(MobilePanel::Left, icons::Icon::Layers, i18n::tr("Layers")), (MobilePanel::Right, icons::Icon::Panels, i18n::tr("Design"))]
|
||||
{
|
||||
let open = self.ui.mobile_panel == Some(side);
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(40.0, 40.0), egui::Sense::click());
|
||||
let resp = resp.tip(name);
|
||||
let fill = if open { t.accent } else { t.panel };
|
||||
ui.painter().rect(r, 8.0, fill, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
icons::paint(ui.painter(), r.shrink(10.0), icon, if open { t.accent_on } else { t.icon });
|
||||
if resp.clicked() {
|
||||
if side == MobilePanel::Left && !open {
|
||||
self.ui.open_left_panel();
|
||||
} else {
|
||||
self.ui.mobile_panel = if open { None } else { Some(side) };
|
||||
}
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// One frame of the window drawing now.
|
||||
pub(crate) fn frame(&mut self, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
@@ -1968,8 +2042,12 @@ impl FigApp {
|
||||
return;
|
||||
}
|
||||
let hidden = self.session.ds().map(|d| d.view.ui_hidden).unwrap_or(false);
|
||||
let right_open = self.right_panel_shown(hidden);
|
||||
if !hidden {
|
||||
let narrow = !hidden && ui.available_width() < NARROW_WIDTH;
|
||||
let mobile = self.ui.mobile_panel.filter(|_| narrow);
|
||||
self.ui.narrow = narrow;
|
||||
// A narrow window shows the right panel when its button asks for it, a board's too.
|
||||
let right_open = (self.right_panel_shown(hidden) && !narrow) || mobile == Some(MobilePanel::Right);
|
||||
if !hidden && (!narrow || mobile == Some(MobilePanel::Left)) {
|
||||
egui::Panel::left("left_rail")
|
||||
.exact_size(56.0)
|
||||
.resizable(false)
|
||||
@@ -1980,12 +2058,19 @@ impl FigApp {
|
||||
});
|
||||
}
|
||||
// The panels are as wide as asked, less in a window too small for them and the canvas.
|
||||
let left_open = !hidden && !self.ui.left_collapsed;
|
||||
let (left_w, right_w) = fit_panels(ui.available_width(), left_open.then_some(self.ui.left_width), right_open.then_some(self.ui.right_width));
|
||||
let left_open = !hidden && if narrow { mobile == Some(MobilePanel::Left) } else { !self.ui.left_collapsed };
|
||||
let (left_w, right_w) = if narrow {
|
||||
let w = (ui.available_width() - MOBILE_STRIP).max(0.0);
|
||||
(if left_open { w } else { 0.0 }, w)
|
||||
} else {
|
||||
fit_panels(ui.available_width(), left_open.then_some(self.ui.left_width), right_open.then_some(self.ui.right_width))
|
||||
};
|
||||
let rows = ui.available_rect_before_wrap().y_range();
|
||||
let mut edges: Vec<(&str, f32, f32, f32)> = Vec::new();
|
||||
if left_open {
|
||||
edges.push(("left", ui.available_rect_before_wrap().min.x + left_w, left_w, 1.0));
|
||||
if !narrow {
|
||||
edges.push(("left", ui.available_rect_before_wrap().min.x + left_w, left_w, 1.0));
|
||||
}
|
||||
egui::Panel::left("left_panel")
|
||||
.resizable(false)
|
||||
.exact_size(left_w)
|
||||
@@ -2001,7 +2086,9 @@ impl FigApp {
|
||||
});
|
||||
}
|
||||
if right_open {
|
||||
edges.push(("right", ui.available_rect_before_wrap().max.x - right_w, right_w, -1.0));
|
||||
if !narrow {
|
||||
edges.push(("right", ui.available_rect_before_wrap().max.x - right_w, right_w, -1.0));
|
||||
}
|
||||
egui::Panel::right("right_panel")
|
||||
.resizable(false)
|
||||
.exact_size(right_w)
|
||||
@@ -2021,13 +2108,17 @@ impl FigApp {
|
||||
let mut worker = worker.unwrap_or_else(|| render_worker::Worker::new(ctx.clone()));
|
||||
canvas::show(self, ui, &mut worker);
|
||||
self.worker = Some(worker);
|
||||
if !hidden {
|
||||
if !hidden && mobile.is_none() {
|
||||
ui.scope_builder(egui::UiBuilder::new().id_salt("toolbar"), |ui| {
|
||||
a11y::container(ui, egui::accesskit::Role::Toolbar, i18n::tr("Tools"));
|
||||
toolbar::show(self, ui);
|
||||
});
|
||||
}
|
||||
});
|
||||
if narrow {
|
||||
let x = if mobile == Some(MobilePanel::Left) { ctx.content_rect().max.x - MOBILE_STRIP + 4.0 } else { ctx.content_rect().min.x + 4.0 };
|
||||
self.mobile_buttons(&ctx, egui::pos2(x, rows.min + 8.0));
|
||||
}
|
||||
// The panels' edges, over the canvas and the panels (drawn after them: they take the press).
|
||||
for (name, x, shown, grows) in edges {
|
||||
if let Some(w) = Self::panel_edge(ui, name, x, rows, shown, grows) {
|
||||
|
||||
@@ -98,6 +98,10 @@ pub struct LiveUi {
|
||||
pub advanced: bool,
|
||||
pub this_computer: bool,
|
||||
pub port: String,
|
||||
/// The server key for online sharing (a server that needs one; empty: none). Passed to
|
||||
/// `collab.share` so a keyed server can be used from the Share popover without the Publish
|
||||
/// dialog.
|
||||
pub share_key: String,
|
||||
/// What is typed in the join field of the Share popover and the home screen.
|
||||
pub code: String,
|
||||
/// The join started from the home screen: what it is doing shows under the field, and its
|
||||
@@ -746,6 +750,9 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
if let (true, Ok(port)) = (local, app.ui.live.port.trim().parse::<u16>()) {
|
||||
params["port"] = json!(port);
|
||||
}
|
||||
if !local && !app.ui.live.share_key.trim().is_empty() {
|
||||
params["key"] = json!(app.ui.live.share_key.trim());
|
||||
}
|
||||
app.run("collab.share", params);
|
||||
}
|
||||
join_and_advanced(app, ui, t, |app, ui, t| {
|
||||
@@ -762,6 +769,15 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
});
|
||||
} else {
|
||||
widgets::label(ui, tr("Another server, or one that needs its key. The usual server is set in Preferences."), t.text_secondary);
|
||||
// The server key (a server that needs one; the address in Preferences is without it).
|
||||
ui.horizontal(|ui| {
|
||||
widgets::label(ui, tr("Server key"), t.text);
|
||||
if let Some(key) =
|
||||
widgets::secret_field_named(ui, egui::Id::unique("live_share_key"), &app.ui.live.share_key, 200.0, tr("Server key"))
|
||||
{
|
||||
app.ui.live.share_key = key.trim().chars().filter(|c| !c.is_whitespace() && !c.is_control()).take(128).collect();
|
||||
}
|
||||
});
|
||||
if widgets::button(ui, tr("Publish to another server…"), false, true).clicked() {
|
||||
open_publish(app);
|
||||
ui.close();
|
||||
|
||||
@@ -232,6 +232,20 @@ fn not_sharing_the_popover_offers_one_way_to_start_and_one_to_join() {
|
||||
assert_eq!(h.app.session.live().status, Status::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_server_key_shows_as_dots_while_shown_and_typed() {
|
||||
let (mut h, _) = harness();
|
||||
let shapes = open(&mut h);
|
||||
click(&mut h, &shapes, "Advanced");
|
||||
h.app.ui.live.share_key = "s3cret-key".into();
|
||||
let shapes = h.frames(2);
|
||||
let dots = "•".repeat(10);
|
||||
assert!(has_text(&shapes, &dots) && !has_text(&shapes, "s3cret-key"));
|
||||
// Editing it keeps it hidden.
|
||||
let shapes = click(&mut h, &shapes, &dots);
|
||||
assert!(!has_text(&shapes, "s3cret-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_online_shows_the_code_copies_it_and_stops() {
|
||||
let (mut h, script) = harness();
|
||||
|
||||
@@ -82,6 +82,14 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>)
|
||||
}
|
||||
use base64::Engine as _;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(png);
|
||||
// One selected layer that can have fills takes the image as its top fill.
|
||||
if let [target] = app.session.selection()[..]
|
||||
&& at.is_none()
|
||||
&& app.session.doc().ok().and_then(|d| d.node(target)).is_some_and(|n| figcraft_engine::cmd::create::accepts_image_fill(&n.kind))
|
||||
{
|
||||
app.run("image.place", json!({"data": b64, "target": target}));
|
||||
return;
|
||||
}
|
||||
// Centred in the view; Paste here puts its top-left at the point, as it does layers.
|
||||
let (x, y) = if at.is_some() { (cx, cy) } else { ((cx - f64::from(w) / 2.0).round(), (cy - f64::from(h) / 2.0).round()) };
|
||||
app.run("file.import", json!({"data": b64, "name": "Pasted image.png", "x": x, "y": y}));
|
||||
@@ -257,7 +265,7 @@ pub fn invoke(app: &mut FigApp, id: &str) {
|
||||
// The file name in the left panel's header turns into its editor, the name selected.
|
||||
"file.rename" => {
|
||||
app.ui.left_tab = crate::LeftTab::File;
|
||||
app.ui.left_collapsed = false;
|
||||
app.ui.open_left_panel();
|
||||
app.ui.rename_file = true;
|
||||
}
|
||||
"text.link" => app.ui.menu_input = Some(crate::dialogs::MenuInput::Link(String::new())),
|
||||
@@ -272,7 +280,7 @@ pub fn invoke(app: &mut FigApp, id: &str) {
|
||||
"tool.set" => app.ui.tool_flyout = Some("move"),
|
||||
"page.select" => {
|
||||
app.ui.left_tab = crate::LeftTab::File;
|
||||
app.ui.left_collapsed = false;
|
||||
app.ui.open_left_panel();
|
||||
app.ui.pages_open = true;
|
||||
}
|
||||
// From a menu the link is pasted into the Join dialog (`collab.join {link}` joins directly).
|
||||
@@ -702,10 +710,19 @@ fn submenu_at<R>(
|
||||
if !open {
|
||||
return None;
|
||||
}
|
||||
let pos = pos2(r.max.x + gap, r.min.y - 8.0);
|
||||
let mut pos = pos2(r.max.x + gap, r.min.y - 8.0);
|
||||
// No room beside the row (a phone): the submenu covers its parent instead of running off screen.
|
||||
let screen = ui.ctx().content_rect();
|
||||
let cramped = pos.x + MENU_W > screen.max.x;
|
||||
if cramped {
|
||||
pos.x = (screen.max.x - MENU_W - 8.0).max(screen.min.x + 4.0);
|
||||
}
|
||||
let child = At { root: at.root, path: key.to_string() };
|
||||
let shown =
|
||||
egui::Area::new(area).fixed_pos(pos).order(egui::Order::Foreground).constrain(constrain).show(ui.ctx(), |ui| menu_frame(ui, t, child, add));
|
||||
let shown = egui::Area::new(area)
|
||||
.fixed_pos(pos)
|
||||
.order(egui::Order::Foreground)
|
||||
.constrain(constrain || cramped)
|
||||
.show(ui.ctx(), |ui| menu_frame(ui, t, child, add));
|
||||
let panel = shown.response.rect;
|
||||
with_level(ui.ctx(), &at, |l| l.panel = Some(panel));
|
||||
shown.inner
|
||||
|
||||
@@ -230,3 +230,36 @@ fn the_smallest_window_keeps_its_canvas_whatever_the_widths() {
|
||||
assert_eq!((app.ui.left_width, app.ui.right_width), asked, "the widths asked for come back in a wider window");
|
||||
}
|
||||
}
|
||||
|
||||
/// A phone-sized window: the canvas has the whole width, and each side panel opens alone over it,
|
||||
/// leaving the strip with the panel buttons. A wide window ignores the choice.
|
||||
#[test]
|
||||
fn a_narrow_window_shows_one_side_panel_at_a_time() {
|
||||
use crate::MobilePanel;
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = FigApp::new(Session::new(), Box::new(NoServices));
|
||||
let mut k = 0.0;
|
||||
let mut canvas_width = |app: &mut FigApp, size: egui::Vec2| {
|
||||
for _ in 0..4 {
|
||||
k += 1.0 / 60.0;
|
||||
let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, size)), time: Some(k), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| app.ui(ui)).textures_delta.clear();
|
||||
}
|
||||
app.canvas.rect.width()
|
||||
};
|
||||
let phone = vec2(390.0, 844.0);
|
||||
assert!(app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).is_ok());
|
||||
assert!(canvas_width(&mut app, phone) >= 380.0, "closed panels leave the whole width");
|
||||
for side in [MobilePanel::Left, MobilePanel::Right] {
|
||||
app.ui.mobile_panel = Some(side);
|
||||
let w = canvas_width(&mut app, phone);
|
||||
assert!((1.0..=60.0).contains(&w), "{side:?}: the open panel leaves {w}");
|
||||
}
|
||||
// A command that opens the left panel opens it on a phone too, in place of the right one.
|
||||
app.run("view.assets", json!({}));
|
||||
let w = canvas_width(&mut app, phone);
|
||||
assert!((1.0..=60.0).contains(&w) && app.ui.mobile_panel == Some(MobilePanel::Left), "view.assets leaves {w}");
|
||||
app.ui.mobile_panel = Some(MobilePanel::Right);
|
||||
let wide = canvas_width(&mut app, vec2(1440.0, 900.0));
|
||||
assert!(wide > 600.0 && wide < 1100.0, "a wide window shows both panels beside the canvas ({wide})");
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ impl Win {
|
||||
fn new() -> Self {
|
||||
let mut s = Session::new();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let clip = Rc::new(RefCell::new(Clip::default()));
|
||||
let app = FigApp::new(s, Box::new(Desktop(clip.clone())));
|
||||
@@ -176,10 +177,80 @@ fn cmd_v_pastes_an_image_copied_elsewhere() {
|
||||
w.copy_image_elsewhere();
|
||||
w.cmd_v(CMD);
|
||||
assert_eq!(w.named("Pasted image"), 1, "the image was pasted from the key's release");
|
||||
w.app.session.execute("select.none", &json!({})).unwrap();
|
||||
w.cmd_v(CMD);
|
||||
assert_eq!(w.named("Pasted image"), 2, "and again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_v_fills_a_selected_oval_without_adding_a_layer() {
|
||||
let mut w = Win::new();
|
||||
let oval =
|
||||
NodeId(w.app.session.execute("shape.ellipse", &json!({"x": 200, "y": 100, "width": 120, "height": 80})).unwrap()["id"].as_u64().unwrap());
|
||||
let before = w.app.session.doc().unwrap().node_count();
|
||||
let original_fill = w.app.session.doc().unwrap().node(oval).unwrap().fills.clone();
|
||||
w.copy_image_elsewhere();
|
||||
w.cmd_v(CMD);
|
||||
|
||||
let doc = w.app.session.doc().unwrap();
|
||||
let node = doc.node(oval).unwrap();
|
||||
assert_eq!(doc.node_count(), before);
|
||||
assert!(matches!(node.kind, figcraft_doc::NodeKind::Ellipse { .. }));
|
||||
// Added on top: the oval's own fill stays under it.
|
||||
assert_eq!(node.fills.len(), original_fill.len() + 1);
|
||||
assert_eq!(node.fills[..original_fill.len()], original_fill[..]);
|
||||
assert!(matches!(node.fills.last().unwrap().kind, figcraft_color::PaintKind::Image(_)));
|
||||
assert_eq!((node.width, node.height), (120.0, 80.0));
|
||||
assert_eq!(w.app.session.selection(), vec![oval]);
|
||||
let png = figcraft_engine::cmd::file::export_bytes(doc, &[oval], figcraft_doc::ExportFormat::Png, 1.0).unwrap();
|
||||
let pixels = image::load_from_memory(&png).unwrap().to_rgba8();
|
||||
assert_eq!(pixels.get_pixel(0, 0)[3], 0, "the picture stays inside the oval");
|
||||
assert_eq!(pixels.get_pixel(60, 40)[3], 255, "the oval contains the picture");
|
||||
|
||||
w.app.session.undo().unwrap();
|
||||
assert_eq!(w.app.session.doc().unwrap().node(oval).unwrap().fills, original_fill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_an_image_keeps_the_other_fills_variables() {
|
||||
let mut w = Win::new();
|
||||
let oval =
|
||||
NodeId(w.app.session.execute("shape.ellipse", &json!({"x": 200, "y": 100, "width": 120, "height": 80})).unwrap()["id"].as_u64().unwrap());
|
||||
w.app.session.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap();
|
||||
w.app.session.execute("variable.create", &json!({"collection": "Theme", "name": "brand", "type": "color", "value": "#FF0000"})).unwrap();
|
||||
w.app.session.execute("variable.bind", &json!({"ids": [oval.0], "field": "fill", "variable": "brand"})).unwrap();
|
||||
assert!(!w.app.session.doc().unwrap().node(oval).unwrap().bound.is_empty());
|
||||
|
||||
w.copy_image_elsewhere();
|
||||
w.cmd_v(CMD);
|
||||
let node = w.app.session.doc().unwrap().node(oval).unwrap();
|
||||
assert!(matches!(node.fills.last().unwrap().kind, figcraft_color::PaintKind::Image(_)));
|
||||
assert!(matches!(node.fills[0].kind, figcraft_color::PaintKind::Solid { .. }));
|
||||
assert!(!node.bound.is_empty(), "the colour fill under the image keeps its variable");
|
||||
}
|
||||
|
||||
/// An image layer copied in FigCraft pastes as a layer, a selected shape or not.
|
||||
#[test]
|
||||
fn an_image_layer_copied_in_figcraft_pastes_as_a_layer() {
|
||||
let mut w = Win::new();
|
||||
w.copy_image_elsewhere();
|
||||
w.cmd_v(CMD);
|
||||
let source = w.app.session.selection()[0];
|
||||
crate::menus::invoke(&mut w.app, "edit.copy");
|
||||
|
||||
let oval =
|
||||
NodeId(w.app.session.execute("shape.ellipse", &json!({"x": 200, "y": 100, "width": 120, "height": 80})).unwrap()["id"].as_u64().unwrap());
|
||||
let fills = w.app.session.doc().unwrap().node(oval).unwrap().fills.clone();
|
||||
let before = w.app.session.doc().unwrap().node_count();
|
||||
w.cmd_v(CMD);
|
||||
|
||||
let doc = w.app.session.doc().unwrap();
|
||||
assert_eq!(doc.node_count(), before + 1);
|
||||
assert!(doc.contains(source));
|
||||
assert_eq!(doc.node(oval).unwrap().fills, fills);
|
||||
assert_ne!(w.app.session.selection(), vec![oval]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_released_before_v_still_pastes_the_image() {
|
||||
let mut w = Win::new();
|
||||
@@ -247,6 +318,7 @@ fn a_paste_event_from_another_key_does_not_block_the_next_cmd_v() {
|
||||
// Shift+Insert on Windows.
|
||||
w.frame(vec![Event::Paste("text".into())]);
|
||||
w.frame(vec![key(Key::Insert, false, Modifiers::SHIFT)]);
|
||||
w.app.session.execute("select.none", &json!({})).unwrap();
|
||||
w.copy_image_elsewhere();
|
||||
w.cmd_v(CMD);
|
||||
assert_eq!(w.named("Pasted image"), 1);
|
||||
|
||||
@@ -59,6 +59,30 @@ fn enter_edits_a_selected_text_with_all_of_it_selected_and_escape_keeps_the_laye
|
||||
assert!(u.app.session.selection().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_and_cutting_selected_text_use_clipboard_service() {
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
struct Clipboard(Rc<RefCell<Vec<String>>>);
|
||||
impl crate::Services for Clipboard {
|
||||
fn clipboard_set_text(&mut self, text: &str) {
|
||||
self.0.borrow_mut().push(text.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut s = Session::new();
|
||||
let t = NodeId(s.execute("text.create", &json!({"text": "Hello world", "x": 20, "y": 20})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [t]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
let copied = Rc::new(RefCell::new(Vec::new()));
|
||||
u.app.services = Box::new(Clipboard(copied.clone()));
|
||||
press(&mut u, Key::Enter);
|
||||
u.frame_output(vec![Event::Copy]);
|
||||
u.frame_output(vec![Event::Cut]);
|
||||
assert_eq!(&*copied.borrow(), &["Hello world", "Hello world"]);
|
||||
assert_eq!(u.app.session.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_file_takes_the_page_colour_of_the_theme_and_keeps_it() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -308,7 +308,8 @@ pub fn install_fonts(ctx: &Context, system: bool, cjk: u8) {
|
||||
brand.extend(fonts.families.get(&FontFamily::Name("semibold".into())).cloned().unwrap_or_default());
|
||||
fonts.families.insert(FontFamily::Name("brand".into()), brand);
|
||||
// CJK and other scripts from craft-fonts as fallbacks (Noto Sans CJK only with FIGCRAFT_EMBED_CJK).
|
||||
const BUILT_IN: [&str; 6] = ["Inter", "BIZ UDPGothic", "Noto Sans CJK SC", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans Georgian"];
|
||||
const BUILT_IN: [&str; 7] =
|
||||
["Inter", "BIZ UDPGothic", "Noto Sans CJK SC", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans Georgian", "Noto Sans Thai"];
|
||||
let lang = crate::i18n::current().map_or("en", |l| l.code);
|
||||
// A Chinese UI draws Han in a Chinese face: the built-in Japanese one goes after the system faces.
|
||||
let late = |family: &str| family == "BIZ UDPGothic" && crate::cjk::language(lang) == crate::cjk::HAN;
|
||||
@@ -524,4 +525,26 @@ mod tests {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_font_draws_thai_with_the_built_in_thai_face() {
|
||||
// The web build has no system fonts: Thai layer names, frame labels and field text are drawn
|
||||
// by craft-fonts' Noto Sans Thai, a built-in fallback, when the build embeds it.
|
||||
if !figcraft_text::CRAFT_FONTS.iter().any(|f| f.family == "Noto Sans Thai") {
|
||||
eprintln!("built without craft-fonts' Noto Sans Thai; skipping");
|
||||
return;
|
||||
}
|
||||
let ctx = Context::default();
|
||||
install_fonts(&ctx, false, 0);
|
||||
let input =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(100.0, 100.0))), ..Default::default() };
|
||||
ctx.run_ui(input, |_| {}).textures_delta.clear();
|
||||
// "ภาษาไทย" (the Thai language) and "น้ำ" (water: a tone mark over SARA AM).
|
||||
let thai = "\u{0E20}\u{0E32}\u{0E29}\u{0E32}\u{0E44}\u{0E17}\u{0E22} \u{0E19}\u{0E49}\u{0E33}";
|
||||
ctx.fonts_mut(|f| {
|
||||
for font in [regular(11.0), semibold(11.0)] {
|
||||
assert!(f.has_glyphs(&font, thai), "UI font draws {thai:?}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use egui::{Context, RawInput, Theme};
|
||||
use figcraft_engine::{Prefs, Session};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{FigApp, NoServices, theme::Tokens};
|
||||
use crate::{FigApp, NoServices, Services, theme::Tokens};
|
||||
|
||||
fn frame(ctx: &Context, app: &mut FigApp, system: Option<Theme>) {
|
||||
let mut output = ctx.run_ui(RawInput { system_theme: system, ..Default::default() }, |ui| app.ui(ui));
|
||||
@@ -84,6 +84,47 @@ fn unavailable_system_theme_uses_the_existing_light_fallback() {
|
||||
assert_eq!(Tokens::get(&ctx), Tokens::light());
|
||||
}
|
||||
|
||||
/// Services whose colour scheme answer stands in for the platform's (winit gives no theme on Linux
|
||||
/// and the BSDs, where the freedesktop colour scheme takes over).
|
||||
struct Scheme(Option<bool>);
|
||||
impl Services for Scheme {
|
||||
fn system_dark(&self) -> Option<bool> {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_system_theme_the_colour_scheme_services_decide() {
|
||||
for (dark, expected) in [(Some(true), Tokens::dark()), (Some(false), Tokens::light())] {
|
||||
let ctx = Context::default();
|
||||
let mut app = FigApp::new(Session::empty(), Box::new(Scheme(dark)));
|
||||
app.session.execute("view.themeSystem", &json!({})).unwrap();
|
||||
frame(&ctx, &mut app, None);
|
||||
assert_eq!(Tokens::get(&ctx), expected);
|
||||
assert_eq!(app.ui.dark, expected.dark);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_os_theme_wins_over_the_colour_scheme_services() {
|
||||
for (os, scheme) in [(Theme::Light, Some(true)), (Theme::Dark, Some(false))] {
|
||||
let ctx = Context::default();
|
||||
let mut app = FigApp::new(Session::empty(), Box::new(Scheme(scheme)));
|
||||
app.session.execute("view.themeSystem", &json!({})).unwrap();
|
||||
frame(&ctx, &mut app, Some(os));
|
||||
assert_eq!(Tokens::get(&ctx).dark, os == Theme::Dark);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_themes_ignore_the_colour_scheme_services() {
|
||||
let ctx = Context::default();
|
||||
let mut app = FigApp::new(Session::empty(), Box::new(Scheme(Some(false))));
|
||||
app.session.execute("view.themeDark", &json!({})).unwrap();
|
||||
frame(&ctx, &mut app, None);
|
||||
assert_eq!(Tokens::get(&ctx), Tokens::dark());
|
||||
}
|
||||
|
||||
/// Checks that the test graph enables egui's `default_fonts` and `monochrome_emoji_fonts`
|
||||
/// features by asserting both a Latin and an emoji glyph are available.
|
||||
#[test]
|
||||
|
||||
@@ -17,6 +17,9 @@ const GLYPH: f32 = 18.0;
|
||||
/// The flyout carets: smaller than the marks, but on a box whose stroke rounds to the same width
|
||||
/// as theirs at 1×, 1.5× and 2× (a 10-point box drew them one physical pixel thinner).
|
||||
const CARET: f32 = 14.0;
|
||||
/// A flyout caret's button: wide enough to hit, a little clear of its tool's (#988).
|
||||
const FLYOUT_W: f32 = 16.0;
|
||||
const FLYOUT_GAP: f32 = 2.0;
|
||||
|
||||
/// `GLYPH`'s box centred in `r`.
|
||||
fn glyph_rect(r: Rect) -> Rect {
|
||||
@@ -130,7 +133,7 @@ pub(crate) fn bar_width(app: &FigApp) -> f32 {
|
||||
/// Actions, and the mode switcher (boards: the Design panel's button instead).
|
||||
fn main_width(groups: &[Group], board: bool) -> f32 {
|
||||
let (cell, gap) = (TOOL_CELL, 4.0);
|
||||
let carets = groups.iter().filter(|g| g.has_flyout()).count() as f32 * 10.0;
|
||||
let carets = groups.iter().filter(|g| g.has_flyout()).count() as f32 * (FLYOUT_GAP + FLYOUT_W);
|
||||
let end = if board { cell + 16.0 } else { 4.0 * 34.0 + 24.0 };
|
||||
groups.len() as f32 * (cell + gap) + carets + 2.0 * (cell + gap) + 16.0 + end
|
||||
}
|
||||
@@ -139,9 +142,13 @@ fn main_width(groups: &[Group], board: bool) -> f32 {
|
||||
/// Draw · Design · Prototype · Inspect mode switcher (on a board, the Design panel's button).
|
||||
fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens, board: bool) {
|
||||
let groups = if board { BOARD_GROUPS } else { GROUPS };
|
||||
let rect = pill(ui, &mut app.ui.nav, canvas, main_width(groups, board), t);
|
||||
let width = main_width(groups, board);
|
||||
let rect = pill(ui, &mut app.ui.nav, canvas, width, t);
|
||||
let cy = rect.center().y;
|
||||
let mut x = rect.min.x + 8.0;
|
||||
let scroll = pill_scroll(ui, rect, width);
|
||||
let outer_clip = ui.clip_rect();
|
||||
ui.set_clip_rect(rect.intersect(outer_clip));
|
||||
let mut x = rect.min.x + 8.0 - scroll;
|
||||
for g in groups {
|
||||
x = group_cell(app, ui, g, pos2(x, cy), rect.min.y, t);
|
||||
}
|
||||
@@ -158,7 +165,23 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens, board:
|
||||
} else {
|
||||
mode_switcher(app, ui, x, cy, t);
|
||||
}
|
||||
help_button(app, ui, canvas, t);
|
||||
ui.set_clip_rect(outer_clip);
|
||||
help_button(app, ui, canvas, rect, t);
|
||||
}
|
||||
|
||||
/// How far the buttons of a pill `width` wide drawn at `rect` are scrolled: wider than the pill (a
|
||||
/// phone), they slide sideways under a drag or the wheel.
|
||||
fn pill_scroll(ui: &Ui, rect: Rect, width: f32) -> f32 {
|
||||
let scroll_id = ui.scope_id().with("tb_scroll");
|
||||
let max_scroll = (width - rect.width()).max(0.0);
|
||||
let mut scroll = 0.0;
|
||||
if max_scroll > 0.0 {
|
||||
let drag = ui.ctx().read_response(ui.scope_id().with("pill")).map_or(0.0, |r| r.drag_delta().x);
|
||||
let wheel = if ui.rect_contains_pointer(rect) { ui.input(|i| i.smooth_scroll_delta.x + i.smooth_scroll_delta.y) } else { 0.0 };
|
||||
scroll = (ui.data(|d| d.get_temp::<f32>(scroll_id)).unwrap_or(0.0) - drag - wheel).clamp(0.0, max_scroll);
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(scroll_id, scroll));
|
||||
scroll
|
||||
}
|
||||
|
||||
/// A toolbar button's size.
|
||||
@@ -190,7 +213,7 @@ fn group_cell(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, top: f32
|
||||
let mut x = at.x + TOOL_CELL;
|
||||
if g.has_flyout() {
|
||||
flyout_caret(app, ui, g, pos2(x, at.y), t);
|
||||
x += 10.0;
|
||||
x += FLYOUT_GAP + FLYOUT_W;
|
||||
}
|
||||
if app.ui.tool_flyout == Some(g.key) {
|
||||
if g.options == Options::None {
|
||||
@@ -205,7 +228,7 @@ fn group_cell(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, top: f32
|
||||
/// The caret after group `g`'s button (at `at`, its left edge and middle): opens or closes its
|
||||
/// flyout.
|
||||
fn flyout_caret(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, t: &Tokens) {
|
||||
let cr = Rect::from_center_size(pos2(at.x + 5.0, at.y), vec2(10.0, TOOL_CELL));
|
||||
let cr = Rect::from_min_size(pos2(at.x + FLYOUT_GAP, at.y - TOOL_CELL / 2.0), vec2(FLYOUT_W, TOOL_CELL));
|
||||
let cresp = ui.interact(cr, ui.scope_id().with(("flyout", g.key)), Sense::click());
|
||||
let open = app.ui.tool_flyout == Some(g.key);
|
||||
a11y::menu_button(&cresp, tr(g.name), open);
|
||||
@@ -300,8 +323,12 @@ fn pick_mode(app: &mut FigApp, i: usize) {
|
||||
}
|
||||
|
||||
/// The floating help button at the bottom right of the canvas.
|
||||
fn help_button(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) {
|
||||
let hr = Rect::from_center_size(pos2(canvas.max.x - 32.0, canvas.max.y - 32.0), vec2(32.0, 32.0));
|
||||
fn help_button(app: &mut FigApp, ui: &mut Ui, canvas: Rect, pill: Rect, t: &Tokens) {
|
||||
let mut hr = Rect::from_center_size(pos2(canvas.max.x - 32.0, canvas.max.y - 32.0), vec2(32.0, 32.0));
|
||||
// Above the pill when they'd overlap (a narrow window).
|
||||
if hr.intersects(pill) {
|
||||
hr = hr.translate(vec2(0.0, -(pill.height() + 12.0)));
|
||||
}
|
||||
let hresp = ui.interact(hr, ui.scope_id().with("help"), Sense::click()).tip(tr("Help and resources"));
|
||||
ui.painter().circle_filled(hr.center() + vec2(0.0, 1.0), 17.0, Color32::from_black_alpha(20));
|
||||
ui.painter().circle_filled(hr.center(), 16.0, t.panel);
|
||||
@@ -444,7 +471,9 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) {
|
||||
}
|
||||
|
||||
/// Where a toolbar pill `width` wide sits: centred 12 px above the canvas's bottom edge.
|
||||
/// (Never wider than the canvas less a margin: on a phone the main toolbar scrolls inside it.)
|
||||
pub(crate) fn pill_rect(canvas: Rect, width: f32) -> Rect {
|
||||
let width = width.min((canvas.width() - 16.0).max(0.0));
|
||||
Rect::from_center_size(pos2(canvas.center().x, canvas.max.y - 12.0 - 24.0), vec2(width, 48.0))
|
||||
}
|
||||
|
||||
@@ -606,6 +635,18 @@ mod tests {
|
||||
use super::*;
|
||||
use egui::Shape;
|
||||
|
||||
#[test]
|
||||
fn pill_never_outgrows_a_phone_canvas() {
|
||||
let full = main_width(GROUPS, false);
|
||||
let phone = Rect::from_min_size(pos2(0.0, 0.0), vec2(375.0, 700.0));
|
||||
let pill = pill_rect(phone, full);
|
||||
assert!(pill.width() <= phone.width() - 16.0 && pill.min.x >= 8.0 && pill.max.x <= phone.max.x - 8.0, "{pill:?}");
|
||||
// A desktop canvas keeps the full width, and a degenerate one doesn't go negative.
|
||||
let wide = Rect::from_min_size(pos2(0.0, 0.0), vec2(1400.0, 800.0));
|
||||
assert_eq!(pill_rect(wide, full).width(), full);
|
||||
assert_eq!(pill_rect(Rect::from_min_size(pos2(0.0, 0.0), vec2(4.0, 4.0)), full).width(), 0.0);
|
||||
}
|
||||
|
||||
/// The stroke widths `icon` paints with in a `size` box at `ppp` pixels per point.
|
||||
fn widths(ppp: f32, icon: Icon, size: f32) -> Vec<f32> {
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
@@ -50,7 +50,7 @@ impl Form {
|
||||
/// Open the panel (File ▸ Show version history).
|
||||
pub fn open(app: &mut FigApp) {
|
||||
app.ui.versions.open = true;
|
||||
app.ui.left_collapsed = false;
|
||||
app.ui.open_left_panel();
|
||||
app.ui.left_tab = crate::LeftTab::File;
|
||||
}
|
||||
|
||||
|
||||
@@ -486,13 +486,20 @@ pub fn bound_field(ui: &mut Ui, id: Id, lead: Lead, rect: Rect, name: &str) -> O
|
||||
/// A text field that commits on Enter / focus loss. Returns the new text when committed.
|
||||
pub fn text_field(ui: &mut Ui, id: Id, value: &str, width: f32, placeholder: &str) -> Option<String> {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
|
||||
text_field_in(ui, id, value, rect, placeholder, placeholder)
|
||||
text_field_in(ui, id, value, rect, placeholder, placeholder, false)
|
||||
}
|
||||
|
||||
/// [`text_field`] without a placeholder, named `name` for screen readers (its label is beside it).
|
||||
pub fn text_field_named(ui: &mut Ui, id: Id, value: &str, width: f32, name: &str) -> Option<String> {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
|
||||
text_field_in(ui, id, value, rect, "", name)
|
||||
text_field_in(ui, id, value, rect, "", name, false)
|
||||
}
|
||||
|
||||
/// [`text_field_named`] for a secret (a server key): it shows dots, typing included, and screen
|
||||
/// readers hear dots too.
|
||||
pub fn secret_field_named(ui: &mut Ui, id: Id, value: &str, width: f32, name: &str) -> Option<String> {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
|
||||
text_field_in(ui, id, value, rect, "", name, true)
|
||||
}
|
||||
|
||||
/// A multi-line text field across the panel (descriptions, notes) that commits when it loses
|
||||
@@ -536,7 +543,7 @@ pub fn multiline_field(ui: &mut Ui, id: Id, value: &str, hint: &str, rows: usize
|
||||
pub fn combo_field(ui: &mut Ui, id: Id, value: &str, width: f32, options: &[String]) -> Option<String> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
|
||||
let mut out = text_field_in(ui, id, value, rect, "", "");
|
||||
let mut out = text_field_in(ui, id, value, rect, "", "", false);
|
||||
// The chevron sits on top of the field's right end.
|
||||
let chev = Rect::from_min_max(pos2(rect.max.x - 18.0, rect.min.y), rect.max);
|
||||
let resp = ui.interact(chev, id.with("menu"), Sense::click());
|
||||
@@ -557,13 +564,14 @@ pub fn combo_field(ui: &mut Ui, id: Id, value: &str, width: f32, options: &[Stri
|
||||
}
|
||||
|
||||
/// [`text_field`] drawn into `rect`; `name` names it for screen readers.
|
||||
fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str, name: &str) -> Option<String> {
|
||||
fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str, name: &str, secret: bool) -> Option<String> {
|
||||
cancel_retargeted_field_save(ui, id);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let editing_id = id.with("editing");
|
||||
let mut editing: Option<String> = ui.data(|d| d.get_temp(editing_id));
|
||||
let resp = ui.interact(rect, id.with("click"), Sense::click());
|
||||
a11y::text(&resp, name, value);
|
||||
let shown = if secret { std::borrow::Cow::Owned("•".repeat(value.chars().count())) } else { std::borrow::Cow::Borrowed(value) };
|
||||
a11y::text(&resp, name, &shown);
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(5), t.field);
|
||||
if editing.is_some() {
|
||||
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside);
|
||||
@@ -579,7 +587,7 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str
|
||||
let mut out = None;
|
||||
match editing {
|
||||
Some(mut text) => {
|
||||
let r = field_editor(ui, rect, id.with("te"), &mut text, "");
|
||||
let r = field_editor(ui, rect, id.with("te"), &mut text, "", secret);
|
||||
a11y::own_focus(&r);
|
||||
keep_focus(&r, started);
|
||||
crate::shortcuts::release_field_for_save(&r);
|
||||
@@ -595,7 +603,7 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let (s, col) = if value.is_empty() { (placeholder, t.text_tertiary) } else { (value, t.text) };
|
||||
let (s, col) = if value.is_empty() { (placeholder, t.text_tertiary) } else { (&*shown, t.text) };
|
||||
// Long text stays inside the field.
|
||||
let painter = ui.painter().with_clip_rect(rect.shrink2(vec2(8.0, 0.0)));
|
||||
painter.text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), col);
|
||||
@@ -605,14 +613,15 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str
|
||||
}
|
||||
|
||||
/// The editor inside a field's `rect`: one line of text in the field's font, without a frame of
|
||||
/// its own (the field draws it), showing `hint` while empty.
|
||||
fn field_editor(ui: &mut Ui, rect: Rect, id: Id, text: &mut String, hint: &str) -> Response {
|
||||
/// its own (the field draws it), showing `hint` while empty; `password` shows dots.
|
||||
fn field_editor(ui: &mut Ui, rect: Rect, id: Id, text: &mut String, hint: &str, password: bool) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(8.0, 4.0))));
|
||||
let r = child.add(
|
||||
egui::TextEdit::singleline(text)
|
||||
.id(id)
|
||||
.frame(egui::Frame::NONE)
|
||||
.password(password)
|
||||
.font(regular(11.0))
|
||||
.hint_text(egui::RichText::new(hint).font(regular(11.0)).color(t.text_tertiary))
|
||||
.desired_width(rect.width() - 16.0),
|
||||
@@ -628,7 +637,7 @@ pub fn text_input(ui: &mut Ui, id: Id, text: &mut String, width: f32, hint: &str
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(5), t.field);
|
||||
let r = field_editor(ui, rect, id, text, hint);
|
||||
let r = field_editor(ui, rect, id, text, hint, false);
|
||||
if r.has_focus() {
|
||||
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside);
|
||||
} else if ui.rect_contains_pointer(rect) {
|
||||
|
||||
@@ -25,6 +25,10 @@ On Linux and other Unix systems except macOS, user fonts are read from
|
||||
`$XDG_DATA_HOME/fonts`, or `$HOME/.local/share/fonts` when `XDG_DATA_HOME` is unset,
|
||||
empty or relative. The legacy `$HOME/.fonts` directory is also scanned.
|
||||
|
||||
On macOS, fonts activated through Adobe Fonts are read where Creative Cloud keeps them
|
||||
(`~/Library/Application Support/Adobe/CoreSync/plugins/livetype`), so they're available while
|
||||
they're active there. Files without an extension are read when they start like a font file.
|
||||
|
||||
The collaboration server is a binary of its own, `figcraft-server` (`cargo build --release -p
|
||||
figcraft-server`): it keeps files with share links and serves their live sessions, and its
|
||||
`key`, `files`, `links`, `reports`, `block-ip` and `check` subcommands manage them (a server can
|
||||
|
||||
+25
-24
@@ -1,10 +1,10 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (a missing translation fails no check: `cargo xtask i18n-report` lists them; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed
|
||||
by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian
|
||||
Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts (machine-drafted,
|
||||
Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese and Czech drafts (machine-drafted,
|
||||
waiting for native speakers' review), plus Swedish and Turkish catalogs. This page lists the twelve key
|
||||
languages of the craftrules standard, then the other languages Figma ships. How to add a
|
||||
language: [`translating.md`](translating.md).
|
||||
@@ -18,21 +18,21 @@ browser's page translation. The `.lproj` folders in `/Applications/Figma.app` (6
|
||||
Electron/Chromium locale packs, not Figma's own UI languages. Text *content* in any script is
|
||||
supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no vertical text.
|
||||
|
||||
## What FigCraft has (measured 2026-10-17, from source)
|
||||
## What FigCraft has (measured 2026-10-22, from source)
|
||||
|
||||
- **Layer:** one TOML catalog per language in `crates/ui-egui/locales/`, baked into the binary by
|
||||
the UI crate's build script (desktop and web; nothing read at run time). Keys are the English text
|
||||
(`tr("Fill")`, `trf` for `{named}` placeholders, `trn` for counts with one/few/many/other plural forms), so
|
||||
a missing translation shows English, never a raw key. One English text that needs two
|
||||
translations takes a context (`"Fill|sizing"`).
|
||||
- **Strings:** 1,680 UI keys in `en.toml` (41 with plural forms; ~110 are labels from engine and
|
||||
document data, such as tool names, blend modes and Dev Mode properties) plus 561 command labels
|
||||
and 31 menu wordings by command id: 2,272 entries (counted against `en.toml` and the complete
|
||||
- **Strings:** 1,747 UI keys in `en.toml` (42 with plural forms; ~110 are labels from engine and
|
||||
document data, such as tool names, blend modes and Dev Mode properties) plus 574 command labels
|
||||
and 31 menu wordings by command id: 2,352 entries (counted against `en.toml` and the complete
|
||||
Spanish catalog's command and menu tables). Command ids, the CLI, MCP and the control
|
||||
channel stay English. Accessibility names come from the same strings.
|
||||
- **Language preference:** Preferences ▸ Language (System, then each catalog by its own name:
|
||||
English, Čeština, Deutsch, Español, Español (Latinoamérica), Français, Bahasa Indonesia, 日本語,
|
||||
한국어, Português (Brasil), Русский, Svenska, Türkçe, 简体中文), 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
|
||||
@@ -54,7 +54,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
|
||||
- **Not yet localized:** messages the engine writes (errors, a few toasts, Dev Mode property values
|
||||
such as "inside"), number formatting (decimal points stay `.`), and decimal plural rules
|
||||
(`trn` takes integers: Czech uses one/few/other and Russian one/few/many; French and Brazilian
|
||||
Portuguese take the singular for 0 as well; Japanese, Korean and Chinese have one form;
|
||||
Portuguese take the singular for 0 as well; Japanese, Korean, Chinese and Vietnamese have one form;
|
||||
CLDR's "many" for millions in French, Spanish and Portuguese reads as the plural).
|
||||
- **Script support in documents:** Unicode bidi with Arabic shaping and mirrored brackets in text
|
||||
layers; right-to-left list markers and indents; right-to-left names in the layers panel, tabs
|
||||
@@ -87,23 +87,23 @@ 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,272 / 2,272 (100%, source) | yes | Latin | yes (source) | full | 0 |
|
||||
| Simplified Chinese (Mandarin) | zh-Hans | 2,166 / 2,272 (95.3%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) |
|
||||
| Spanish | es, es-419 | 2,272 / 2,272 (100%, draft); es-419 overlays 101 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) |
|
||||
| English | en | 2,352 / 2,352 (100%, source) | yes | Latin | yes (source) | full | 0 |
|
||||
| Simplified Chinese (Mandarin) | zh-Hans | 2,216 / 2,352 (94.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) |
|
||||
| Spanish | es, es-419 | 2,352 / 2,352 (100%, draft); es-419 overlays 104 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) |
|
||||
| Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 |
|
||||
| Arabic | ar | 0 (0%) | no | Bidi, shaping and RTL names work; the UI does not mirror | no | none | 9–15 |
|
||||
| French | fr | 2,167 / 2,272 (95.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
|
||||
| Portuguese | pt-BR | 2,167 / 2,272 (95.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
|
||||
| Indonesian | id | 2,244 / 2,272 (98.8%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
|
||||
| Japanese | ja | 2,254 / 2,272 (99.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) |
|
||||
| German | de | 2,176 / 2,272 (95.8%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) |
|
||||
| Korean | ko | 2,166 / 2,272 (95.3%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) |
|
||||
| Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics | no | none | 3–5 |
|
||||
| French | fr | 2,328 / 2,352 (99.0%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
|
||||
| Portuguese | pt-BR | 2,331 / 2,352 (99.1%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
|
||||
| Indonesian | id | 2,292 / 2,352 (97.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
|
||||
| Japanese | ja | 2,304 / 2,352 (98.0%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) |
|
||||
| German | de | 2,226 / 2,352 (94.6%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) |
|
||||
| Korean | ko | 2,216 / 2,352 (94.2%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) |
|
||||
| Vietnamese | vi | 2,352 / 2,352 (100%, draft) | yes | Latin with stacked diacritics | no | draft | 1–2 (after native review) |
|
||||
|
||||
Other languages (measured 2026-10-17): Russian (`ru`) 2,244 / 2,272 (98.8%), reviewed and approved
|
||||
by VanTan, one/few/many integer plurals; Swedish (`sv`) 2,264 (99.6%), its command labels and menu
|
||||
wordings added 2026-10-17 (#599), native review welcome; Turkish (`tr`) 2,243 (98.7%), whose header
|
||||
says it was machine-drafted and reviewed by a native speaker; Czech (`cs`) 2,244 (98.8%), a draft
|
||||
Other languages (measured 2026-10-22): Russian (`ru`) 2,352 / 2,352 (100%), reviewed and approved
|
||||
by VanTan, one/few/many integer plurals; Swedish (`sv`) 2,314 / 2,352 (98.4%), its command labels and menu
|
||||
wordings added 2026-10-17 (#599), native review welcome; Turkish (`tr`) 2,291 / 2,352 (97.4%), whose header
|
||||
says it was machine-drafted and reviewed by a native speaker; Czech (`cs`) 2,292 / 2,352 (97.4%), a draft
|
||||
with one/few/other plurals. Figma ships none beyond the eight above.
|
||||
|
||||
The layer is done. Per-language hours above are agent hours for a catalog of ~1,800 entries
|
||||
@@ -115,13 +115,14 @@ right-to-left mirroring of the panels is in Arabic's hours. Total: **29–51 h**
|
||||
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 and Czech, which Figma doesn't ship), then
|
||||
Hindi, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; its users see English).
|
||||
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).
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | minor | Vietnamese (`vi`) draft catalog (#1026), assessed as a machine draft awaiting review like the others; string counts re-measured: 2,352 entries. |
|
||||
| 2026-10-23 | minor | A missing translation fails no check: Spanish is no longer held to full coverage, and the tests of one language's wording, names and plural forms are gone (the plural rules and language-tag tests stay). `cargo xtask i18n-report`, run by `cargo xtask ci`, lists each language's missing UI strings, command labels and menu wordings. Grade unchanged |
|
||||
| 2026-10-15 | minor | Community catalogs (#257, #263, #469, #477, #288): Russian (`ru`, Русский, reviewed and approved by VanTan) and Turkish (`tr`, Türkçe, reviewed by a native speaker), Czech (`cs`, Čeština) and Indonesian (`id`, Bahasa Indonesia) drafts, and the Japanese draft caught up; Preferences ▸ Language lists them and System maps their regional tags. Catalogs may give few and many plural forms; Russian picks one, few or many by its integer rule (11–14 take many), the catalog supplying the text choosing the rule. Only Spanish is held to completeness. Counts re-measured: 1,587 UI keys (38 plural), 559 command labels and 31 menu wordings, 2,177 entries; Spanish 100%, Japanese 2,176, Czech and Indonesian 2,166, Russian and Turkish 2,165, the five older drafts 2,088–2,089 (95%). Localization 39% and 29–51 h unchanged at this rounding |
|
||||
| 2026-10-17 | minor | Chinese showed as boxes on macOS (#406): the font scan also walks the system's downloaded font assets (`/System/Library/AssetsV2` and the older `/System/Library/Assets`, only their `com_apple_MobileAsset_Font*` folders), where current versions keep PingFang SC, and Hiragino Sans GB and Heiti SC join the Chinese UI faces and the text fallbacks. Tested with a folder laid out the same way; not yet run on a Mac |
|
||||
|
||||
+1
-1
@@ -535,7 +535,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|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|sv|zh-Hans|…}`: the app's interface language, applied live ("system":
|
||||
- `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|sv|vi|zh-Hans|…}`: 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.
|
||||
|
||||
|
||||
@@ -68,6 +68,22 @@ FIGCRAFT_DEFAULT_SERVER=wss://design.example.org/ cargo build --release -p figcr
|
||||
The release workflow leaves it unset. The public server's terms are in
|
||||
[`online-sharing-terms.md`](online-sharing-terms.md).
|
||||
|
||||
## The default font
|
||||
|
||||
New text starts in Inter (`figcraft_doc::DEFAULT_FONT`). A distribution whose users mostly write a
|
||||
script Inter lacks (Thai, say) can name another family with `FIGCRAFT_DEFAULT_FONT` when building;
|
||||
unset or empty means Inter. Pick a family the build embeds from craft-fonts (the web build has no
|
||||
other fonts) or one its users have installed:
|
||||
|
||||
```sh
|
||||
FIGCRAFT_DEFAULT_FONT="Noto Sans Thai" cargo build --release -p figcraft
|
||||
```
|
||||
|
||||
Files record each text's family, so the variable only changes what new text starts in: a file
|
||||
saved by one build keeps its fonts in a build with another default. Like any other family, it
|
||||
renders only where it is available (embedded or installed); elsewhere the text falls back to the
|
||||
faces that are. The release workflow leaves it unset.
|
||||
|
||||
## What gets built
|
||||
|
||||
| Platform | Artifacts | Built on |
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
@@ -127,6 +127,7 @@ blockers.
|
||||
| 2026-10-15 | minor | Languages (#257, #263, #469, #477, #288): Russian (with one/few/many plural forms) and Turkish catalogs, reviewed by native speakers; Czech and Indonesian drafts; the Japanese draft completed. 2,177 entries now (1,587 UI strings, 559 command labels, 31 menu wordings); strings added since a catalog was written read English there. Localization 39% and 29–51 h unchanged at this rounding (Indonesian is now a draft, while the five older drafts lack 88 newer entries) |
|
||||
| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
|
||||
| 2026-10-16 | minor | Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged |
|
||||
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
|
||||
| 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged |
|
||||
| 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
|
||||
|
||||
+6
-4
@@ -17,7 +17,7 @@ Simplified.
|
||||
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
|
||||
(`fi`) and Bengali (`bn`), machine-drafted and waiting for native speakers' review. Polish (`pl`,
|
||||
(`fi`), Bengali (`bn`), Italian (`it`) and Vietnamese (`vi`), 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.
|
||||
Russian (`ru`, Русский) was reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and
|
||||
@@ -61,7 +61,9 @@ formal Anda (never kamu), keeps established design terms in English (frame, laye
|
||||
instance, auto layout, plugin, file) and keeps a noun unchanged after a count, so its plural entries
|
||||
give only `other`. Georgian has no letter case (never write Mtavruli for capitals), uses verbal nouns
|
||||
for commands (გვერდის დამატება) and keeps a noun singular after a numeral, so counts read
|
||||
"შრე: {n}" and its plural entries give the same text for `one` and `other`.
|
||||
"შრე: {n}" and its plural entries give the same text for `one` and `other`. Vietnamese uses concise
|
||||
imperatives for buttons and menu items (Lưu, Xóa) and keeps a noun unchanged after a count, so its
|
||||
plural entries give only `other`.
|
||||
|
||||
CJK text needs a CJK font, which egui and Inter lack: the desktop app adds an installed one when
|
||||
something on screen needs it (see `crates/ui-egui/src/cjk.rs`). The web build has no system fonts,
|
||||
@@ -80,8 +82,8 @@ picker leaves Georgian out. A new CJK or other non-Latin language also needs its
|
||||
set `[meta] name` and `status = "draft — needs native review"`.
|
||||
2. Translate every value; keep the keys. Keep `{placeholders}` exactly, "…" where English has it,
|
||||
brand names and keyboard glyphs. English labels of commands: `figcraft-cli commands`.
|
||||
3. Plurals: give `one` only if the language has a singular form (Japanese, Korean and Chinese give
|
||||
only `other`). `other` is required; `one`, `few` and `many` are optional. `trn` picks `one` for
|
||||
3. Plurals: give `one` only if the language has a singular form (Japanese, Korean, Chinese and
|
||||
Vietnamese give only `other`). `other` is required; `one`, `few` and `many` are optional. `trn` picks `one` for
|
||||
1 (also 0 in French and Brazilian Portuguese). Russian uses `one` for 1, 21, 31… except 11;
|
||||
`few` for 2–4, 22–24… except 12–14; and `many` for the other integers, including 0. Keep `{n}`
|
||||
in all Russian forms. Add new rules to `plural` in
|
||||
|
||||
Reference in New Issue
Block a user