mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 11:26:50 -06:00
Generated
+1
@@ -1527,6 +1527,7 @@ dependencies = [
|
||||
"windows",
|
||||
"winit",
|
||||
"winresource",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"image.place",
|
||||
"Place image",
|
||||
"{data (base64) | path, x?, y?, width?, height?, parent?, mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
|
||||
"{data (base64) | path, x?, y?, width?, height?, parent?, target? (an existing layer: the image is added on top of its fills instead of making a layer), mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
|
||||
true,
|
||||
place_image
|
||||
),
|
||||
@@ -394,9 +394,28 @@ fn read_path(path: &str) -> Result<Vec<u8>> {
|
||||
Err(crate::EngineError::Other(format!("{path}: files can't be read in the browser")))
|
||||
}
|
||||
|
||||
/// Layers that can show an image inside their own outline.
|
||||
pub fn accepts_image_fill(kind: &NodeKind) -> bool {
|
||||
kind.is_shape() || kind.is_frame_like() || matches!(kind, NodeKind::Text(_) | NodeKind::BooleanOp { .. })
|
||||
}
|
||||
|
||||
/// Add an image fill on top of the layer's fills: the others keep their place and variable
|
||||
/// bindings; a linked fill style no longer describes the list, so the link goes.
|
||||
fn add_image_fill(node: &mut Node, image: String, mode: ImageScaleMode) {
|
||||
node.fills.push(Paint::image(image, mode));
|
||||
node.styles.fill = None;
|
||||
}
|
||||
|
||||
/// Place an image as a rectangle with an image fill (Figma's image layer).
|
||||
pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "image.place";
|
||||
let target = opt_id(cmd, p, "target")?;
|
||||
if let Some(id) = target {
|
||||
let node = s.doc()?.node(id).ok_or(crate::EngineError::NoNode(id))?;
|
||||
if !accepts_image_fill(&node.kind) {
|
||||
return Err(bad(cmd, "`target` must be a layer that can have a fill"));
|
||||
}
|
||||
}
|
||||
let bytes = if let Some(b64) = string(p, "data") {
|
||||
use base64::Engine as _;
|
||||
let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64);
|
||||
@@ -412,6 +431,14 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"tile" => ImageScaleMode::Tile,
|
||||
_ => ImageScaleMode::Fill,
|
||||
};
|
||||
if let Some(id) = target {
|
||||
let doc = s.doc_mut()?;
|
||||
let (hash, _, _) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let profile = figcraft_render::images::profile_name(&hash, doc.images.get(&hash));
|
||||
let node = doc.get_mut(id)?;
|
||||
add_image_fill(node, hash.clone(), mode);
|
||||
return Ok(json!({"id": id, "image": hash, "width": node.width, "height": node.height, "imageProfile": profile}));
|
||||
}
|
||||
let page = s.page()?;
|
||||
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
|
||||
let ds = s.ds_mut()?;
|
||||
|
||||
@@ -456,16 +456,19 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
let closed = figcraft_doc::geometry::is_closed(&n);
|
||||
let Some(geom) = (match &n.kind {
|
||||
NodeKind::Text(_) | NodeKind::BooleanOp { .. } => figcraft_render::node_outline(doc, &n).map(|(p, _)| p),
|
||||
_ => figcraft_doc::geometry::stroke_geometry(&n),
|
||||
let Some((area, rule)) = (match &n.kind {
|
||||
NodeKind::Text(_) => figcraft_render::node_outline(doc, &n)
|
||||
.map(|(p, _)| (figcraft_render::draw::stroke_area(&n, &p, closed), figcraft_render::FillRule::Winding)),
|
||||
_ => figcraft_render::stroke_fill(doc, &n),
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let area = figcraft_render::draw::stroke_area(&n, &geom, closed);
|
||||
let mut path = (*area).clone();
|
||||
// Union the pieces so the outline is one clean shape.
|
||||
if let Ok(u) = figcraft_pathops::simplify(&path) {
|
||||
// Simplify non-zero strokes into even-odd contours. Inside/outside rings already use
|
||||
// even-odd: interpreting their same-direction contours as non-zero would fill the hole.
|
||||
if rule == figcraft_render::FillRule::Winding
|
||||
&& let Ok(u) = figcraft_pathops::simplify(&path)
|
||||
{
|
||||
path = u;
|
||||
}
|
||||
let bb = path.bounding_box();
|
||||
|
||||
@@ -1009,8 +1009,8 @@ fn bound_field(n: &Node, field: &str) -> Option<String> {
|
||||
let cl = &n.child_layout;
|
||||
let pad = |i: usize| layout.and_then(|l| l.padding.get(i).copied());
|
||||
Some(match field {
|
||||
"width" => format!("{}", n.width),
|
||||
"height" => format!("{}", n.height),
|
||||
"width" | "w" => format!("{}", n.width),
|
||||
"height" | "h" => format!("{}", n.height),
|
||||
"minWidth" => format!("{:?}", cl.min_w),
|
||||
"maxWidth" => format!("{:?}", cl.max_w),
|
||||
"minHeight" => format!("{:?}", cl.min_h),
|
||||
|
||||
@@ -288,3 +288,25 @@ fn a_drag_with_the_text_tool_makes_a_fixed_size_box() {
|
||||
let (b3, _) = text_tool((100.0, 100.0), (180.0, 101.0), "A");
|
||||
assert_eq!((b3.width(), b3.height()), (80.0, line.height()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_text_starts_in_the_builds_default_font() {
|
||||
// `text.create` without a fontFamily and a click with the Text tool both start in
|
||||
// `figcraft_doc::DEFAULT_FONT` (Inter, unless the build names another with
|
||||
// `FIGCRAFT_DEFAULT_FONT`), and the family is written with the text.
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
let mut s = Session::new();
|
||||
let made = text(&mut s, "Text", json!({}));
|
||||
s.set_tool(ToolId::Text);
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, 300.0, 300.0, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, 300.0, 300.0, 1.0)).unwrap();
|
||||
crate::textedit::insert(&mut s, "Tool").unwrap();
|
||||
let typed = s.ds().unwrap().text_edit.as_ref().unwrap().id;
|
||||
assert_ne!(made, typed);
|
||||
let doc = s.doc().unwrap();
|
||||
for t in [made, typed] {
|
||||
let td = doc.node(t).unwrap().kind.text().unwrap();
|
||||
assert_eq!(td.style.font_family, figcraft_doc::DEFAULT_FONT);
|
||||
assert_eq!(serde_json::to_value(td).unwrap()["style"]["font_family"], figcraft_doc::DEFAULT_FONT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use figcraft_color::{ImageScaleMode, PaintKind};
|
||||
use figcraft_doc::geometry::select_target;
|
||||
use figcraft_doc::*;
|
||||
use figcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
@@ -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).
|
||||
|
||||
@@ -6,6 +6,8 @@ mod empty_text_box_undo;
|
||||
mod library_style_acceptance;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod list_indent_response;
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod outline_stroke;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod scope_reset;
|
||||
#[allow(clippy::unwrap_used)]
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Outline stroke keeps the renderer's holes and existing property ownership.
|
||||
|
||||
use figcraft_doc::{Document, NodeId, NodeKind};
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_render::{Options, View, render_view};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap()
|
||||
}
|
||||
|
||||
fn rectangle(s: &mut Session, opacity: f64) -> NodeId {
|
||||
NodeId(
|
||||
run(
|
||||
s,
|
||||
"shape.rectangle",
|
||||
json!({
|
||||
"x": 24, "y": 24, "width": 64, "height": 64,
|
||||
"props": {"fill": "#FF0000", "stroke": "#0000FF", "strokeWeight": 8, "opacity": opacity}
|
||||
}),
|
||||
)["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
fn pixels(doc: &Document) -> Vec<u8> {
|
||||
render_view(doc, doc.first_page().unwrap(), &View { origin: (0.0, 0.0), scale: 1.0, width: 112, height: 112 }, &Options::default())
|
||||
.unwrap()
|
||||
.data()
|
||||
.to_vec()
|
||||
}
|
||||
|
||||
fn outlined(s: &mut Session, id: NodeId) -> NodeId {
|
||||
NodeId(run(s, "vector.outlineStroke", json!({"ids": [id]}))["ids"][0].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn same_pixels(actual: &[u8], expected: &[u8]) {
|
||||
assert_eq!(actual.len(), expected.len());
|
||||
let differences: Vec<_> = actual.iter().zip(expected).enumerate().filter(|(_, (a, b))| a != b).take(8).collect();
|
||||
assert!(differences.is_empty(), "rendered channels changed (byte, actual/expected): {differences:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_keeps_rings_through_undo_and_native_reopen() {
|
||||
for vector in [false, true] {
|
||||
let mut s = Session::new();
|
||||
let mut id = rectangle(&mut s, 1.0);
|
||||
if vector {
|
||||
id = NodeId(run(&mut s, "vector.flatten", json!({"ids": [id]}))["id"].as_u64().unwrap());
|
||||
}
|
||||
let before = s.doc().unwrap().clone();
|
||||
let expected = pixels(&before);
|
||||
let depth = s.undo_depth();
|
||||
let group = outlined(&mut s, id);
|
||||
let after = s.doc().unwrap().clone();
|
||||
same_pixels(&pixels(&after), &expected);
|
||||
assert!(after.node(id).unwrap().strokes.is_empty());
|
||||
assert_eq!(s.selection(), vec![group]);
|
||||
assert_eq!(s.undo_depth(), (depth.0 + 1, 0));
|
||||
let (loaded, notes) = figcraft_format::load_bytes(&figcraft_format::save_bytes(&after, None).unwrap()).unwrap();
|
||||
assert!(notes.is_empty(), "{notes:?}");
|
||||
same_pixels(&pixels(&loaded), &expected);
|
||||
assert!(s.undo().unwrap());
|
||||
assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), serde_json::to_value(&before).unwrap());
|
||||
assert!(s.redo().unwrap());
|
||||
assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), serde_json::to_value(&after).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_only_keeps_holes_at_each_opacity() {
|
||||
for opacity in [0.0, 0.25, 0.5, 1.0] {
|
||||
let mut s = Session::new();
|
||||
let id = rectangle(&mut s, opacity);
|
||||
run(&mut s, "object.removeFill", json!({"ids": [id]}));
|
||||
let before = pixels(s.doc().unwrap());
|
||||
let vector = outlined(&mut s, id);
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(matches!(doc.node(vector).unwrap().kind, NodeKind::Vector { .. }));
|
||||
assert_eq!(doc.node(vector).unwrap().opacity, opacity as f32);
|
||||
same_pixels(&pixels(doc), &before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_keeps_stroke_holes_and_alignment() {
|
||||
for props in [
|
||||
json!({"strokeAlign": "inside"}),
|
||||
json!({"strokeAlign": "outside"}),
|
||||
json!({"strokeAlign": "center"}),
|
||||
json!({"strokeSides": [2, 4, 8, 16]}),
|
||||
json!({"strokeAlign": "center", "dashes": [8, 4]}),
|
||||
] {
|
||||
for filled in [false, true] {
|
||||
let mut s = Session::new();
|
||||
let id = rectangle(&mut s, 1.0);
|
||||
run(&mut s, "node.set", json!({"ids": [id], "props": props}));
|
||||
if !filled {
|
||||
run(&mut s, "object.removeFill", json!({"ids": [id]}));
|
||||
}
|
||||
let before = pixels(s.doc().unwrap());
|
||||
outlined(&mut s, id);
|
||||
same_pixels(&pixels(s.doc().unwrap()), &before);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outlining_an_instance_keeps_its_inherited_opacity_link() {
|
||||
let mut s = Session::new();
|
||||
let component = NodeId(
|
||||
run(
|
||||
&mut s,
|
||||
"frame.create",
|
||||
json!({
|
||||
"width": 64, "height": 64,
|
||||
"props": {"fill": "#FF0000", "stroke": "#0000FF", "strokeWeight": 8, "opacity": 0.5}
|
||||
}),
|
||||
)["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
run(&mut s, "component.create", json!({"ids": [component]}));
|
||||
let instance = NodeId(run(&mut s, "instance.create", json!({"component": component, "x": 200}))["id"].as_u64().unwrap());
|
||||
outlined(&mut s, instance);
|
||||
assert_eq!(s.doc().unwrap().node(instance).unwrap().opacity, 0.5);
|
||||
assert!(!s.doc().unwrap().node(instance).unwrap().overrides.contains("opacity"));
|
||||
run(&mut s, "node.set", json!({"id": component, "props": {"opacity": 0.25}}));
|
||||
let n = s.doc().unwrap().node(instance).unwrap();
|
||||
assert_eq!(n.opacity, 0.25, "the instance still follows its main");
|
||||
assert!(!n.overrides.contains("opacity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outlining_a_main_layer_keeps_its_instance_override() {
|
||||
let mut s = Session::new();
|
||||
let frame = NodeId(run(&mut s, "frame.create", json!({"width": 100, "height": 100}))["id"].as_u64().unwrap());
|
||||
let layer = NodeId(
|
||||
run(
|
||||
&mut s,
|
||||
"shape.rectangle",
|
||||
json!({
|
||||
"parent": frame, "x": 24, "y": 24, "width": 64, "height": 64,
|
||||
"props": {"fill": "#FF0000", "stroke": "#0000FF", "strokeWeight": 8}
|
||||
}),
|
||||
)["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
let component = run(&mut s, "component.create", json!({"ids": [frame]}))["id"].clone();
|
||||
run(&mut s, "instance.create", json!({"component": component, "x": 200}));
|
||||
let copy = s.doc().unwrap().nodes().find(|n| n.source == Some(layer)).unwrap().id;
|
||||
run(&mut s, "node.set", json!({"id": copy, "props": {"opacity": 0.25}}));
|
||||
let before = s.doc().unwrap().clone();
|
||||
let view = View { origin: (0.0, 0.0), scale: 1.0, width: 312, height: 112 };
|
||||
let expected = render_view(&before, s.page().unwrap(), &view, &Options::default()).unwrap();
|
||||
let group = outlined(&mut s, layer);
|
||||
let doc = s.doc().unwrap();
|
||||
let actual = render_view(doc, s.page().unwrap(), &view, &Options::default()).unwrap();
|
||||
same_pixels(actual.data(), expected.data());
|
||||
let instance_group = doc.node(copy).unwrap();
|
||||
assert!(matches!(instance_group.kind, NodeKind::Group));
|
||||
assert_eq!(instance_group.source, Some(group));
|
||||
assert_eq!(instance_group.opacity, 0.25);
|
||||
assert!(instance_group.overrides.contains("opacity"));
|
||||
assert!(s.undo().unwrap());
|
||||
assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), serde_json::to_value(&before).unwrap());
|
||||
assert!(s.redo().unwrap());
|
||||
let actual = render_view(s.doc().unwrap(), s.page().unwrap(), &view, &Options::default()).unwrap();
|
||||
same_pixels(actual.data(), expected.data());
|
||||
}
|
||||
@@ -265,6 +265,37 @@ fn hangul_comes_from_a_korean_font_when_one_is_installed() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thai_comes_from_a_thai_font_when_one_is_installed() {
|
||||
// The default (Latin) face has no Thai. With a Thai face (Noto Sans Thai from craft-fonts or
|
||||
// Linux, Leelawadee UI or Tahoma on Windows, Thonburi on macOS) the fallback draws every Thai
|
||||
// cluster, marks included, instead of the missing-glyph box. Skips on a machine without one.
|
||||
// "ภาษาไทย" (the Thai language) and "น้ำ" (water: a tone mark over SARA AM).
|
||||
let s = "Ab \u{0E20}\u{0E32}\u{0E29}\u{0E32}\u{0E44}\u{0E17}\u{0E22} \u{0E19}\u{0E49}\u{0E33}";
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let is_thai = |c: &char| ('\u{0E00}'..='\u{0E7F}').contains(c);
|
||||
let draws_all = |f: &Face| chars.iter().filter(|c| is_thai(c)).all(|c| f.has_char(*c));
|
||||
let Some(f) = face() else { return };
|
||||
if draws_all(&f) {
|
||||
return;
|
||||
}
|
||||
let thai = ["Noto Sans Thai", "Noto Sans Thai UI", "Leelawadee UI", "Thonburi", "Tahoma"];
|
||||
if !thai.iter().any(|t| find_face(t, "Regular").is_some_and(|t| draws_all(&t))) {
|
||||
return;
|
||||
}
|
||||
let l = layout(&text(s, TextAutoResize::WidthAndHeight), None, None);
|
||||
let glyphs: Vec<_> = l.glyphs.iter().filter(|g| chars.get(g.char_start).is_some_and(is_thai)).collect();
|
||||
assert!(!glyphs.is_empty());
|
||||
for g in &glyphs {
|
||||
assert!(g.gid != 0, "char {}: missing glyph in {} {}", g.char_start, g.face.family, g.face.style);
|
||||
let cluster = chars.get(g.char_start..g.char_end).unwrap_or(&[]);
|
||||
assert!(cluster.iter().all(|c| g.face.has_char(*c)), "chars {}..{}: drawn by {}, which lacks some", g.char_start, g.char_end, g.face.family);
|
||||
}
|
||||
let drawn: std::collections::BTreeSet<usize> = glyphs.iter().flat_map(|g| g.char_start..g.char_end).collect();
|
||||
let want: std::collections::BTreeSet<usize> = (3..chars.len()).filter(|i| chars[*i] != ' ').collect();
|
||||
assert_eq!(drawn, want, "every Thai char is in a drawn cluster");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_come_from_a_symbol_font_when_one_is_installed() {
|
||||
// ⌖ (U+2316) and ⤒ (U+2912) are in no Latin text face. With a symbol font that has them (Apple
|
||||
|
||||
@@ -408,7 +408,7 @@ fn names(f: &skrifa::FontRef) -> (Option<String>, Option<String>) {
|
||||
/// Families tried, in order, for characters a text's own font lacks. The symbol fonts come last:
|
||||
/// Apple Symbols (macOS), Segoe UI Symbol (Windows) and the Noto symbol and math fonts (Linux), for
|
||||
/// signs such as ⌖ and ⤒ that no text face has.
|
||||
const FALLBACK_FAMILIES: [&str; 24] = [
|
||||
const FALLBACK_FAMILIES: [&str; 29] = [
|
||||
"Inter",
|
||||
"Helvetica Neue",
|
||||
"Arial",
|
||||
@@ -427,6 +427,11 @@ const FALLBACK_FAMILIES: [&str; 24] = [
|
||||
"Noto Sans CJK KR",
|
||||
"Noto Sans KR",
|
||||
"Noto Sans Arabic",
|
||||
"Noto Sans Thai",
|
||||
"Noto Sans Thai UI",
|
||||
"Leelawadee UI",
|
||||
"Thonburi",
|
||||
"Tahoma",
|
||||
"Apple Symbols",
|
||||
"Segoe UI Symbol",
|
||||
"Noto Sans Symbols",
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -308,7 +308,8 @@ pub fn install_fonts(ctx: &Context, system: bool, cjk: u8) {
|
||||
brand.extend(fonts.families.get(&FontFamily::Name("semibold".into())).cloned().unwrap_or_default());
|
||||
fonts.families.insert(FontFamily::Name("brand".into()), brand);
|
||||
// CJK and other scripts from craft-fonts as fallbacks (Noto Sans CJK only with FIGCRAFT_EMBED_CJK).
|
||||
const BUILT_IN: [&str; 6] = ["Inter", "BIZ UDPGothic", "Noto Sans CJK SC", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans Georgian"];
|
||||
const BUILT_IN: [&str; 7] =
|
||||
["Inter", "BIZ UDPGothic", "Noto Sans CJK SC", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans Georgian", "Noto Sans Thai"];
|
||||
let lang = crate::i18n::current().map_or("en", |l| l.code);
|
||||
// A Chinese UI draws Han in a Chinese face: the built-in Japanese one goes after the system faces.
|
||||
let late = |family: &str| family == "BIZ UDPGothic" && crate::cjk::language(lang) == crate::cjk::HAN;
|
||||
@@ -524,4 +525,26 @@ mod tests {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_font_draws_thai_with_the_built_in_thai_face() {
|
||||
// The web build has no system fonts: Thai layer names, frame labels and field text are drawn
|
||||
// by craft-fonts' Noto Sans Thai, a built-in fallback, when the build embeds it.
|
||||
if !figcraft_text::CRAFT_FONTS.iter().any(|f| f.family == "Noto Sans Thai") {
|
||||
eprintln!("built without craft-fonts' Noto Sans Thai; skipping");
|
||||
return;
|
||||
}
|
||||
let ctx = Context::default();
|
||||
install_fonts(&ctx, false, 0);
|
||||
let input =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(100.0, 100.0))), ..Default::default() };
|
||||
ctx.run_ui(input, |_| {}).textures_delta.clear();
|
||||
// "ภาษาไทย" (the Thai language) and "น้ำ" (water: a tone mark over SARA AM).
|
||||
let thai = "\u{0E20}\u{0E32}\u{0E29}\u{0E32}\u{0E44}\u{0E17}\u{0E22} \u{0E19}\u{0E49}\u{0E33}";
|
||||
ctx.fonts_mut(|f| {
|
||||
for font in [regular(11.0), semibold(11.0)] {
|
||||
assert!(f.has_glyphs(&font, thai), "UI font draws {thai:?}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use egui::{Context, RawInput, Theme};
|
||||
use figcraft_engine::{Prefs, Session};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{FigApp, NoServices, theme::Tokens};
|
||||
use crate::{FigApp, NoServices, Services, theme::Tokens};
|
||||
|
||||
fn frame(ctx: &Context, app: &mut FigApp, system: Option<Theme>) {
|
||||
let mut output = ctx.run_ui(RawInput { system_theme: system, ..Default::default() }, |ui| app.ui(ui));
|
||||
@@ -84,6 +84,47 @@ fn unavailable_system_theme_uses_the_existing_light_fallback() {
|
||||
assert_eq!(Tokens::get(&ctx), Tokens::light());
|
||||
}
|
||||
|
||||
/// Services whose colour scheme answer stands in for the platform's (winit gives no theme on Linux
|
||||
/// and the BSDs, where the freedesktop colour scheme takes over).
|
||||
struct Scheme(Option<bool>);
|
||||
impl Services for Scheme {
|
||||
fn system_dark(&self) -> Option<bool> {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_system_theme_the_colour_scheme_services_decide() {
|
||||
for (dark, expected) in [(Some(true), Tokens::dark()), (Some(false), Tokens::light())] {
|
||||
let ctx = Context::default();
|
||||
let mut app = FigApp::new(Session::empty(), Box::new(Scheme(dark)));
|
||||
app.session.execute("view.themeSystem", &json!({})).unwrap();
|
||||
frame(&ctx, &mut app, None);
|
||||
assert_eq!(Tokens::get(&ctx), expected);
|
||||
assert_eq!(app.ui.dark, expected.dark);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_os_theme_wins_over_the_colour_scheme_services() {
|
||||
for (os, scheme) in [(Theme::Light, Some(true)), (Theme::Dark, Some(false))] {
|
||||
let ctx = Context::default();
|
||||
let mut app = FigApp::new(Session::empty(), Box::new(Scheme(scheme)));
|
||||
app.session.execute("view.themeSystem", &json!({})).unwrap();
|
||||
frame(&ctx, &mut app, Some(os));
|
||||
assert_eq!(Tokens::get(&ctx).dark, os == Theme::Dark);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_themes_ignore_the_colour_scheme_services() {
|
||||
let ctx = Context::default();
|
||||
let mut app = FigApp::new(Session::empty(), Box::new(Scheme(Some(false))));
|
||||
app.session.execute("view.themeDark", &json!({})).unwrap();
|
||||
frame(&ctx, &mut app, None);
|
||||
assert_eq!(Tokens::get(&ctx), Tokens::dark());
|
||||
}
|
||||
|
||||
/// Checks that the test graph enables egui's `default_fonts` and `monochrome_emoji_fonts`
|
||||
/// features by asserting both a Latin and an emoji glyph are available.
|
||||
#[test]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user