Merge main into #1026

# Conflicts:
#	docs/translating.md
This commit is contained in:
bflatastic
2026-10-26 10:15:28 +07:00
28 changed files with 3309 additions and 86 deletions
Generated
+1
View File
@@ -1527,6 +1527,7 @@ dependencies = [
"windows",
"winit",
"winresource",
"zbus",
]
[[package]]
+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()
}
+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
+6 -1
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",
+12
View File
@@ -276,3 +276,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
+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
@@ -382,6 +382,18 @@ fn system_languages_pick_the_first_one_with_a_catalog() {
assert_eq!(code("fr", Some("de-DE")), "fr", "a chosen language beats the system's");
}
#[test]
fn italian_is_picked_for_italian_locales() {
let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code;
for tag in ["it", "it-IT", "it-CH", "it_IT.UTF-8"] {
assert_eq!(code("system", Some(tag)), "it", "{tag}");
}
let it = LANGS.iter().position(|l| l.code == "it").unwrap();
assert_eq!(LANGS[it].name, "Italiano");
assert!(LANGS[it].draft);
assert!(!plural_one(it, 0) && plural_one(it, 1) && !plural_one(it, 2));
}
#[test]
fn swedish_is_picked_for_swedish_locales() {
let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code;
+9 -3
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
@@ -1719,9 +1724,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;
+8
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}));
+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]
+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 |
+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`), Bengali (`bn`) and Vietnamese (`vi`), machine-drafted and waiting for native speakers' review. Polish (`pl`,
(`fi`), Bengali (`bn`), Italian (`it`) and Vietnamese (`vi`), machine-drafted and waiting for native speakers' review. Polish (`pl`,
Polski) was reviewed and approved by Lucas (@lukasz-sklad). Georgian (`ka`) was written with AI
assistance and reviewed by a native speaker, and stays a draft until a maintainer changes its status.
Russian (`ru`, Русский) was reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and