Merge main into #990

# Conflicts:
#	ROADMAP.md
This commit is contained in:
bflatastic
2026-10-26 10:24:45 +07:00
39 changed files with 3585 additions and 132 deletions
Generated
+1
View File
@@ -1527,6 +1527,7 @@ dependencies = [
"windows",
"winit",
"winresource",
"zbus",
]
[[package]]
+3 -1
View File
@@ -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 (CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; 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 (CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; 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
@@ -153,6 +153,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.
@@ -403,6 +404,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. |
+3 -3
View File
@@ -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) {
+6
View File
@@ -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
+13 -10
View File
@@ -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;
}
+92 -1
View File
@@ -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};
@@ -16,6 +16,8 @@ pub struct Desktop {
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 +58,33 @@ 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(),
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 +165,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")]
@@ -372,6 +456,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()
}
+11 -4
View File
@@ -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}");
}
+3
View File
@@ -80,3 +80,6 @@ sync_github_name = false
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
[people.leandrodslv]
sync_github_name = true
+20 -1
View File
@@ -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.
+28 -1
View File
@@ -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()?;
+9 -6
View File
@@ -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();
+2 -2
View File
@@ -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),
+22
View File
@@ -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);
}
}
+39
View File
@@ -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};
@@ -1726,6 +1727,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).
+2
View File
@@ -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)]
+172
View File
@@ -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, &params).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());
}
+31
View File
@@ -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
+24 -3
View File
@@ -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;
+41
View File
@@ -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}");
}
}
+58 -58
View File
@@ -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
+6 -5
View File
@@ -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"));
}
+5 -2
View File
@@ -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;
+28
View File
@@ -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();
+12
View File
@@ -381,6 +381,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;
+4 -4
View File
@@ -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);
}
+103 -13
View File
@@ -307,6 +307,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
@@ -516,6 +521,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
@@ -670,6 +703,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.
@@ -815,6 +852,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,
@@ -1251,7 +1290,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"));
@@ -1266,7 +1312,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),
@@ -1275,7 +1321,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,
@@ -1719,9 +1765,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;
@@ -1894,6 +1941,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();
@@ -1961,8 +2034,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)
@@ -1973,12 +2050,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)
@@ -1994,7 +2078,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)
@@ -2014,13 +2100,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) {
+22 -5
View File
@@ -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
+33
View File
@@ -229,3 +229,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})");
}
+72
View File
@@ -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);
+24 -1
View File
@@ -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:?}");
}
});
}
}
+42 -1
View File
@@ -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]
+49 -8
View File
@@ -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();
+1 -1
View File
@@ -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;
}
+4
View File
@@ -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
+16
View File
@@ -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
View File
@@ -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. |
+1 -1
View File
@@ -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`) and Italian (`it`), 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