Compare commits

...
Author SHA1 Message Date
bflatastic c3a21a6ab3 Merge fix/765 into batch 17 2026-10-18 12:36:19 +07:00
bflatastic 976bbb84ef Merge fix/783 into batch 17 2026-10-18 12:36:18 +07:00
bflatastic 78fd30ab5f Merge fix/772 into batch 17 2026-10-18 12:36:18 +07:00
bflatastic 4458eb8802 Merge fix/776 into batch 17 2026-10-18 12:36:17 +07:00
bflatastic d1619f57bd Merge fix/771 into batch 17 2026-10-18 12:36:16 +07:00
bflatastic eac260f160 Merge fix/770 into batch 17 2026-10-18 12:36:15 +07:00
bflatastic a819e0a188 #776: Edit ▸ Delete from the menu bar removes the open interaction too (the guard moves to menus::invoke, which the Delete binding also reaches); has_selected_interaction goes 2026-10-18 12:28:27 +07:00
BFlat 3a9610d480 Merge pull request #785 from storytold/community/batch-16
Community batch 16: canvas label rename, View toggles kept, missing fonts, macOS glyphs (4 PRs) + rename selects the name
2026-10-18 12:19:46 +07:00
bflatastic 104b80cd58 Merge fix/758 into batch 16 (#758's latest head) 2026-10-18 12:06:14 +07:00
bflatastic 55a0712b11 #758: leave the progress docs to the maintainer 2026-10-18 12:06:04 +07:00
bflatastic bec13a4d07 Merge #758's latest head (window only after a file opens; waits for the font scan)
# Conflicts:
#	crates/ui-egui/src/missing_fonts.rs
2026-10-18 11:57:47 +07:00
bflatastic f90becadab #765: search results use Home's own Open files rows and Recent files section (the full list stays known, so pictures and the selection survive a search); search_grid, SearchHit, prepare_search and search_file_card go; the field is widgets::text_input and Escape clears it 2026-10-18 11:52:32 +07:00
bflatastic 6a334fa354 #765: drop the progress-doc edits (maintainer adds them) 2026-10-18 11:37:58 +07:00
bflatastic 2a3ea27605 Merge main into #765 2026-10-18 11:37:55 +07:00
bflatastic d654a14720 #783: put the new French rows in their sorted places 2026-10-18 11:37:21 +07:00
bflatastic d828e6379e #783: drop the progress-doc edits (maintainer adds them) 2026-10-18 11:36:06 +07:00
bflatastic e46dda1036 Merge main into #783 2026-10-18 11:35:53 +07:00
bflatastic 1768be2965 #772: leave the presentation backdrop as the file sets it (owner decision) 2026-10-18 11:35:38 +07:00
bflatastic 0f8e29bc9f #772: drop the progress-doc edits (maintainer adds them) 2026-10-18 11:35:34 +07:00
bflatastic 47c2b228a5 Merge main into #772 2026-10-18 11:35:19 +07:00
bflatastic d712f2ffd1 #776: collapse the nested if in select.none (clippy) 2026-10-18 11:34:43 +07:00
bflatastic 9b489a052e #776: drop the evidence log 2026-10-18 11:34:42 +07:00
bflatastic c8e93ddaaa Merge main into #776 2026-10-18 11:34:21 +07:00
bflatastic 52e3fe2967 Merge main into #771 2026-10-18 11:34:09 +07:00
bflatastic 78222e35d2 Merge main into #770 2026-10-18 11:34:04 +07:00
bflatastic 04e04f3275 Merge fix/rename-selects-text into batch 16
# Conflicts:
#	docs/ui-parity.md
2026-10-18 11:17:02 +07:00
bflatastic 67610bbba4 Merge fix/762 into batch 16 2026-10-18 11:16:44 +07:00
bflatastic 401af64bba Merge fix/758 into batch 16 2026-10-18 11:16:43 +07:00
bflatastic 27fedf0aa8 Merge fix/385 into batch 16 2026-10-18 11:16:42 +07:00
bflatastic abd03d9f51 Merge fix/766 into batch 16 2026-10-18 11:16:41 +07:00
bflatastic bcfaa3daef Merge #385's rebased head (the branch carries the reviewed resolution) 2026-10-18 11:15:20 +07:00
RealtaNuaandClaude Opus 5.5 db43dc5a4a M7: missing fonts window only after a file opens; wait for the font scan (#752)
From review:
- The check ran after any file.open/openRecent/import result, so a
  pasted or dropped image, reopening a file that was already open, or
  Clear recent brought the window back. It now runs only for results
  with a `summary` (a file opened, or pages imported), and never in the
  browser.
- Text > Missing fonts... before the startup scan finished listed
  installed fonts as missing. It now says it is checking the installed
  fonts and lists them once that is done
  (`figcraft_text::installed_fonts_known`, also true when system fonts
  are opted out, where checks used to wait forever).
- ROADMAP progress log and file-format-parity status line.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 11:14:39 +07:00
RealtaNuaandClaude Opus 5.5 cf6bd4eeb2 M7: list the fonts a file uses that aren't installed (#752)
Text in a family that isn't installed was drawn in a fallback font
without a word. Now:

- `text.missingFonts` lists the families a document names that no
  installed or built-in font provides: text layers and their styled
  ranges, text styles, and string variables bound to a font family
  (every mode, through aliases), with the number of text layers.
- The desktop app shows a Missing fonts window after the user opens a
  file that has any: once installed fonts are indexed (before that every
  one of them would count as missing) and after any import report is
  closed. Files named on the command line count. Programmatic opens and
  the browser build never open it by themselves. Text > Missing fonts...
  shows it for the file in front; Escape closes it.
- `figcraft-cli info` prints `missing font` lines (`missingFonts` with
  --json).
- file-format-parity: the row claimed REST import warnings that no code
  produced.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 11:14:11 +07:00
sanches e9be17427f M9: French catalog at 100% of the strings (still a draft)
The 91 UI strings, 12 command labels and 2 menu wordings the strings added since the French catalog was drafted left in English are translated in the catalog's conventions (NBSP before : ! and inside guillemets, one/other plurals, 0 singular): starter templates and the Templates tab, the home screen's recents table with its sorting and dates in the French way, dropping files to open or import, the Getting started tips with sharing by code, the Playground's bound-variable chip, smart-selection spacing, outline mode's options, the converting and panel-width commands. Appended to each section; still requires native review.
2026-10-18 05:51:39 +03:00
Yarila 1fe78aec66 M7: respect theme in presenter controls and backdrop 2026-10-18 03:14:53 +05:00
Yarila 1f0d9a9107 M7: show file previews in Home search 2026-10-18 01:49:22 +05:00
Yarila dd980c884f M7: search known files and templates from Home 2026-10-18 01:49:21 +05:00
bflatastic 154baa4467 M7: a rename selects the whole name, so typing replaces it 2026-10-18 01:49:02 +07:00
Natnael-Dev 1e7983a550 docs(evidence): record verification evidence for #767 fix 2026-10-17 21:47:40 +03:00
Natnael-Dev bc5a2b8fa7 fix(proto): delete prototype interaction without deleting component (#767)
When an interaction noodle or interaction editor was selected in the
Prototype tab, pressing Delete or Backspace (or executing edit.delete)
targeted the selected layer/component instead of deleting the prototype
connection.

- Intercept Backspace and Delete in shortcuts::key_event and run_binding
  when an interaction is selected in the Prototype tab to remove the
  interaction using proto.interaction.remove rather than deleting the layer.
- Ensure clicking on empty canvas or outside noodles/handles deselects the
  active interaction noodle/editor.
- Close the interaction editor when navigating away from the Prototype tab.
- Add regression tests in proto_editor_tests verifying interaction deletion
  with Delete/Backspace keeps the component intact, and Escape closes the
  interaction editor before deselecting the component.
2026-10-17 21:45:51 +03:00
NetterSilverandClaude Haiku 5.5 89c5d3dfe6 Keep View toggles such as Rulers in the preferences
Fixes #379

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 18:25:28 +00:00
bflatastic 7fd936c8ba #762: one system fallback helper for scripts and shortcut glyphs, a face added once; the glyph test skips when this machine's fonts lack some 2026-10-18 00:36:36 +07:00
bflatastic cbf6d2df41 #762: drop the progress-doc edit (the maintainer adds those) 2026-10-18 00:33:32 +07:00
bflatastic cc12b8a3ec Merge main into #762 2026-10-18 00:33:13 +07:00
bflatastic 7ee2772da8 #758: ask whether the installed fonts are indexed only while a list waits for them 2026-10-18 00:22:21 +07:00
bflatastic 694c3114e8 #758: drop the progress-doc edits (the maintainer adds those) 2026-10-18 00:22:13 +07:00
bflatastic a7352c1860 Merge main into #758 2026-10-18 00:22:03 +07:00
bflatastic 913634bec8 #385: outline modes and soft-proof stay with their file; one toggle table for the menu's check marks 2026-10-18 00:13:49 +07:00
bflatastic 4a038a5f63 Merge main into #385
# Conflicts:
#	crates/engine/src/lib.rs
2026-10-18 00:10:16 +07:00
RealtaNuaandClaude Opus 5.5 7b9ebaacbb fix(ui): fitting Intensity translations; cut values end in "…", cut labels show on hover (#256)
From review:
- ru and pt-BR "Intensity" were too long for the glass field and ended
  in "…"; they are now "Яркость" and "Brilho", which fit.
- A value too long for its field ends in "…" instead of being clipped,
  so "100000" never shows as "10".
- A label cut short shows whole on hover.
- The every-language test now checks the Intensity label fits whole in
  each catalog (it fails with the old ru translation); the panel test
  checks the label isn't cut and that the field is the 98.5 points the
  language test measures against.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 23:50:52 +07:00
bflatastic 76f0edce08 #766: port the engine test, a section and click-elsewhere UI case, and the control-protocol note 2026-10-17 23:44:41 +07:00
bflatastic a7da760f9c #766: drop the progress-doc edits (the maintainer adds those) 2026-10-17 23:44:41 +07:00
bflatastic f33ffb4367 #766: keep the label rename in layers::Rename like the layers panel 2026-10-17 23:43:58 +07:00
bflatastic e03c2a65f7 #766: check a label before a widget's clickable part on a double-click 2026-10-17 23:43:32 +07:00
bflatastic 43bc7a3032 Merge main into #766 2026-10-17 23:42:55 +07:00
RealtaNuaandClaude Opus 5.5 1c16aeae09 fix(canvas): the Pencil previews its stroke in its own colour and weight (#256)
While drawing, the Pencil previewed its stroke in the theme's text
colour, 2 points wide: white in dark theme, so over a white shape
nothing showed until the mouse was let go. The preview now uses the
colour and weight the Pencil draws with (one constant, PENCIL_STROKE,
black at 2 pt), scaled by the zoom, at least 1 pt. It doesn't show pen
pressure or a brush, which only the finished vector has.

Refs #256 ("The 'draw' tool is not visible when you are drawing. You
can see it after you release the mouse.").

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 23:25:51 +07:00
RealtaNuaandClaude Opus 5.5 a672a6650f fix(ui): glass settings: label the light intensity, keep labels in their fields (#256)
The glass effect's light intensity field had no label, and "Refraction
80%" and "Dispersion 50%" ran into the edge of their fields. The field
is labelled "Intensity" in every catalog. Text labels of number fields
take their measured width instead of 7 points per character, and a
label too long for its field ends in "…" before the value, which is
clipped to its own area. This changes every text-labelled number field:
Latin labels sit a little tighter, CJK labels get their real width.

Refs #256 ("There is one parameter that is not signed in the glass
effect editing options. The inscriptions also go beyond their
borders.").

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 23:23:25 +07:00
RealtaNua cae69a7ae8 Merge remote-tracking branch 'upstream/main' into feat/752-report-missing-fonts
# Conflicts:
#	ROADMAP.md
#	docs/file-format-parity.md
2026-10-17 22:21:25 +07:00
Filip Strajnar 6ce46b3532 M1: double-click a frame's name on the canvas to rename it (#757)
A frame's name above its top edge (or a section's pill) only selected and
dragged it; a double-click there did nothing, since the label lies outside
the frame and hits no layer. With the Move or Scale tool, a double-click on
a label now selects that frame and asks the UI (`canvas.renameLabel`) for a
field over the label with the name selected, as the layers panel and pages
list do. Enter or a click elsewhere renames it (`object.rename`, one undo
step) and Escape leaves the name as it was. Rotated labels work too.

Tests drive the canvas: a double-click on a label opens the field, typing
and Enter rename the frame without moving it, undo restores the name, and
Escape cancels on a rotated frame.
2026-10-17 17:09:51 +02:00
RealtaNuaandClaude Opus 5.5 a3c5025186 M7: missing fonts window only after a file opens; wait for the font scan (#752)
From review:
- The check ran after any file.open/openRecent/import result, so a
  pasted or dropped image, reopening a file that was already open, or
  Clear recent brought the window back. It now runs only for results
  with a `summary` (a file opened, or pages imported), and never in the
  browser.
- Text > Missing fonts... before the startup scan finished listed
  installed fonts as missing. It now says it is checking the installed
  fonts and lists them once that is done
  (`figcraft_text::installed_fonts_known`, also true when system fonts
  are opted out, where checks used to wait forever).
- ROADMAP progress log and file-format-parity status line.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 22:06:03 +07:00
JimKnoxxandClaude Opus 5.5 b84ee179d4 M8: draw the macOS shortcut glyphs the UI font lacks
Menus and tooltips showed boxes for ⌃ ⌥ ↵ ⌫ ⇥ ⇞ ⇟ (e.g. Present ⌥⌘↵):
egui's default face has ⌘ and ⇧ but not these. The desktop app now adds
a system face that has them after the UI fonts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:05:49 +02:00
RealtaNuaandClaude Opus 5.5 825cb1e7af M7: list the fonts a file uses that aren't installed (#752)
Text in a family that isn't installed was drawn in a fallback font
without a word. Now:

- `text.missingFonts` lists the families a document names that no
  installed or built-in font provides: text layers and their styled
  ranges, text styles, and string variables bound to a font family
  (every mode, through aliases), with the number of text layers.
- The desktop app shows a Missing fonts window after the user opens a
  file that has any: once installed fonts are indexed (before that every
  one of them would count as missing) and after any import report is
  closed. Files named on the command line count. Programmatic opens and
  the browser build never open it by themselves. Text > Missing fonts...
  shows it for the file in front; Escape closes it.
- `figcraft-cli info` prints `missing font` lines (`missingFonts` with
  --json).
- file-format-parity: the row claimed REST import warnings that no code
  produced.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 21:49:32 +07:00
NetterSilverandClaude Haiku 5.5 3d90d8edfe Keep View toggles such as Rulers in the preferences
Fixes #379

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:33:28 +02:00
58 changed files with 1639 additions and 124 deletions

No files matched your search

+6
View File
@@ -155,6 +155,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
Some(json!({"id": p, "name": pn.name, "nodes": nodes, "topLevel": pn.children.len(), "frames": frames}))
})
.collect();
let missing = figcraft_engine::fonts::missing(&doc);
let v = json!({
"file": path,
"name": doc.name,
@@ -164,6 +165,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
"variables": doc.variables.variables.len(),
"images": doc.images.len(),
"notes": notes,
"missingFonts": missing.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::<Vec<_>>(),
});
if as_json {
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
@@ -187,6 +189,10 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]);
}
}
for m in &missing {
let layers = if m.layers == 1 { "text layer" } else { "text layers" };
println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family, m.layers);
}
Ok(ExitCode::SUCCESS)
}
+22
View File
@@ -45,3 +45,25 @@ fn headless_runs_resolve_installed_families_unless_opted_out() {
assert_eq!(run_one("face", face.clone(), &[], false)["family"], json!(installed), "text uses the installed family");
assert_ne!(run_one("face", face, &["--no-system-fonts"], false)["family"], json!(installed), "opted out, it falls back");
}
#[test]
fn info_lists_the_fonts_a_file_uses_that_are_missing() {
let d = std::env::temp_dir().join(format!("figcraft-cli-missing-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let (script, file) = (d.join("make.json"), d.join("doc.figcraft"));
let text =
|family: &str| json!({"command": "text.create", "params": {"text": "Hamburgefonstiv", "x": 0, "y": 0, "props": {"fontFamily": family}}});
std::fs::write(&script, json!([text("FigCraft Missing CLI"), text("FigCraft Missing CLI")]).to_string()).unwrap();
let cli = || Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
let made = cli().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
let out = cli().arg("info").arg(&file).arg("--json").output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let v: Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["missingFonts"], json!([{"family": "FigCraft Missing CLI", "layers": 2}]));
let out = cli().arg("info").arg(&file).output().unwrap();
let shown = String::from_utf8_lossy(&out.stdout);
assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}");
}
+3
View File
@@ -161,6 +161,7 @@ fn main() -> eframe::Result {
log::error!("{f}: {e}");
}
}
let opened: Vec<u64> = session.docs.iter().map(|d| d.uid).collect();
// Documents a crashed or killed run left unsaved come back; this run keeps its own copies.
let recovery_root = services::config_dir().map(|d| d.join("Recovery"));
// The preferences first: they hold the online files a recovered document may be one of.
@@ -194,6 +195,8 @@ fn main() -> eframe::Result {
let mut fig = FigApp::new(session, Box::new(svc));
fig.automation_roots = roots;
fig.ui.home = false;
// Files named on the command line are the user's too: their missing fonts are listed.
fig.ui.font_checks = opened;
fig.note_recovered(recovered.copies);
if let Some(link) = &args.join {
figcraft_ui_egui::live::open_invite(&mut fig, link);
+3
View File
@@ -53,3 +53,6 @@ sync_github_name = false
[people.krinry]
display_name = "krinry"
sync_github_name = true
[people.pechnikov2026-star]
sync_github_name = true
+3 -1
View File
@@ -213,6 +213,8 @@ pub static MENU: &[MenuItem] = &[
m!("Text/Case", "Uppercase", "text.caseUpper"),
m!("Text/Case", "Lowercase", "text.caseLower"),
m!("Text/Case", "Title case", "text.caseTitle"),
m!("Text", SEP, ""),
m!("Text", "Missing fonts…", "text.missingFonts"),
// Arrange
m!("Arrange", "Align left", "align.left"),
m!("Arrange", "Align horizontal centers", "align.hcenter"),
@@ -287,7 +289,7 @@ pub fn items() -> impl Iterator<Item = &'static MenuItem> {
}
/// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones).
const OWN: [&str; 2] = ["file.newFromTemplate", "help.gettingStarted"];
const OWN: [&str; 3] = ["file.newFromTemplate", "help.gettingStarted", "text.missingFonts"];
/// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer.
pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
+1
View File
@@ -80,6 +80,7 @@ fn registry() -> &'static Registry {
list.extend(page::specs());
list.extend(view::specs());
list.extend(text::specs());
list.extend(crate::fonts::specs());
list.extend(styles::specs());
list.extend(image::specs());
list.extend(variables::specs());
+65 -37
View File
@@ -19,30 +19,58 @@ fn flag(s: &mut Session, p: &Value, f: fn(&mut ViewState) -> &mut bool) -> Resul
Ok(json!({"on": *slot}))
}
/// One of a view's on/off settings.
type Slot = fn(&mut ViewState) -> &mut bool;
/// The View menu's on/off toggles (Pixel preview, with its scale, has its own command), and
/// whether the preferences keep each: files opened or made later start with the kept ones. Outline
/// modes and the sRGB soft-proof stay with their file, so a new file never opens in one.
const TOGGLES: &[(&str, Slot, bool)] = &[
("view.pixelGrid", |v| &mut v.pixel_grid, true),
("view.layoutGrids", |v| &mut v.layout_grids, true),
("view.rulers", |v| &mut v.rulers, true),
("view.slices", |v| &mut v.show_slices, true),
("view.comments", |v| &mut v.comments, true),
("view.multiplayerCursors", |v| &mut v.multiplayer_cursors, true),
("view.outlines", |v| &mut v.outlines, false),
("view.outlinesSelection", |v| &mut v.outlines_selection, false),
("view.outlinesHiddenLayers", |v| &mut v.outline_hidden, true),
("view.outlinesObjectBounds", |v| &mut v.outline_bounds, true),
("view.maskOutlines", |v| &mut v.mask_outlines, true),
("view.frameOutlines", |v| &mut v.frame_outlines, true),
("view.proofSrgb", |v| &mut v.proof_srgb, false),
("view.snapGeometry", |v| &mut v.snap_geometry, true),
("view.snapObjects", |v| &mut v.snap_objects, true),
("view.snapPixel", |v| &mut v.snap_pixel, true),
];
/// View toggle `id`: the file's view changes, and the preferences remember a kept one.
fn toggle(s: &mut Session, p: &Value, id: &str) -> Result<Value> {
let Some(&(_, f, keep)) = TOGGLES.iter().find(|(k, ..)| *k == id) else { return Err(bad(id, "not a View toggle")) };
let r = flag(s, p, f)?;
if keep && let Some(on) = r.get("on").and_then(Value::as_bool) {
s.prefs.view.insert(id.to_string(), on);
}
Ok(r)
}
/// Set `v`'s View toggles to the ones the preferences keep (the others stay as they are).
pub(crate) fn apply_kept(prefs: &crate::Prefs, v: &mut ViewState) {
for (id, f, _) in TOGGLES.iter().filter(|t| t.2) {
if let Some(on) = prefs.view.get(*id) {
*f(v) = *on;
}
}
}
/// Whether the View menu's toggle `id` is on (the menus show a check mark); `None` for a command
/// that isn't one.
pub fn checked(s: &Session, id: &str) -> Option<bool> {
let v = &s.ds().ok()?.view;
Some(match id {
"view.pixelGrid" => v.pixel_grid,
"view.layoutGrids" => v.layout_grids,
"view.rulers" => v.rulers,
"view.slices" => v.show_slices,
"view.comments" => v.comments,
"view.multiplayerCursors" => v.multiplayer_cursors,
"view.outlines" => v.outlines,
"view.outlinesSelection" => v.outlines_selection,
"view.outlinesHiddenLayers" => v.outline_hidden,
"view.outlinesObjectBounds" => v.outline_bounds,
"view.maskOutlines" => v.mask_outlines,
"view.pixelPreview" => v.pixel_preview,
"view.frameOutlines" => v.frame_outlines,
"view.proofSrgb" => v.proof_srgb,
"view.snapGeometry" => v.snap_geometry,
"view.snapObjects" => v.snap_objects,
"view.snapPixel" => v.snap_pixel,
_ => return None,
})
let mut v = s.ds().ok()?.view.clone();
if id == "view.pixelPreview" {
return Some(v.pixel_preview);
}
TOGGLES.iter().find(|(k, ..)| *k == id).map(|(_, f, _)| *f(&mut v))
}
/// Pixel preview: toggles, or turns on at a resolution of 1x or 2x.
@@ -235,46 +263,46 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("view.nextFrame", "Zoom to next frame", "{}", false, |s, _| zoom_frame(s, 1)),
spec!("view.prevPage", "Previous page", "{}", false, |s, _| page_step(s, -1)),
spec!("view.nextPage", "Next page", "{}", false, |s, _| page_step(s, 1)),
spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.pixel_grid)),
spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| flag(s, p, |v| &mut v.layout_grids)),
spec!("view.rulers", "Rulers", "{on?}", false, |s, p| flag(s, p, |v| &mut v.rulers)),
spec!("view.slices", "Slices", "{on?}", false, |s, p| flag(s, p, |v| &mut v.show_slices)),
spec!("view.comments", "Comments", "{on?}", false, |s, p| flag(s, p, |v| &mut v.comments)),
spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.pixelGrid")),
spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| toggle(s, p, "view.layoutGrids")),
spec!("view.rulers", "Rulers", "{on?}", false, |s, p| toggle(s, p, "view.rulers")),
spec!("view.slices", "Slices", "{on?}", false, |s, p| toggle(s, p, "view.slices")),
spec!("view.comments", "Comments", "{on?}", false, |s, p| toggle(s, p, "view.comments")),
spec!("view.multiplayerCursors", "Multiplayer cursors", "{on?} others' cursors in a live session", false, |s, p| {
flag(s, p, |v| &mut v.multiplayer_cursors)
toggle(s, p, "view.multiplayerCursors")
}),
spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (ten times as far, like Shift)}", false, pan),
spec!("view.outlines", "Outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.outlines)),
spec!("view.outlines", "Outlines", "{on?}", false, |s, p| toggle(s, p, "view.outlines")),
spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| {
flag(s, p, |v| &mut v.outlines_selection)
toggle(s, p, "view.outlinesSelection")
}),
spec!(
"view.outlinesHiddenLayers",
"Include hidden layers in outlines",
"{on?} outline mode (and outlines for the selection) outlines hidden layers too; they can be clicked there",
false,
|s, p| { flag(s, p, |v| &mut v.outline_hidden) }
|s, p| { toggle(s, p, "view.outlinesHiddenLayers") }
),
spec!(
"view.outlinesObjectBounds",
"Include object bounds in outlines",
"{on?} outline mode also draws each layer's box (groups, text, vectors); a click on a box's edge picks its layer",
false,
|s, p| { flag(s, p, |v| &mut v.outline_bounds) }
|s, p| { toggle(s, p, "view.outlinesObjectBounds") }
),
spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.mask_outlines)),
spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| toggle(s, p, "view.maskOutlines")),
spec!("view.pixelPreview", "Pixel preview", "{on?, scale? (1|2: rendered pixels per page pixel; turns it on)}", false, pixel_preview),
spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.frame_outlines)),
spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| toggle(s, p, "view.frameOutlines")),
spec!(
"view.proofSrgb",
"Soft-proof sRGB",
"{on?} mark the colours of a Display P3 file that sRGB screens and files can't show",
false,
|s, p| { flag(s, p, |v| &mut v.proof_srgb) }
|s, p| { toggle(s, p, "view.proofSrgb") }
),
spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_geometry)),
spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_objects)),
spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_pixel)),
spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| toggle(s, p, "view.snapGeometry")),
spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| toggle(s, p, "view.snapObjects")),
spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.snapPixel")),
spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)),
spec!("view.fullscreen", "Full screen", "{on?} the window fills the screen (the desktop app; a no-op headless)", false, |s, p| {
s.request_ui("window.fullscreen", json!({"on": boolean(p, "on")}));
+100
View File
@@ -0,0 +1,100 @@
//! Fonts a document names that this machine doesn't have (#752): text layers, their styled
//! ranges, text styles and the string variables bound to a font family. Text in such a family
//! is drawn in a fallback font, so the user is told which ones are missing.
use std::collections::BTreeMap;
use figcraft_doc::{Document, NodeKind, StyleKind, VariableValue, Variables};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::{Result, Session, spec};
/// A family the document names that no installed or built-in font provides.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Missing {
/// As the document spells it.
pub family: String,
/// Text layers that use it (0: only a text style or a variable names it).
pub layers: usize,
}
/// The families `doc` names that the font database doesn't have, by name. On the desktop the
/// installed fonts are indexed in the background: ask once that's done
/// (`figcraft_text::installed_fonts_known`), or every installed family counts as missing.
pub fn missing(doc: &Document) -> Vec<Missing> {
// Lower-case family → (spelling, layers).
let mut used: BTreeMap<String, (String, usize)> = BTreeMap::new();
let mut add = |family: &str, layer: bool| {
let family = family.trim();
if family.is_empty() {
return;
}
let e = used.entry(family.to_lowercase()).or_insert_with(|| (family.to_string(), 0));
e.1 += usize::from(layer);
};
for n in doc.nodes() {
if let NodeKind::Text(t) = &n.kind {
let mut families: Vec<&str> = t.spans().into_iter().map(|(_, _, st)| st.font_family.as_str()).collect();
families.push(&t.style.font_family);
families.sort_by_key(|f| f.trim().to_lowercase());
families.dedup_by_key(|f| f.trim().to_lowercase());
for f in families {
add(f, true);
}
}
if let Some(var) = n.bound.get("fontFamily") {
for f in string_values(&doc.variables, var) {
add(&f, false);
}
}
}
for st in &doc.styles {
if let StyleKind::Text { style } = &st.kind {
add(&style.font_family, false);
}
if let Some(var) = st.bound.get("fontFamily") {
for f in string_values(&doc.variables, var) {
add(&f, false);
}
}
}
used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect()
}
/// Every string value of variable `id` in any mode, following aliases (at most 32 deep).
fn string_values(vars: &Variables, id: &str) -> Vec<String> {
let mut out = Vec::new();
let mut todo = vec![(id.to_string(), 0)];
while let Some((id, depth)) = todo.pop() {
let Some(var) = vars.get(&id) else { continue };
for v in var.values.values() {
match v {
VariableValue::String(s) => out.push(s.clone()),
VariableValue::Alias(a) if depth < 32 => todo.push((a.clone(), depth + 1)),
_ => {}
}
}
}
out
}
fn missing_fonts(s: &mut Session, _: &Value) -> Result<Value> {
let families: Vec<Value> = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect();
s.request_ui("dialog.missingFonts", json!({}));
Ok(json!({"families": families}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"text.missingFonts",
"Missing fonts",
"{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}",
false,
missing_fonts
)]
}
#[cfg(test)]
#[path = "fonts_tests.rs"]
mod tests;
+126
View File
@@ -0,0 +1,126 @@
use serde_json::json;
use super::*;
use crate::params::id_of;
use figcraft_doc::{Style, StyleRun, TextStyle};
fn text(s: &mut Session, family: &str) -> figcraft_doc::NodeId {
let id = id_of(&s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 0})).unwrap()["id"]).unwrap();
if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind {
t.style.font_family = family.into();
}
id
}
fn families(s: &mut Session) -> Vec<(String, u64)> {
let r = s.execute("text.missingFonts", &json!({})).unwrap();
r["families"].as_array().unwrap().iter().map(|f| (f["family"].as_str().unwrap().to_string(), f["layers"].as_u64().unwrap())).collect()
}
/// An installed family (built in, or found by the system scan), if this machine has any.
fn installed() -> Option<String> {
figcraft_text::scan_system_fonts();
figcraft_text::family_names().into_iter().next()
}
#[test]
fn lists_families_of_layers_ranges_styles_and_variables_once_each() {
let mut s = Session::new();
text(&mut s, "FigCraft Missing A");
let b = text(&mut s, "figcraft missing a");
if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(b).unwrap().kind {
let style = TextStyle { font_family: "FigCraft Missing B".into(), ..t.style.clone() };
t.runs.push(StyleRun { start: 0, end: 3, style });
}
let doc = s.doc_mut().unwrap();
doc.styles.push(Style {
id: "S:1".into(),
name: "Heading".into(),
description: String::new(),
kind: StyleKind::Text { style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() } },
bound: Default::default(),
});
s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Light", "Dark"]})).unwrap();
s.execute("variable.create", &json!({"collection": "Type", "name": "Family", "type": "string", "value": "FigCraft Missing D"})).unwrap();
s.execute("variable.set", &json!({"variable": "Family", "mode": "Dark", "value": "FigCraft Missing H"})).unwrap();
let var = s.doc().unwrap().variables.get("Family").unwrap().id.clone();
s.doc_mut().unwrap().get_mut(b).unwrap().bound.insert("fontFamily".into(), var);
assert_eq!(
families(&mut s),
vec![
("FigCraft Missing A".to_string(), 2),
("FigCraft Missing B".to_string(), 1),
("FigCraft Missing C".to_string(), 0),
("FigCraft Missing D".to_string(), 0),
("FigCraft Missing H".to_string(), 0),
]
);
assert!(s.ui_requests.iter().any(|r| r.kind == "dialog.missingFonts"));
}
#[test]
fn installed_families_are_not_listed() {
let Some(family) = installed() else {
eprintln!("skipped: no fonts on this machine");
return;
};
let mut s = Session::new();
text(&mut s, &family);
text(&mut s, &family.to_uppercase());
text(&mut s, "FigCraft Missing E");
assert_eq!(families(&mut s), vec![("FigCraft Missing E".to_string(), 1)]);
}
#[test]
fn a_family_found_only_by_the_system_scan_is_not_missing() {
figcraft_text::scan_system_fonts();
let embedded: Vec<String> = figcraft_text::CRAFT_FONTS.iter().map(|f| f.family.to_lowercase()).collect();
let Some(family) = figcraft_text::family_names().into_iter().find(|f| !embedded.contains(&f.to_lowercase())) else {
eprintln!("skipped: no system fonts on this machine");
return;
};
let mut s = Session::new();
text(&mut s, &family);
assert!(families(&mut s).is_empty(), "{family} is installed");
}
#[test]
fn blank_families_and_aliases_are_handled() {
let mut s = Session::new();
text(&mut s, " ");
s.execute("variable.createCollection", &json!({"name": "Type"})).unwrap();
s.execute("variable.create", &json!({"collection": "Type", "name": "Base", "type": "string", "value": "FigCraft Missing F"})).unwrap();
let base = s.doc().unwrap().variables.get("Base").unwrap().id.clone();
let t = text(&mut s, "FigCraft Missing G");
let doc = s.doc_mut().unwrap();
// A variable that only aliases another, and one aliasing itself.
let mut alias = doc.variables.get(&base).unwrap().clone();
alias.id = "V:alias".into();
alias.name = "Alias".into();
for v in alias.values.values_mut() {
*v = VariableValue::Alias(base.clone());
}
let mut cycle = alias.clone();
cycle.id = "V:cycle".into();
cycle.name = "Cycle".into();
for v in cycle.values.values_mut() {
*v = VariableValue::Alias("V:cycle".into());
}
doc.variables.variables.push(alias);
doc.variables.variables.push(cycle);
doc.get_mut(t).unwrap().bound.insert("fontFamily".into(), "V:alias".into());
let other = text(&mut s, "FigCraft Missing G");
s.doc_mut().unwrap().get_mut(other).unwrap().bound.insert("fontFamily".into(), "V:cycle".into());
// The bound layer takes the value its alias resolves to (bindings are applied after the next
// command); the cycle resolves to nothing.
let NodeKind::Text(bound) = &s.doc().unwrap().node(t).unwrap().kind else { panic!() };
assert_eq!(bound.style.font_family, "FigCraft Missing F");
assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]);
}
#[test]
fn needs_an_open_document() {
let mut s = Session::empty();
assert!(s.execute("text.missingFonts", &json!({})).is_err());
}
+23 -1
View File
@@ -1,6 +1,7 @@
//! Frame name labels as pointer targets: a press on a label (its area as the canvas drew it,
//! turned with a rotated frame) selects the frame and drags it in one undo step, hovering it
//! highlights the frame, and labels of locked, hidden, deleted or other pages' frames do nothing.
//! highlights the frame, a double-click asks the UI to rename it there, and labels of locked,
//! hidden, deleted or other pages' frames do nothing.
use figcraft_doc::NodeId;
use figcraft_geom::Point;
@@ -90,3 +91,24 @@ fn labels_of_frames_that_cant_be_picked_do_nothing() {
let flat = [Point::new(1.0, 1.0); 4];
assert_eq!(label_at(s.doc().unwrap(), page, &[(f, nan), (f, flat)], Point::new(1.0, 1.0)), None);
}
#[test]
fn double_clicking_a_label_asks_to_rename_its_frame() {
let (mut s, f, q) = setup();
let c = center(&q);
let before = s.doc().unwrap().world_bounds(f).unwrap();
let undo = s.ds().unwrap().history.undo.len();
for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::DoubleClick] {
s.pointer(&ev(kind, c)).unwrap();
}
assert_eq!(s.selection(), vec![f]);
let asked = s.ui_requests.iter().find(|r| r.kind == "canvas.renameLabel").map(|r| id_of(&r.data["id"]));
assert_eq!(asked, Some(Some(f)), "the UI renames the frame at its label");
assert_eq!(s.doc().unwrap().world_bounds(f), Some(before), "not moved");
assert_eq!(s.ds().unwrap().history.undo.len(), undo, "nothing to undo yet");
// Other tools ignore the label.
s.ui_requests.clear();
s.set_tool(crate::tools::ToolId::Hand);
s.pointer(&ev(PointerKind::DoubleClick, c)).unwrap();
assert!(s.ui_requests.iter().all(|r| r.kind != "canvas.renameLabel"));
}
+19
View File
@@ -29,6 +29,7 @@ pub mod distance;
pub mod doc_urls;
pub mod drop;
pub mod file_access;
pub mod fonts;
pub mod guard;
pub mod guides;
pub mod inspect;
@@ -375,6 +376,8 @@ pub struct DocState {
pub guide: Option<(NodeId, usize)>,
/// The selection's rotation origin, when moved or shown (see [`rotation_origin`]; not saved).
pub(crate) rotation_origin: Option<rotation_origin::RotationOrigin>,
/// The View toggles the preferences keep were applied ([`Session::apply_view_prefs`]).
view_from_prefs: bool,
/// The last duplicate while its copies are the selection: Duplicate repeats its offset (see
/// [`cmd::edit::Duplicated`]; not saved).
pub(crate) last_duplicate: Option<cmd::edit::Duplicated>,
@@ -436,6 +439,7 @@ impl DocState {
focus: None,
guide: None,
rotation_origin: None,
view_from_prefs: false,
last_duplicate: None,
};
st.zoom_to_fit();
@@ -664,6 +668,10 @@ pub struct Prefs {
pub home_sort: String,
/// Which way: `"desc"` (newest first, Z to A) or `"asc"`.
pub home_order: String,
/// The View menu's toggles turned on or off (rulers, pixel grid, snapping…), by command id:
/// files opened or made later start with them. Missing: the toggle's default.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub view: BTreeMap<String, bool>,
/// Recently run actions (the Actions palette's Recents), newest first, at most
/// [`MAX_RECENT_ACTIONS`]. Entries are `command:<id>`, `plugin:<plugin>/<command>` or
/// `widget:<plugin>`.
@@ -702,6 +710,7 @@ impl Default for Prefs {
home_views: BTreeMap::new(),
home_sort: "modified".into(),
home_order: "desc".into(),
view: BTreeMap::new(),
recent_actions: Vec::new(),
}
}
@@ -854,6 +863,14 @@ impl Session {
s
}
/// Files new to the session start with the View toggles the preferences keep.
pub fn apply_view_prefs(&mut self) {
for ds in self.docs.iter_mut().filter(|d| !d.view_from_prefs) {
cmd::view::apply_kept(&self.prefs, &mut ds.view);
ds.view_from_prefs = true;
}
}
pub fn ds(&self) -> Result<&DocState> {
self.docs.get(self.active).ok_or(EngineError::NoDocument)
}
@@ -895,6 +912,7 @@ impl Session {
return Err(EngineError::Disabled(id.into(), "leave the live session before reverting to the saved file".into()));
}
branches::sync(self);
self.apply_view_prefs();
if textpreview::COMMANDS.contains(&id) {
// A live preview is no edit: no undo step, normalisation or rollback here.
let r = guard::run(id, || (spec.run)(self, params));
@@ -920,6 +938,7 @@ impl Session {
match &result {
Ok(_) => {
self.after_command(spec, before);
self.apply_view_prefs();
if spec.history && id != "edit.repeat" {
self.last_command = Some((id.to_string(), params.clone()));
}
+19 -2
View File
@@ -661,6 +661,9 @@ fn on_corner_square(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> bool
/// handles: the canvas draws just its outline (the handles still answer just outside it).
pub const SMALL_BOX_PX: f64 = 16.0;
/// The stroke the Pencil draws: its colour and weight (the canvas previews it the same).
pub const PENCIL_STROKE: (figcraft_color::Color, f64) = (figcraft_color::Color::BLACK, 2.0);
/// The shorter and longer sides, in screen pixels, of the box with `corners` (tl, tr, br, bl).
pub fn box_sides_px(corners: &[Point; 4], zoom: f64) -> (f64, f64) {
let [a, b, _, d] = corners;
@@ -1859,6 +1862,20 @@ impl Session {
if self.tool.tool == ToolId::Pen {
return self.pen_finish();
}
// A frame's or section's name label: rename it there (the canvas puts a field over it).
// Labels come before widgets, as on a press; the second press began a move: undo it.
if matches!(self.tool.tool, ToolId::Move | ToolId::Scale)
&& !(ev.shift || ev.cmd || ev.alt)
&& let Some(id) = label_at(&doc, page, &self.tool.labels, ev.p())
{
self.tool.gesture = Gesture::None;
self.cancel_interaction()?;
if sel != [id] {
self.set_selection(vec![id]);
}
self.request_ui("canvas.renameLabel", serde_json::json!({"id": id}));
return Ok(());
}
// Quick clicks on a widget's clickable part are clicks, one by one.
if matches!(self.tool.tool, ToolId::Move | ToolId::Scale)
&& !(ev.shift || ev.cmd || ev.alt)
@@ -2227,8 +2244,8 @@ impl Session {
let mut local = fitted.clone();
local.apply_affine(pw.inverse());
let _ = d;
let mut props =
json!({"fills": [], "strokes": ["#000000"], "strokeWeight": 2, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"});
let (color, weight) = PENCIL_STROKE;
let mut props = json!({"fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"});
// Pen pressure becomes the stroke's width profile (brushes follow it too).
if let Some(points) = pressure_profile(points, pressure) {
props["widthProfile"] = json!(points);
+29
View File
@@ -742,3 +742,32 @@ fn alt_measures_between_layers() {
assert_eq!(measured(&mut s, p, m), want, "{why}");
}
}
#[test]
fn view_toggles_are_kept_for_the_next_launch() {
let mut s = Session::new();
// (command, turned to, ViewState field, derivation)
let rows: Vec<(&str, bool, fn(&ViewState) -> bool, &str)> = vec![
("view.rulers", true, |v| v.rulers, "rulers shown stay shown"),
("view.pixelGrid", false, |v| v.pixel_grid, "a hidden pixel grid stays hidden"),
("view.layoutGrids", false, |v| v.layout_grids, "hidden layout grids stay hidden"),
("view.snapPixel", false, |v| v.snap_pixel, "snapping to the pixel grid off stays off"),
];
for (cmd, on, _, _) in &rows {
ex(&mut s, cmd, json!({"on": on}));
}
// Modes stay with their file: a new one never opens in outline mode or soft-proofing.
for cmd in ["view.outlines", "view.outlinesSelection", "view.proofSrgb"] {
ex(&mut s, cmd, json!({"on": true}));
}
// The app writes its preferences as JSON and reads them back at the next launch.
let saved = serde_json::to_value(&s.prefs).unwrap();
let mut next = Session::empty();
next.prefs = serde_json::from_value(saved).unwrap();
ex(&mut next, "file.new", json!({}));
let view = &next.ds().unwrap().view;
for (cmd, on, get, why) in rows {
assert_eq!(get(view), on, "{cmd}: {why}");
}
assert!(!view.outlines && !view.outlines_selection && !view.proof_srgb, "{view:?}");
}
+15
View File
@@ -852,6 +852,11 @@ pub fn family_names() -> Vec<String> {
db().read().unwrap_or_else(std::sync::PoisonError::into_inner).families()
}
/// The global db has `family` (embedded, loaded or indexed; any case).
pub fn has_family(family: &str) -> bool {
db().read().unwrap_or_else(std::sync::PoisonError::into_inner).has_family(family)
}
/// Style names of a family in the global db.
pub fn family_styles(family: &str) -> Vec<String> {
db().write().unwrap_or_else(std::sync::PoisonError::into_inner).styles(family)
@@ -888,6 +893,16 @@ fn system_fonts_opted_out() -> bool {
std::env::var_os("FIGCRAFT_NO_SYSTEM_FONTS").is_some_and(|v| !v.is_empty() && v != "0")
}
/// Every font text can use is known: the system fonts were indexed, or there are none to index
/// (opted out, or the web). Until then a family the scan would find counts as missing.
pub fn installed_fonts_known() -> bool {
#[cfg(not(target_arch = "wasm32"))]
if !system_fonts_opted_out() {
return system_fonts_scanned();
}
true
}
/// Index the system fonts the first time the font database is used (headless apps: a run that
/// lays out no text never pays for the scan). The desktop app scans in the background instead.
pub fn scan_system_fonts_on_demand() {
+2 -2
View File
@@ -16,8 +16,8 @@ mod onpath;
pub use breaks::grapheme_step;
pub use color::{ColorBitmap, ColorInk, ColorLayer};
pub use fontdb::{
Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, no_coords,
scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned,
Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, has_family,
installed_fonts_known, no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned,
};
pub use layout::{Decoration, Glyph, Line, TextLayout, layout};
pub use onpath::OnPath;
+1
View File
@@ -634,6 +634,7 @@ status = "draft — needs native review"
"Instance swap (no other component)" = "Výměna instance (žádná jiná komponenta)"
"Instances" = "Instance"
"Instant" = "Okamžitě"
"Intensity" = "Intenzita"
"Interaction" = "Interakce"
"Interactions" = "Interakce"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Návrh rozhraní a prototypování. Svobodný nástroj s otevřeným zdrojovým kódem, napsaný od základu v jazyce Rust nezávisle na kódu jiných produktů. Součást sady ArtCraft Crafting Apps."
+1
View File
@@ -619,6 +619,7 @@ status = "draft — needs native review"
"Instance swap (no other component)" = "Instanztausch (keine andere Komponente)"
"Instances" = "Instanzen"
"Instant" = "Sofort"
"Intensity" = "Intensität"
"Interaction" = "Interaktion"
"Interactions" = "Interaktionen"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface-Design und Prototyping. Ein kostenloses Open-Source-Designwerkzeug, als Clean-Room-Entwicklung von Grund auf in Rust geschrieben, Teil der ArtCraft Crafting Apps."
+11
View File
@@ -91,6 +91,7 @@ name = "English"
"All components" = "All components"
"All fonts" = "All fonts"
"All supported properties" = "All supported properties"
"All the fonts this file uses are installed" = "All the fonts this file uses are installed"
"All variables" = "All variables"
"Alpha" = "Alpha"
"Alphabetical" = "Alphabetical"
@@ -197,6 +198,7 @@ name = "English"
"C or ⇧C" = "C or ⇧C"
"CMYK colour" = "CMYK colour"
"Can view" = "Can view"
"Checking the fonts installed on this computer…" = "Checking the fonts installed on this computer…"
"Checking the server…" = "Checking the server…"
"Clear from this list" = "Clear from this list"
"Code or invite link" = "Code or invite link"
@@ -647,6 +649,7 @@ name = "English"
"Instance swap (no other component)" = "Instance swap (no other component)"
"Instances" = "Instances"
"Instant" = "Instant"
"Intensity" = "Intensity"
"Interaction" = "Interaction"
"Interactions" = "Interactions"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps."
@@ -771,6 +774,7 @@ name = "English"
"Mirror angle and length" = "Mirror angle and length"
"Mirroring" = "Mirroring"
"Missing" = "Missing"
"Missing fonts" = "Missing fonts"
"Missing layer" = "Missing layer"
"Missing style" = "Missing style"
"Missing variable" = "Missing variable"
@@ -830,6 +834,7 @@ name = "English"
"No contributor data was built into this copy." = "No contributor data was built into this copy."
"No device" = "No device"
"No fonts match" = "No fonts match"
"No files or templates match your search" = "No files or templates match your search"
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead."
"No layer changes to show." = "No layer changes to show."
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link."
@@ -1175,6 +1180,8 @@ name = "English"
"Search actions, layers and tools" = "Search actions, layers and tools"
"Search assets" = "Search assets"
"Search comments" = "Search comments"
"Search files and templates" = "Search files and templates"
"Search results" = "Search results"
"Search fonts" = "Search fonts"
"Search layers" = "Search layers"
"Search shortcuts" = "Search shortcuts"
@@ -1374,6 +1381,7 @@ name = "English"
"This condition is edited as an expression" = "This condition is edited as an expression"
"This file" = "This file"
"This file has unsaved changes" = "This file has unsaved changes"
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them."
"This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code."
"This frame is new on the branch." = "This frame is new on the branch."
"This frame isn't on the branch." = "This frame isn't on the branch."
@@ -1403,6 +1411,7 @@ name = "English"
"Truncate with an ellipsis" = "Truncate with an ellipsis"
"Try again" = "Try again"
"Try another search or filter." = "Try another search or filter."
"Try another search, or browse templates." = "Try another search, or browse templates."
"Try another word, or look at every kind." = "Try another word, or look at every kind."
"Turn a layer into a component, reuse it, and change every copy at once" = "Turn a layer into a component, reuse it, and change every copy at once"
"Two digits" = "Two digits"
@@ -1593,6 +1602,7 @@ name = "English"
"nobody else here yet" = "nobody else here yet"
"non-default slot settings are not represented and were skipped" = "non-default slot settings are not represented and were skipped"
"now" = "now"
"only in a text style or variable" = "only in a text style or variable"
"or" = "or"
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "point stroke joins were preserved; dashed or variable-width strokes render with the layer join"
"preferred slot component keys cannot be resolved to file nodes and were skipped" = "preferred slot component keys cannot be resolved to file nodes and were skipped"
@@ -1700,6 +1710,7 @@ name = "English"
"{n} replies" = { one = "{n} reply", other = "{n} replies" }
"{n} rows" = { one = "{n} row", other = "{n} rows" }
"{n} styles" = { one = "{n} style", other = "{n} styles" }
"{n} text layers" = { one = "{n} text layer", other = "{n} text layers" }
"{n} variables" = { one = "{n} variable", other = "{n} variables" }
"{n} weeks ago" = { one = "{n} week ago", other = "{n} weeks ago" }
"{n} years ago" = { one = "{n} year ago", other = "{n} years ago" }
+12
View File
@@ -93,6 +93,7 @@ status = "draft — needs native review"
"All comments" = "Todos los comentarios"
"All components" = "Todos los componentes"
"All fonts" = "Todas las fuentes"
"All the fonts this file uses are installed" = "Todas las fuentes que usa este archivo están instaladas"
"All supported properties" = "Todas las propiedades compatibles"
"All variables" = "Todas las variables"
"Alpha" = "Alfa"
@@ -200,6 +201,7 @@ status = "draft — needs native review"
"C or ⇧C" = "C o ⇧C"
"CMYK colour" = "Color CMYK"
"Can view" = "Puede ver"
"Checking the fonts installed on this computer…" = "Comprobando las fuentes instaladas en este equipo…"
"Checking the server…" = "Comprobando el servidor…"
"Clear from this list" = "Borrar de esta lista"
"Code or invite link" = "Código o enlace de invitación"
@@ -648,6 +650,7 @@ status = "draft — needs native review"
"Instance swap (no other component)" = "Intercambio de instancia (no hay otro componente)"
"Instances" = "Instancias"
"Instant" = "Instantánea"
"Intensity" = "Intensidad"
"Interaction" = "Interacción"
"Interactions" = "Interacciones"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Diseño de interfaces y prototipos. Una herramienta de diseño gratuita y de código abierto, de implementación independiente (clean-room), escrita desde cero en Rust, parte de las Crafting Apps de ArtCraft."
@@ -774,6 +777,7 @@ status = "draft — needs native review"
"Missing" = "No encontrado"
"Missing layer" = "Capa no encontrada"
"Missing style" = "Estilo no encontrado"
"Missing fonts" = "Fuentes que faltan"
"Missing variable" = "Variable no encontrada"
"Miter" = "Inglete"
"Miter angle" = "Ángulo de inglete"
@@ -831,6 +835,7 @@ status = "draft — needs native review"
"No contributor data was built into this copy." = "Esta copia no incluye datos de colaboradores."
"No device" = "Sin dispositivo"
"No fonts match" = "Ninguna fuente coincide"
"No files or templates match your search" = "Ningún archivo ni plantilla coincide con tu búsqueda"
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Aquí no hay código para unirse (el servidor es anterior a los códigos, o esta dirección no cabe en uno): comparte el enlace."
"No layer changes to show." = "No hay cambios de capas que mostrar."
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Aún no hay bibliotecas. Añade un archivo .figcraft publicado como biblioteca o pega el enlace de una biblioteca de equipo."
@@ -1176,6 +1181,8 @@ status = "draft — needs native review"
"Search actions, layers and tools" = "Buscar acciones, capas y herramientas"
"Search assets" = "Buscar recursos"
"Search comments" = "Buscar comentarios"
"Search files and templates" = "Buscar archivos y plantillas"
"Search results" = "Resultados de búsqueda"
"Search fonts" = "Buscar fuentes"
"Search layers" = "Buscar capas"
"Search shortcuts" = "Buscar atajos"
@@ -1375,6 +1382,7 @@ status = "draft — needs native review"
"This condition is edited as an expression" = "Esta condición se edita como expresión"
"This file" = "Este archivo"
"This file has unsaved changes" = "Este archivo tiene cambios sin guardar"
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este archivo usa fuentes que no están instaladas en este equipo. Su texto se dibuja con una fuente de reserva hasta que las instales."
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Este archivo está en {server}, abierto solo para ti. Compártelo para dejar entrar a otros con un código nuevo."
"This frame is new on the branch." = "Este marco es nuevo en la rama."
"This frame isn't on the branch." = "Este marco no está en la rama."
@@ -1404,6 +1412,7 @@ status = "draft — needs native review"
"Truncate with an ellipsis" = "Truncar con puntos suspensivos"
"Try again" = "Reintentar"
"Try another search or filter." = "Prueba otra búsqueda u otro filtro."
"Try another search, or browse templates." = "Prueba otra búsqueda o explora las plantillas."
"Try another word, or look at every kind." = "Prueba con otra palabra o mira todos los tipos."
"Turn a layer into a component, reuse it, and change every copy at once" = "Convierte una capa en componente, reutilízalo y cambia todas las copias a la vez"
"Two digits" = "Dos dígitos"
@@ -1594,6 +1603,7 @@ status = "draft — needs native review"
"nobody else here yet" = "aún no hay nadie más"
"non-default slot settings are not represented and were skipped" = "los ajustes de ranura no predeterminados no se representan y se omitieron"
"now" = "ahora"
"only in a text style or variable" = "solo en un estilo de texto o una variable"
"or" = "o"
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "se conservaron las uniones de trazo de los puntos; los trazos discontinuos o de ancho variable se dibujan con la unión de la capa"
"preferred slot component keys cannot be resolved to file nodes and were skipped" = "las claves de componentes preferidos de las ranuras no corresponden a capas del archivo y se omitieron"
@@ -1701,6 +1711,7 @@ status = "draft — needs native review"
"{n} replies" = { one = "{n} respuesta", other = "{n} respuestas" }
"{n} rows" = { one = "{n} fila", other = "{n} filas" }
"{n} styles" = { one = "{n} estilo", other = "{n} estilos" }
"{n} text layers" = { one = "{n} capa de texto", other = "{n} capas de texto" }
"{n} variables" = { one = "{n} variable", other = "{n} variables" }
"{n} weeks ago" = { one = "hace {n} semana", other = "hace {n} semanas" }
"{n} years ago" = { one = "hace {n} año", other = "hace {n} años" }
@@ -2126,6 +2137,7 @@ status = "draft — needs native review"
"text.listOutdent" = "Reducir sangría de la lista"
"text.listStyle" = "Estilo de lista"
"text.maxLines" = "Máximo de líneas"
"text.missingFonts" = "Fuentes que faltan"
"text.numberList" = "Lista numerada"
"text.onPath" = "Texto en trazado"
"text.paragraphIndent" = "Sangría de párrafo"
+106
View File
@@ -19,7 +19,10 @@ status = "draft — needs native review"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Une collection de ce fichier avec ces modes et ces variables : remplacez leurs valeurs par mode et ajoutez vos propres variables"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Un fichier .figcraft portant ce nom existe déjà. Choisissez son nom complet pour confirmer le remplacement."
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Un calque seul s’aligne sur son cadre : sélectionnez d’autres calques, ou un calque dans un cadre"
"A responsive product page: hero, features, figures, a quote and a footer" = "Une page produit responsive : héros, fonctionnalités, chiffres, une citation et un pied de page"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Un ressort joue jusqu’à ce qu’il se stabilise : sa masse, sa rigidité et son amortissement en fixent la durée"
"A title, an agenda, a big figure, a chart and a closing slide" = "Un titre, un sommaire, un grand chiffre, un graphique et une diapositive de clôture"
"A to Z" = "A à Z"
"About" = "À propos"
"About FigCraft" = "À propos de FigCraft"
"About this server" = "À propos de ce serveur"
@@ -65,6 +68,7 @@ status = "draft — needs native review"
"Add variant with a new value" = "Ajouter une variante avec une nouvelle valeur"
"Added" = "Ajouté"
"Added the library \"{name}\"" = "Bibliothèque « {name} » ajoutée"
"Adding to {name}" = "Ajout dans {name}"
"Adjust" = "Réglages"
"Adjustment presets" = "Préréglages"
"Advanced" = "Avancé"
@@ -90,7 +94,9 @@ status = "draft — needs native review"
"All supported properties" = "Toutes les propriétés prises en charge"
"All variables" = "Toutes les variables"
"Alpha" = "Alpha"
"Alphabetical" = "Alphabétique"
"Alt+drag" = "Alt+glisser"
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Un écran d’analyse sur une grille : chiffres, graphiques tracés en vectoriel et un tableau"
"An invite link that opens FigCraft and joins" = "Un lien d’invitation qui ouvre FigCraft et rejoint la session"
"Anchor" = "Ancre"
"Android" = "Android"
@@ -109,6 +115,8 @@ status = "draft — needs native review"
"Apply variable" = "Appliquer une variable"
"Apply variable to text" = "Appliquer une variable au texte"
"Apply variable to visibility" = "Appliquer une variable à la visibilité"
"Apps" = "Applications"
"Apr" = "avr."
"Arc" = "Arc"
"Arc ratio" = "Rapport de l’arc"
"Arc start" = "Début de l’arc"
@@ -120,6 +128,7 @@ status = "draft — needs native review"
"As typed" = "Tel que saisi"
"Asking the server…" = "Interrogation du serveur…"
"Assets" = "Ressources"
"Aug" = "août"
"Auto" = "Auto"
"Auto (space around)" = "Auto (espace autour)"
"Auto (space between)" = "Auto (espace entre)"
@@ -164,6 +173,7 @@ status = "draft — needs native review"
"Bottom" = "Bas"
"Bottom left radius" = "Rayon inférieur gauche"
"Bottom right radius" = "Rayon inférieur droit"
"Bound to {name}. Click to detach the variable." = "Lié à {name}. Cliquez pour détacher la variable."
"Branch" = "Branche"
"Branch archived" = "Branche archivée"
"Branch merged" = "Branche fusionnée"
@@ -250,6 +260,7 @@ status = "draft — needs native review"
"Color values in this file are in this color space." = "Les valeurs de couleur de ce fichier sont dans cet espace colorimétrique."
"Color variables" = "Variables de couleur"
"Colour" = "Couleur"
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Styles de couleur et de texte, variables Clair et Sombre, boutons, champs et cartes"
"Column" = "Colonne"
"Column gap" = "Espacement des colonnes"
"Column sizes" = "Tailles des colonnes"
@@ -286,6 +297,8 @@ status = "draft — needs native review"
"Contributors" = "Contributeurs"
"Contributors: {people} · Commits: {commits}" = "Contributeurs : {people} · Commits : {commits}"
"Controller" = "Manette"
"Convert to frame" = "Convertir en cadre"
"Convert to section" = "Convertir en section"
"Cool" = "Froid"
"Copied as CSS" = "Copié en CSS"
"Copied as PNG" = "Copié en PNG"
@@ -319,6 +332,7 @@ status = "draft — needs native review"
"Create property…" = "Créer une propriété…"
"Create style" = "Créer un style"
"Create style from the selected text" = "Créer un style à partir du texte sélectionné"
"Created" = "Création"
"Crop" = "Recadrer"
"Crop marks" = "Traits de coupe"
"Ctrl+arrows" = "Ctrl+flèches"
@@ -333,6 +347,9 @@ status = "draft — needs native review"
"Dark" = "Sombre"
"Darken" = "Assombrir"
"Dash" = "Tirets"
"Dashboard" = "Tableau de bord"
"Date created" = "Date de création"
"Dec" = "déc."
"Decoration style" = "Style de décoration"
"Decoration" = "Décoration"
"Deep select" = "Sélection en profondeur"
@@ -359,6 +376,7 @@ status = "draft — needs native review"
"Description" = "Description"
"Description (optional)" = "Description (facultative)"
"Design" = "Design"
"Design system starter" = "Base de système de design"
"Desktop" = "Ordinateur"
"Destination" = "Destination"
"Detach alias" = "Détacher l’alias"
@@ -394,8 +412,12 @@ status = "draft — needs native review"
"Drafts" = "Brouillons"
"Drag the stroke to add a width point" = "Faites glisser le contour pour ajouter un point de largeur"
"Draw" = "Dessin"
"Draw a frame" = "Tracer un cadre"
"Drawing tools" = "Outils de dessin"
"Drop a .figcraft, .fig, .json, .svg or image file." = "Déposez un fichier .figcraft, .fig, .json, .svg ou une image."
"Drop shadow" = "Ombre portée"
"Drop to import" = "Déposer pour importer"
"Drop to open" = "Déposer pour ouvrir"
"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Double licence MIT OR Apache-2.0. Réalisé par l’équipe et la communauté ArtCraft."
"Duo" = "Duo"
"Duplicate" = "Dupliquer"
@@ -412,6 +434,7 @@ status = "draft — needs native review"
"Edit style" = "Modifier le style"
"Edit variable" = "Modifier la variable"
"Edit with the builder" = "Modifier avec l’assistant"
"Edited {when}" = "Modifié {when}"
"Effect settings" = "Réglages de l’effet"
"Effect styles" = "Styles d’effet"
"Effects" = "Effets"
@@ -442,6 +465,7 @@ status = "draft — needs native review"
"Expand" = "Développer"
"Export" = "Exporter"
"Export all" = "Tout exporter"
"Export frames and layers as PNG, JPG, SVG or PDF" = "Exporter les cadres et les calques en PNG, JPG, SVG ou PDF"
"Export page as PNG" = "Exporter la page en PNG"
"Export settings" = "Réglages d’exportation"
"Export variables" = "Exporter les variables"
@@ -461,11 +485,13 @@ status = "draft — needs native review"
"F" = "F"
"Fade" = "Fondu"
"False" = "Faux"
"Feb" = "févr."
"Fetching from the server…" = "Récupération depuis le serveur…"
"FigCraft couldn't open its window: {error}" = "FigCraft n’a pas pu ouvrir sa fenêtre : {error}"
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft ne prend pas encore en charge toutes les fonctionnalités de ces fichiers. Les notes indiquent ce qui a été omis ou modifié dans la copie importée ; le fichier d’origine reste inchangé."
"File" = "Fichier"
"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 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." }
@@ -512,6 +538,7 @@ status = "draft — needs native review"
"Font size" = "Taille de police"
"Font style" = "Style de police"
"Forget on this computer" = "Oublier sur cet ordinateur"
"Four screens of a trail app with components and a prototype flow" = "Quatre écrans d’une application de randonnée avec des composants et un flux de prototype"
"Fractions" = "Fractions"
"Frame" = "Cadre"
"Frame selection" = "Encadrer la sélection"
@@ -525,6 +552,7 @@ status = "draft — needs native review"
"Gap between columns" = "Espacement entre les colonnes"
"Gap between items" = "Espacement entre les éléments"
"Gap between rows" = "Espacement entre les lignes"
"Getting started" = "Premiers pas"
"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Indiquez l’adresse d’un serveur ouvert au public, ou l’adresse d’un serveur avec sa clé (`figcraft-server key` l’affiche)."
"Glass" = "Verre"
"Go to main component" = "Aller au composant principal"
@@ -533,6 +561,7 @@ status = "draft — needs native review"
"Gradient" = "Dégradé"
"Gray" = "Gris"
"Green" = "Vert"
"Greyscale components and two example screens to sketch a flow with" = "Composants en niveaux de gris et deux écrans d’exemple pour esquisser un flux"
"Grid" = "Grille"
"Grid ({size})" = "Grille ({size})"
"Grid styles" = "Styles de grille"
@@ -554,6 +583,8 @@ status = "draft — needs native review"
"Hide contents" = "Masquer le contenu"
"Hide from publishing" = "Exclure de la publication"
"Hide section contents" = "Masquer le contenu de la section"
"Hide these tips (Getting started in the Help menu shows them again)" = "Masquer ces astuces (Premiers pas dans le menu Aide les affiche de nouveau)"
"Hide when publishing" = "Masquer à la publication"
"Highlight layers on hover" = "Mettre en évidence les calques au survol"
"Highlights" = "Hautes lumières"
"Hold Alt" = "Maintenir Alt"
@@ -575,6 +606,8 @@ status = "draft — needs native review"
"Hue" = "Teinte"
"Hug" = "Ajuster"
"Hug contents" = "Ajuster au contenu"
"Icon components on a 24 px grid with keylines and a size preview" = "Composants d’icônes sur une grille de 24 px avec repères et aperçu de la taille"
"Icon set starter" = "Base de jeu d’icônes"
"If it keeps happening, please report it at {link} with this message." = "Si cela se reproduit, signalez-le sur {link} avec ce message."
"If" = "Si"
"If {condition}" = "Si {condition}"
@@ -615,6 +648,7 @@ status = "draft — needs native review"
"Instance swap (no other component)" = "Permutation d’instance (aucun autre composant)"
"Instances" = "Instances"
"Instant" = "Instantané"
"Intensity" = "Intensité"
"Interaction" = "Interaction"
"Interactions" = "Interactions"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Design d’interfaces et prototypage. Un outil de design libre et gratuit, développé en salle blanche (clean-room) et écrit de zéro en Rust, membre des Crafting Apps d’ArtCraft."
@@ -624,6 +658,7 @@ status = "draft — needs native review"
"Invite link" = "Lien d’invitation"
"Invite link copied" = "Lien d’invitation copié"
"It asks permission to:" = "Il demande l’autorisation de :"
"Jan" = "janv."
"Join" = "Rejoindre"
"Join code copied" = "Code d’accès copié"
"Join code or invite link" = "Code d’accès ou lien d’invitation"
@@ -634,6 +669,8 @@ status = "draft — needs native review"
"Joined {file}" = "Connecté à {file}"
"Joining…" = "Connexion…"
"Join|stroke" = "Jointure"
"Jul" = "juil."
"Jun" = "juin"
"Just now" = "À l’instant"
"Justify" = "Justifier"
"Keep appearance" = "Conserver l’apparence"
@@ -643,11 +680,14 @@ status = "draft — needs native review"
"Key/Gamepad" = "Touche/manette"
"Key/Gamepad {key}" = "Touche/manette {key}"
"Keyboard shortcuts" = "Raccourcis clavier"
"Kits" = "Kits"
"Landing page" = "Page de destination"
"Language" = "Langue"
"Lasso" = "Lasso"
"Last" = "Dernier"
"Last change: {note}" = "Dernière modification : {note}"
"Last commit" = "Dernier commit"
"Last modified" = "Dernière modification"
"Last on top" = "Dernier au-dessus"
"Layer blur" = "Flou de calque"
"Layer name" = "Nom du calque"
@@ -686,6 +726,7 @@ status = "draft — needs native review"
"Link" = "Lien"
"Link copied" = "Lien copié"
"Link docs, code or tickets for developers." = "Liez de la documentation, du code ou des tickets pour les développeurs."
"Link frames on the Prototype tab, then present the flow" = "Reliez les cadres dans l’onglet Prototype, puis présentez le flux"
"Link this component to its code component: instances then show your real snippet with their values." = "Reliez ce composant à son composant de code : les instances affichent alors votre véritable extrait avec leurs valeurs."
"List spacing" = "Espacement de liste"
"List style" = "Style de liste"
@@ -703,7 +744,9 @@ status = "draft — needs native review"
"Main" = "Principal"
"Main component deleted" = "Composant principal supprimé"
"Main menu" = "Menu principal"
"Make a component" = "Créer un composant"
"Make slot" = "Créer un emplacement"
"Mar" = "mars"
"Margin" = "Marge"
"Mark all as read" = "Tout marquer comme lu"
"Mark as resolved" = "Marquer comme résolu"
@@ -716,6 +759,7 @@ status = "draft — needs native review"
"Max W" = "L max."
"Max lines" = "Lignes max."
"Maximize" = "Agrandir"
"May" = "mai"
"Measure to the selection" = "Mesurer jusqu’à la sélection"
"Merge into main" = "Fusionner dans le principal"
"Merge regions" = "Fusionner les régions"
@@ -737,6 +781,7 @@ status = "draft — needs native review"
"Mixed" = "Mixte"
"Mixed fonts" = "Polices mixtes"
"Mixed interactions: click + to replace them" = "Interactions mixtes : cliquez sur + pour les remplacer"
"Mobile app" = "Application mobile"
"Mode" = "Mode"
"Model" = "Modèle"
"Models" = "Modèles"
@@ -766,6 +811,7 @@ status = "draft — needs native review"
"Navigate to" = "Naviguer vers"
"Nested instance" = "Instance imbriquée"
"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 ready for dev" = "Prêt pour le dev suivant"
@@ -799,6 +845,7 @@ status = "draft — needs native review"
"No model credits were built into this copy." = "Cette copie ne contient aucun crédit de modèle."
"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 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."
@@ -812,6 +859,7 @@ status = "draft — needs native review"
"No string variables yet. Create them in Local variables." = "Aucune variable de chaîne pour l’instant. Créez-en dans Variables locales."
"No styles yet." = "Aucun style pour l’instant."
"No styles yet. Click + to create one from the selection." = "Aucun style pour l’instant. Cliquez sur + pour en créer un à partir de la sélection."
"No templates match your search" = "Aucun modèle ne correspond à votre recherche"
"No text styles yet. Click + to create one from the selected text." = "Aucun style de texte pour l’instant. Cliquez sur + pour en créer un à partir du texte sélectionné."
"No variables fit here." = "Aucune variable ne convient ici."
"No variables yet. Create them in Local variables." = "Aucune variable pour l’instant. Créez-en dans Variables locales."
@@ -831,6 +879,7 @@ status = "draft — needs native review"
"Nothing here yet. Set a frame's status to list it." = "Rien pour l’instant. Attribuez un statut à un cadre pour le lister ici."
"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Rien en ligne pour l’instant. Utilisez Partager sur un fichier pour le mettre en ligne et il apparaîtra ici."
"Nothing to export" = "Rien à exporter"
"Nov" = "nov."
"Now" = "Maintenant"
"Nudge (Shift: big nudge)" = "Décaler (Maj : grand décalage)"
"Nudge amount" = "Pas de décalage"
@@ -839,13 +888,16 @@ status = "draft — needs native review"
"Number ↓" = "Numéro ↓"
"Numbered list" = "Liste numérotée"
"Object" = "Objet"
"Oct" = "oct."
"Off" = "Non"
"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"
"Oldest first" = "Plus ancien d’abord"
"On click" = "Au clic"
"On drag" = "Au glissement"
"On this page" = "Sur cette page"
"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."
@@ -870,9 +922,13 @@ status = "draft — needs native review"
"Open variables ({count})" = "Ouvrir les variables ({count})"
"Opened as a new document" = "Ouvert comme nouveau document"
"Opened “{name}” in a new tab" = "« {name} » ouvert dans un nouvel onglet"
"Opening the file…" = "Ouverture du fichier…"
"Opening {n} files as new files" = { one = "Ouverture comme nouveau fichier", other = "Ouverture de {n} fichiers comme nouveaux fichiers" }
"Open|comments" = "Ouverts"
"Options" = "Options"
"Orange" = "Orange"
"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 type this under Share ▸ Join with a code" = "Les autres le saisissent dans Partager ▸ Rejoindre avec un code"
@@ -893,6 +949,7 @@ status = "draft — needs native review"
"Page" = "Page"
"Pages" = "Pages"
"Pages added to the document" = "Pages ajoutées au document"
"Pick the Frame tool, then drag on the canvas or choose a device size" = "Choisissez l’outil Cadre, puis faites glisser sur le canevas ou choisissez une taille d’appareil"
"Placed on the current page" = "Placé sur la page actuelle"
"Pages: {pages}" = "Pages : {pages}"
"Paint bucket" = "Pot de peinture"
@@ -930,7 +987,10 @@ status = "draft — needs native review"
"Place a shape or frame (its tool chosen)" = "Placer une forme ou un cadre (outil choisi)"
"Place automatically" = "Placer automatiquement"
"Place image" = "Placer une image"
"Playground" = "Bac à sable"
"Plugin" = "Plugin"
"Plugins" = "Plugins"
"Plugins & widgets" = "Plugins et widgets"
"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Les plugins s’exécutent dans le bac à sable de FigCraft, sans fichiers, réseau ni horloge. N’installez que des plugins de confiance."
"Plus darker" = "Plus foncé"
"Plus lighter" = "Plus clair"
@@ -947,6 +1007,7 @@ status = "draft — needs native review"
"Present or preview" = "Présenter ou prévisualiser"
"Present this flow" = "Présenter ce flux"
"Presentation" = "Présentation"
"Presentation slides" = "Diapositives de présentation"
"Presenting: comments" = "Présentation : commentaires"
"Presenting: cycle the size options" = "Présentation : parcourir les options de taille"
"Presenting: full screen" = "Présentation : plein écran"
@@ -1003,6 +1064,7 @@ status = "draft — needs native review"
"Remove auto layout" = "Retirer la mise en page auto"
"Remove dev resource" = "Retirer la ressource de développement"
"Remove export setting" = "Retirer le réglage d’exportation"
"Remove from recents" = "Retirer des récents"
"Remove from server" = "Retirer du serveur"
"Remove from server…" = "Retirer du serveur…"
"Remove guide" = "Retirer le repère"
@@ -1115,15 +1177,18 @@ status = "draft — needs native review"
"Search layers" = "Rechercher des calques"
"Search plugins" = "Rechercher des plugins"
"Search shortcuts" = "Rechercher des raccourcis"
"Search templates" = "Rechercher des modèles"
"Section" = "Section"
"Section color" = "Couleur de la section"
"Sections" = "Sections"
"See all templates" = "Voir tous les modèles"
"Select a frame and add a starting point to present it as a flow." = "Sélectionnez un cadre et ajoutez un point de départ pour le présenter comme un flux."
"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 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"
"Select layers with this color" = "Sélectionner les calques de cette couleur"
"Select overlapping shapes" = "Sélectionnez des formes qui se chevauchent"
@@ -1136,6 +1201,7 @@ status = "draft — needs native review"
"Send backward" = "Reculer"
"Send report" = "Envoyer le signalement"
"Send to back" = "Mettre à l’arrière-plan"
"Sep" = "sept."
"Server address" = "Adresse du serveur"
"Server address (ws://host:47900/)" = "Adresse du serveur (ws://host:47900/)"
"Server for online sharing" = "Serveur pour le partage en ligne"
@@ -1150,6 +1216,7 @@ status = "draft — needs native review"
"Shape tools" = "Outils de forme"
"Share" = "Partager"
"Share this file" = "Partager ce fichier"
"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"
@@ -1159,6 +1226,7 @@ status = "draft — needs native review"
"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"
"Show" = "Afficher"
"Show all templates" = "Afficher tous les modèles"
"Show as grid" = "Afficher en grille"
"Show as list" = "Afficher en liste"
"Show autosaves" = "Afficher les sauvegardes auto"
@@ -1167,6 +1235,9 @@ status = "draft — needs native review"
"Show device frame" = "Afficher le cadre de l’appareil"
"Show dimensions on objects" = "Afficher les dimensions sur les objets"
"Show hotspot hints" = "Afficher les zones interactives"
"Show in Explorer" = "Afficher dans l’Explorateur"
"Show in Finder" = "Afficher dans le Finder"
"Show in folder" = "Afficher dans le dossier"
"Show only this on the canvas" = "Afficher uniquement ceci sur le canevas"
"Show open" = "Afficher les ouverts"
"Show section contents" = "Afficher le contenu de la section"
@@ -1191,13 +1262,17 @@ status = "draft — needs native review"
"Small caps" = "Petites capitales"
"Smart animate" = "Animation intelligente"
"Snap to pixel grid" = "Aimanter à la grille de pixels"
"Social and slides" = "Réseaux sociaux et diapositives"
"Social media" = "Réseaux sociaux"
"Social post sizes" = "Formats de publications sociales"
"Soft light" = "Lumière douce"
"Solid" = "Continu"
"Some variants have the same property values: give each one a unique combination" = "Certaines variantes ont les mêmes valeurs de propriété : donnez à chacune une combinaison unique"
"Sort" = "Trier"
"Sort by" = "Trier par"
"Sort by date" = "Trier par date"
"Sort by page" = "Trier par page"
"Sort by {column}" = "Trier par {column}"
"Space around" = "Espace autour"
"Space between" = "Espace entre"
"Space drag" = "Espace et glisser"
@@ -1216,9 +1291,11 @@ status = "draft — needs native review"
"Start" = "Début"
"Start ascending from / stop descending at" = "Compter vers le haut depuis / vers le bas jusqu’à"
"Start from a blank canvas" = "Commencer avec un canevas vide"
"Start from a template" = "Commencer avec un modèle"
"Start on path" = "Début sur le tracé"
"Start point" = "Point de départ"
"Start sharing" = "Commencer le partage"
"Start sharing with the Share button, then pass on the code it gives" = "Commencez à partager avec le bouton Partager, puis communiquez le code qu’il donne"
"Starting to share…" = "Démarrage du partage…"
"Status" = "Statut"
"Sticky" = "Collant"
@@ -1267,6 +1344,7 @@ status = "draft — needs native review"
"Tell the server's operator what is wrong with it" = "Indiquez à l’opérateur du serveur ce qui ne va pas"
"Temperature" = "Température"
"Template" = "Modèle"
"Templates" = "Modèles"
"Terms" = "Conditions"
"Text" = "Texte"
"Text align" = "Alignement du texte"
@@ -1276,6 +1354,9 @@ status = "draft — needs native review"
"Texts" = "Textes"
"Texture" = "Texture"
"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Le canevas affiche les couleurs en sRGB ; les fichiers PNG, JPG et WebP exportés sont balisés Display P3."
"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 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} »)"
@@ -1324,6 +1405,8 @@ status = "draft — needs native review"
"Truncate with an ellipsis" = "Tronquer avec des points de suspension"
"Try again" = "Réessayer"
"Try another search or filter." = "Essayez une autre recherche ou un autre filtre."
"Try another word, or look at every kind." = "Essayez un autre mot, ou regardez chaque type."
"Turn a layer into a component, reuse it, and change every copy at once" = "Transformez un calque en composant, réutilisez-le et modifiez toutes les copies d’un coup"
"Two digits" = "Deux chiffres"
"Type" = "Type"
"Type settings" = "Réglages typographiques"
@@ -1362,6 +1445,7 @@ status = "draft — needs native review"
"Use the layer's fill" = "Utiliser le remplissage du calque"
"Used in {files} · {instances}" = "Utilisé dans {files} · {instances}"
"Username" = "Nom d’utilisateur"
"Uses variables: each layer shows them in its own mode" = "Utilise des variables : chaque calque les affiche dans son propre mode"
"Value" = "Valeur"
"Variable" = "Variable"
"Variable collections" = "Collections de variables"
@@ -1400,21 +1484,25 @@ status = "draft — needs native review"
"Web" = "Web"
"WebSocket URL" = "URL WebSocket"
"WebSocket URL copied" = "URL WebSocket copiée"
"Websites" = "Sites web"
"Wedge" = "Coin"
"Weight" = "Graisse"
"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 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."
"What you try here changes this preview and the code below, not the file" = "Ce que vous essayez ici change cet aperçu et le code ci-dessous, pas le fichier"
"While hovering" = "Pendant le survol"
"While pressing" = "Pendant l’appui"
"White" = "Blanc"
"Who runs it, its terms and how long it keeps files" = "Qui le gère, ses conditions et combien de temps il conserve les fichiers"
"Why it changed" = "Pourquoi cela a changé"
"Widget" = "Widget"
"Widgets" = "Widgets"
"Width" = "Largeur"
"Width point" = "Point de largeur"
"Width profile" = "Profil de largeur"
"Wireframe kit" = "Kit de wireframes"
"With the layers' export settings" = "Avec les réglages d’exportation des calques"
"Wrap" = "Renvoi à la ligne"
"Wrap in new section" = "Envelopper dans une nouvelle section"
@@ -1429,6 +1517,7 @@ status = "draft — needs native review"
"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."
"Z" = "Z"
"Z to A" = "Z à A"
"Zoom" = "Zoom"
"Zoom around the pointer" = "Zoomer autour du pointeur"
"Zoom device to fill window" = "Zoomer l’appareil pour remplir la fenêtre"
@@ -1492,6 +1581,7 @@ status = "draft — needs native review"
"invalid internal .fig text path data was ignored" = "les données internes non valides de texte sur tracé du .fig ont été ignorées"
"invalid or oversized vector networks import as outlines" = "les réseaux vectoriels non valides ou trop grands sont importés comme tracés"
"invalid or oversized vector path data was skipped" = "les données de tracé vectoriel non valides ou trop grandes ont été ignorées"
"just now" = "à l’instant"
"key triggers without a supported key were skipped" = "les déclencheurs de touche sans touche prise en charge ont été ignorés"
"key up triggers import as key presses" = "les déclencheurs au relâchement d’une touche sont importés comme des appuis sur la touche"
"layers without a position were placed at their parent's origin" = "les calques sans position ont été placés à l’origine de leur parent"
@@ -1559,9 +1649,11 @@ status = "draft — needs native review"
"very long vector paths were skipped" = "les tracés vectoriels très longs ont été ignorés"
"video actions are not supported and were skipped" = "les actions vidéo ne sont pas prises en charge et ont été ignorées"
"video triggers are not supported and were skipped" = "les déclencheurs vidéo ne sont pas pris en charge et ont été ignorés"
"yesterday" = "hier"
"{instances} in {files}" = "{instances} dans {files}"
"{language}: draft translation, waiting for a native speaker's review." = "{language} : traduction provisoire, en attente de relecture par un locuteur natif."
"{length} bleed" = "fond perdu de {length}"
"{month} {day}, {year}" = "{day} {month} {year}"
"{name}\nClick to apply to the selection" = "{name}\nCliquez pour l’appliquer à la sélection"
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nFaites glisser sur le canevas, ou cliquez pour le placer au centre de la vue.\nMaintenez Alt (Option) en le déposant sur une instance pour la permuter."
"{name} (from {library})" = "{name} (de {library})"
@@ -1726,8 +1818,10 @@ status = "draft — needs native review"
"component.editProperty" = "Modifier la propriété de composant"
"component.exposeInstance" = "Exposer l’instance imbriquée"
"component.goToMain" = "Aller au composant principal"
"component.hideWhenPublishing" = "Masquer à la publication"
"component.list" = "Lister les composants"
"component.makeSlot" = "Créer un emplacement"
"component.playground" = "Bac à sable de composant"
"component.properties" = "Propriétés du composant"
"component.pushChanges" = "Transmettre les modifications au composant principal"
"component.removeSlot" = "Retirer l’emplacement"
@@ -1786,6 +1880,7 @@ status = "draft — needs native review"
"file.exportSvg" = "Exporter la sélection en SVG"
"file.import" = "Importer"
"file.new" = "Nouveau fichier de design"
"file.newFromTemplate" = "Nouveau à partir d’un modèle"
"file.open" = "Ouvrir"
"file.openRecent" = "Ouvrir un fichier récent"
"file.placeImage" = "Placer une image"
@@ -1813,6 +1908,7 @@ status = "draft — needs native review"
"guide.remove" = "Retirer le repère"
"guide.select" = "Sélectionner le repère"
"help.about" = "À propos de FigCraft"
"help.gettingStarted" = "Premiers pas"
"help.page" = "Page d’aide"
"help.shortcuts" = "Raccourcis clavier"
"image.adjust" = "Régler l’image"
@@ -1866,6 +1962,9 @@ status = "draft — needs native review"
"node.reparent" = "Déplacer le calque dans"
"node.set" = "Définir les propriétés"
"object.batchRename" = "Renommer par lot"
"object.convertToFrame" = "Convertir en cadre"
"object.convertToGroup" = "Convertir en groupe"
"object.convertToSection" = "Convertir en section"
"object.flipH" = "Retourner horizontalement"
"object.flipV" = "Retourner verticalement"
"object.frameSelection" = "Encadrer la sélection"
@@ -1986,6 +2085,7 @@ status = "draft — needs native review"
"stroke.set" = "Définir le contour"
"stroke.widthProfile" = "Profil de largeur"
"style.apply" = "Appliquer le style"
"style.bind" = "Lier une variable dans un style"
"style.create" = "Créer un style"
"style.delete" = "Supprimer le style"
"style.detach" = "Détacher le style"
@@ -1994,6 +2094,7 @@ status = "draft — needs native review"
"style.move" = "Déplacer les styles"
"style.renameFolder" = "Renommer le dossier de styles"
"style.update" = "Modifier le style"
"template.list" = "Lister les modèles"
"text.alignCenter" = "Centrer"
"text.alignJustify" = "Justifier"
"text.alignLeft" = "Aligner à gauche"
@@ -2159,8 +2260,11 @@ status = "draft — needs native review"
"view.nextPage" = "Page suivante"
"view.nextRegion" = "Activer 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"
"view.outlinesSelection" = "Contours de la sélection"
"view.pan" = "Faire défiler la vue"
"view.panelWidths" = "Réinitialiser la largeur des panneaux"
"view.pixelGrid" = "Grille de pixels"
"view.pixelPreview" = "Aperçu des pixels"
"view.prevFrame" = "Zoomer sur le cadre précédent"
@@ -2218,6 +2322,8 @@ status = "draft — needs native review"
"view.design" = "Design"
"view.layers" = "Calques"
"view.outlines" = "Afficher les contours"
"view.outlinesHiddenLayers" = "Inclure les calques masqués"
"view.outlinesObjectBounds" = "Inclure les limites des objets"
"view.outlinesSelection" = "Afficher les contours de la sélection"
"view.prototype" = "Prototype"
"view.themeDark" = "Sombre"
+1
View File
@@ -638,6 +638,7 @@ status = "draft — needs native review"
"Instance swap (no other component)" = "Tukar instance (tidak ada component lain)"
"Instances" = "Instance"
"Instant" = "Instan"
"Intensity" = "Intensitas"
"Interaction" = "Interaksi"
"Interactions" = "Interaksi"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Desain antarmuka dan prototyping. Alat desain clean-room gratis dan open-source yang ditulis dari nol dengan Rust, bagian dari ArtCraft Crafting Apps."
+1
View File
@@ -643,6 +643,7 @@ status = "draft — needs native review"
"Instance swap (no other component)" = "インスタンスの入れ替え(他のコンポーネントなし)"
"Instances" = "インスタンス"
"Instant" = "即時"
"Intensity" = "強さ"
"Interaction" = "インタラクション"
"Interactions" = "インタラクション"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "インターフェースデザインとプロトタイピング。Rust でゼロから書かれた、無料でオープンソースのクリーンルーム設計のデザインツール。ArtCraft Crafting Apps の一つ。"
+1
View File
@@ -615,6 +615,7 @@ status = "draft — needs native review"
"Instance swap (no other component)" = "인스턴스 교체(다른 컴포넌트 없음)"
"Instances" = "인스턴스"
"Instant" = "즉시"
"Intensity" = "강도"
"Interaction" = "인터랙션"
"Interactions" = "인터랙션"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "인터페이스 디자인과 프로토타이핑. Rust로 처음부터 작성한 무료 오픈 소스 클린룸 디자인 도구이며 ArtCraft Crafting Apps의 하나."
+1
View File
@@ -614,6 +614,7 @@ status = "draft — needs native review"
"Instance swap (no other component)" = "Troca de instância (nenhum outro componente)"
"Instances" = "Instâncias"
"Instant" = "Instantâneo"
"Intensity" = "Brilho"
"Interaction" = "Interação"
"Interactions" = "Interações"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Design de interfaces e prototipagem. Uma ferramenta de design gratuita e de código aberto, com implementação independente (clean-room), escrita do zero em Rust, parte dos Crafting Apps da ArtCraft."
+5
View File
@@ -635,6 +635,7 @@ name = "Русский"
"Instance swap (no other component)" = "Замена экземпляра (других компонентов нет)"
"Instances" = "Экземпляры"
"Instant" = "Мгновенно"
"Intensity" = "Яркость"
"Interaction" = "Взаимодействие"
"Interactions" = "Взаимодействия"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Дизайн интерфейсов и прототипирование. Бесплатный инструмент с открытым исходным кодом, независимо созданный с нуля на Rust, из семейства ArtCraft Crafting Apps."
@@ -818,6 +819,7 @@ name = "Русский"
"No contributor data was built into this copy." = "В этой сборке нет данных об участниках."
"No device" = "Без устройства"
"No fonts match" = "Подходящих шрифтов нет"
"No files or templates match your search" = "Файлы и шаблоны по запросу не найдены"
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Код подключения недоступен (сервер не поддерживает коды или адрес нельзя включить в код): поделитесь ссылкой."
"No layer changes to show." = "Нет изменений слоёв для показа."
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Библиотек пока нет. Добавьте файл .figcraft, опубликованный как библиотека, или вставьте ссылку на командную библиотеку."
@@ -1156,6 +1158,8 @@ name = "Русский"
"Search actions, layers and tools" = "Поиск действий, слоёв и инструментов"
"Search assets" = "Поиск ресурсов"
"Search comments" = "Поиск комментариев"
"Search files and templates" = "Поиск файлов и шаблонов"
"Search results" = "Результаты поиска"
"Search fonts" = "Поиск шрифтов"
"Search layers" = "Поиск слоёв"
"Search shortcuts" = "Поиск сочетаний клавиш"
@@ -1381,6 +1385,7 @@ name = "Русский"
"Truncate with an ellipsis" = "Обрезать с многоточием"
"Try again" = "Повторить"
"Try another search or filter." = "Попробуйте другой запрос или фильтр."
"Try another search, or browse templates." = "Попробуйте другой запрос или просмотрите шаблоны."
"Try another word, or look at every kind." = "Попробуйте другое слово или посмотрите все категории."
"Turn a layer into a component, reuse it, and change every copy at once" = "Превратите слой в компонент, используйте повторно и изменяйте все копии сразу"
"Two digits" = "Две цифры"
+1
View File
@@ -645,6 +645,7 @@ name = "Svenska"
"Instance swap (no other component)" = "Instansbyte (ingen annan komponent)"
"Instances" = "Instanser"
"Instant" = "Direkt"
"Intensity" = "Intensitet"
"Interaction" = "Interaktion"
"Interactions" = "Interaktioner"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Gränssnittsdesign och prototyper. Ett fritt clean-room-designverktyg med öppen källkod, skrivet från grunden i Rust och en del av ArtCraft Crafting Apps."
+1
View File
@@ -638,6 +638,7 @@ name = "Türkçe"
"Instance swap (no other component)" = "Örnek değiştirme (başka bileşen yok)"
"Instances" = "Örnekler"
"Instant" = "Anında"
"Intensity" = "Yoğunluk"
"Interaction" = "Etkileşim"
"Interactions" = "Etkileşimler"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Arayüz tasarımı ve prototipleme. Rust ile sıfırdan yazılmış, ücretsiz, açık kaynaklı, temiz oda yöntemiyle geliştirilmiş bir tasarım aracı; ArtCraft Crafting Apps ailesinin bir parçası."
+1
View File
@@ -616,6 +616,7 @@ status = "draft — needs native review"
"Instance swap (no other component)" = "实例替换(无其他组件)"
"Instances" = "实例"
"Instant" = "即时"
"Intensity" = "强度"
"Interaction" = "交互"
"Interactions" = "交互"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "界面设计与原型制作。一款以 Rust 从零编写的免费、开源、净室设计工具,属于 ArtCraft Crafting Apps 系列。"
+4 -13
View File
@@ -274,8 +274,9 @@ fn word_at(text: &str, i: usize) -> (usize, usize) {
(a, b)
}
/// Faces the UI adds for scripts its own fonts lack (desktop: from the system), as egui font data.
pub fn script_fallbacks(have: &dyn Fn(char) -> bool) -> Vec<(String, egui::FontData)> {
/// Faces the UI adds for scripts its own fonts lack (desktop: from the system), as egui font data;
/// `taken` as for [`crate::theme::system_fallbacks`].
pub fn script_fallbacks(have: &dyn Fn(char) -> bool, taken: &mut Vec<Arc<figcraft_text::Face>>) -> Vec<(String, egui::FontData)> {
// Hebrew, Arabic letters, Arabic presentation forms (what joined names are drawn with).
const SAMPLES: [char; 3] = ['\u{05D0}', '\u{0628}', '\u{FE91}'];
const FAMILIES: [&str; 11] = [
@@ -291,17 +292,7 @@ pub fn script_fallbacks(have: &dyn Fn(char) -> bool) -> Vec<(String, egui::FontD
"FreeSans",
"Noto Sans",
];
let mut out: Vec<(String, egui::FontData)> = Vec::new();
let mut taken: Vec<Arc<figcraft_text::Face>> = Vec::new();
for c in SAMPLES {
if have(c) || taken.iter().any(|f| f.has_char(c)) {
continue;
}
let Some(face) = figcraft_text::face_with_char(c, &FAMILIES) else { continue };
out.push((format!("system-{}-{}", face.family, face.style), crate::theme::font_data(&face)));
taken.push(face);
}
out
crate::theme::system_fallbacks(SAMPLES, &FAMILIES, have, taken)
}
#[cfg(test)]
+4 -1
View File
@@ -242,6 +242,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
painter.image(tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
app.session.tool.labels = overlays(app, &painter, &t);
crate::frame_labels::rename_field(app, ui);
// The zoom tool's area.
if let (Some(start), Some(p)) = (app.canvas.zoom_drag, ctx.input(|i| i.pointer.hover_pos())) {
let area = Rect::from_two_pos(start, p);
@@ -1128,7 +1129,9 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
if !ov.pen.is_empty() {
let pts: Vec<Pos2> = ov.pen.iter().map(|[x, y]| app.to_screen(*x, *y)).collect();
if app.session.tool.tool == ToolId::Pencil {
painter.add(Shape::line(pts, stroke(t.text, 2.0)));
// The stroke it will make, in its colour and weight (at least a point when zoomed out).
let (color, weight) = figcraft_engine::tools::PENCIL_STROKE;
painter.add(Shape::line(pts, stroke(doc_c32(painter.ctx(), color), ((weight * zoom) as f32).max(1.0))));
} else {
for [x0, y0, x1, y1] in &ov.pen_handles {
bezier_handle(painter, app.to_screen(*x0, *y0), app.to_screen(*x1, *y1), 3.5, Color32::WHITE);
+33
View File
@@ -128,3 +128,36 @@ fn the_pen_draws_the_handles_of_the_point_being_dragged_and_a_curved_band() {
let band = shapes.iter().any(|s| matches!(s, Shape::CubicBezier(c) if c.points[0].distance(a) < 0.5 && c.points[1].distance(at(&u, 140.0, 100.0)) < 0.5 && c.stroke.color == accent));
assert!(band, "a curve leaving along the out handle");
}
/// While drawing, the Pencil shows the stroke it will make, in its colour and weight (#256): in a
/// dark theme the theme's text colour was white, so over a white shape nothing showed until the
/// stroke was let go.
#[test]
fn the_pencil_previews_its_stroke_in_its_colour_and_weight() {
let mut u = Ui::new(Session::new());
u.app.run("tool.set", json!({"tool": "pencil"}));
let (color, weight) = figcraft_engine::tools::PENCIL_STROKE;
let preview = |shapes: &[Shape]| {
shapes.iter().find_map(|s| match s {
Shape::Path(p) if !p.closed && p.points.len() >= 3 && p.stroke.color == egui::epaint::ColorMode::Solid(crate::theme::c32(color)) => {
Some(p.stroke.width)
}
_ => None,
})
};
let at = |u: &Ui, x: f64, y: f64| u.app.to_screen(x, y);
let start = at(&u, 10.0, 10.0);
u.button(start, true);
for i in 1..=6 {
let p = at(&u, 10.0 + 12.0 * f64::from(i), 10.0 + 6.0 * f64::from(i % 2));
u.frame(vec![egui::Event::PointerMoved(p)]);
}
let zoom = u.app.session.ds().unwrap().view.zoom;
let w = preview(&flat(&u.frame_output(vec![]).shapes)).expect("a preview while drawing");
assert!((w - (weight * zoom) as f32).abs() < 0.01, "{w} for {weight} at {zoom}");
// Zoomed far out it stays visible.
u.app.session.ds_mut().unwrap().view.zoom = 0.05;
let p = at(&u, 120.0, 40.0);
let w = preview(&flat(&u.frame_output(vec![egui::Event::PointerMoved(p)]).shapes)).expect("still previewing");
assert!((w - 1.0).abs() < 0.01, "at least a point: {w}");
}
+4 -1
View File
@@ -15,7 +15,7 @@
//! for the frames that follow (all false: released); `gamepad` presses a controller button in the
//! prototype playing (presenter or preview pane)
//! - `ui.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog)
//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, focusRegion?, preview?, presentScroll?, …}`
//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeSearch?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, focusRegion?, preview?, presentScroll?, …}`
//! - `ui.screenshot {path?}` — PNG of the window (needs a visible window), written to `path` or
//! returned as base64 `data`
//! - `ui.render {path?, scale?, ids?}` — PNG of the document (no UI chrome; works without a
@@ -429,6 +429,9 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
if let Some(section) = p.get("homeSection").and_then(Value::as_str).and_then(crate::home::Section::parse) {
app.ui.home_view.section = section;
}
if let Some(query) = p.get("homeSearch").and_then(Value::as_str) {
app.ui.home_view.file_query = query.chars().take(256).collect();
}
if let Some(view) = p.get("homeRecentsView").and_then(Value::as_str).and_then(crate::home_recents::View::parse) {
view.store(&mut app.session.prefs);
}
+3
View File
@@ -151,6 +151,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) {
|| u.find.is_some()
|| u.batch_rename.is_some()
|| u.import_report.is_some()
|| u.missing_fonts.is_some()
|| u.confirm_close
|| u.export_preview
|| u.shortcuts_panel
@@ -164,6 +165,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) {
|| u.find.take().is_some()
|| u.batch_rename.take().is_some()
|| u.import_report.take().is_some()
|| u.missing_fonts.take().is_some()
{
return;
}
@@ -178,6 +180,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
// Dropped files waiting to open: their card, then (once it has shown) the files.
crate::pending_open::pending_opens(app, ctx);
crate::import_report::show(app, ctx);
crate::missing_fonts::show(app, ctx);
let t = Tokens::get(ctx);
if app.ui.about {
let mut open = true;
+76
View File
@@ -473,3 +473,79 @@ fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() {
let (_, _, w, h) = geom(&u, a);
assert_eq!((w, h, undo_steps(&u)), (60.0, 45.0, steps + 1), "40×30 scaled to a width of 60");
}
/// The texts painted in the last frame: what they say, whether they end in an ellipsis, and where
/// they show (within their clip).
fn painted_texts(shapes: &[egui::epaint::ClippedShape]) -> Vec<(String, bool, egui::Rect)> {
fn walk(shape: &egui::Shape, clip: egui::Rect, out: &mut Vec<(String, bool, egui::Rect)>) {
match shape {
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, clip, out)),
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.galley.elided, t.visual_bounding_rect().intersect(clip))),
_ => {}
}
}
let mut out = Vec::new();
shapes.iter().for_each(|c| walk(&c.shape, c.clip_rect, &mut out));
out
}
/// The glass effect's light intensity field is labelled (#256): drawn in the Design panel with the
/// effect's settings open.
#[test]
fn the_glass_light_intensity_field_has_a_label() {
let mut s = Session::new();
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("effect.add", &json!({"type": "glass"})).unwrap();
let mut u = Ui::new(s);
u.ctx.data_mut(|d| d.insert_temp(Id::new(("fx_open", 0usize)), true));
u.frame(vec![]);
let shapes = u.frame_output(vec![]).shapes;
let lead = u.ctx.read_response(Id::new(("fx_gi", 0usize)).with("lead")).expect("the intensity field is shown").rect;
let labels: Vec<(String, bool)> =
painted_texts(&shapes).into_iter().filter(|(_, _, at)| lead.contains(at.center())).map(|(text, cut, _)| (text, cut)).collect();
assert_eq!(labels, [("Intensity".to_string(), false)], "labelled, and not cut short");
let field = u.ctx.read_response(Id::new(("fx_gi", 0usize)).with("field")).expect("its value").rect;
assert_eq!(field.max.x - lead.min.x, GLASS_FIELD_W, "the width the next test measures against");
}
/// A glass settings field's width in the Design panel at its default width.
const GLASS_FIELD_W: f32 = 98.5;
/// The light intensity label this fix adds fits whole beside its value in every language (#256).
/// Other glass labels may end in "…" in some languages (es "Profundidad"); a longer
/// "Intensity" translation fails here.
#[test]
fn the_intensity_label_fits_whole_in_every_language() {
use crate::i18n::{self, Text};
let ctx = egui::Context::default();
let mut out = ctx.run_ui(Default::default(), |ui| {
let width = |s: &str| ui.painter().layout_no_wrap(s.to_string(), crate::theme::regular(11.0), egui::Color32::WHITE).size().x;
for lang in i18n::languages() {
let label = lang.ui.iter().find(|(k, _)| *k == "Intensity").map_or("Intensity", |(_, t)| match t {
Text::Plain(s) | Text::Plural { other: s, .. } => s,
});
let lead = crate::widgets::text_lead_width(ui, label, GLASS_FIELD_W);
assert!(lead >= 12.0 + width(label), "{}: {label:?} would end in \"…\"", lang.code);
}
});
out.textures_delta.clear();
}
#[test]
fn a_label_too_long_for_its_field_ends_in_an_ellipsis_before_the_value() {
let ctx = egui::Context::default();
let mut field = egui::Rect::NOTHING;
let mut out = ctx.run_ui(Default::default(), |ui| {
let lead = crate::widgets::Lead::Text("A label much wider than its field");
let _ = crate::widgets::number_field(ui, Id::new("long"), lead, Some(80.0), "%", 60.0, 1.0);
field = ui.min_rect();
});
out.textures_delta.clear();
let texts = painted_texts(&out.shapes);
let [(_, label_cut, l), (value, value_cut, v)] = texts.as_slice() else { panic!("the label and the value: {texts:?}") };
assert!(*label_cut, "the label ends in an ellipsis");
assert_eq!((value.as_str(), *value_cut), ("80%", false), "the value shows whole");
assert!(field.contains_rect(*l) && field.contains_rect(*v), "both inside the field {field:?}: {l:?} {v:?}");
assert!(l.max.x < v.min.x, "the label stops before the value: {l:?} {v:?}");
}
+78 -1
View File
@@ -1,6 +1,6 @@
//! Frame name labels on the canvas, driven through egui headless: an upright frame's label sits
//! just above its top-left corner, a rotated frame's runs along its turned top edge, and pressing
//! either label selects and drags its frame.
//! either label selects and drags its frame; double-clicking one renames the frame or section there.
use egui::{Event, Pos2, pos2, vec2};
use figcraft_doc::NodeId;
@@ -79,3 +79,80 @@ fn pressing_a_rotated_label_selects_and_drags_the_frame() {
assert!(after.x0 > before.x0 + 1.0 && after.y0 > before.y0 + 1.0, "{before:?} → {after:?}");
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), undo + 1);
}
fn key(k: egui::Key) -> Vec<Event> {
vec![
Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() },
Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() },
]
}
/// Double-click `f`'s label and wait for the field to take the keyboard.
fn double_click_label(u: &mut Ui, f: NodeId) {
let at = mid(&label(u, f));
for _ in 0..2 {
u.button(at, true);
u.button(at, false);
}
u.frame(vec![]);
u.frame(vec![]);
}
fn name(u: &Ui, f: NodeId) -> String {
u.app.session.doc().unwrap().node(f).unwrap().name.clone()
}
#[test]
fn double_clicking_a_label_renames_the_frame_there() {
let (mut u, a, b) = setup();
let before = u.app.session.doc().unwrap().world_bounds(a).unwrap();
double_click_label(&mut u, a);
assert_eq!(u.app.session.selection(), vec![a]);
assert_eq!(u.app.ui.rename_label.map(|r| r.id), Some(a), "a field over the label");
// The name starts selected: typing replaces it, Enter keeps it.
u.frame(vec![Event::Text("Sign in".into())]);
u.frame(key(egui::Key::Enter));
u.frame(vec![]);
assert_eq!(name(&u, a), "Sign in");
assert!(u.app.ui.rename_label.is_none());
// The double-click didn't move the frame or rename the other one.
assert_eq!(u.app.session.doc().unwrap().world_bounds(a).unwrap(), before);
assert_eq!(name(&u, b), "Turned");
// One undo step puts the old name back.
u.app.session.undo().unwrap();
assert_eq!(name(&u, a), "Upright");
}
#[test]
fn escape_leaves_a_label_rename_with_the_old_name() {
let (mut u, _, b) = setup();
double_click_label(&mut u, b);
assert_eq!(u.app.ui.rename_label.map(|r| r.id), Some(b), "rotated labels rename too");
u.frame(vec![Event::Text("Nope".into())]);
u.frame(key(egui::Key::Escape));
u.frame(vec![]);
assert_eq!(name(&u, b), "Turned");
assert!(u.app.ui.rename_label.is_none());
}
#[test]
fn a_sections_label_renames_it_and_a_click_elsewhere_commits() {
let mut s = Session::new();
let a = id(s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 300, "height": 200, "name": "Area"})).unwrap());
s.execute("select.none", &json!({})).unwrap();
let mut u = Ui::new(s);
u.frame(vec![]);
let undo = u.app.session.ds().unwrap().history.undo.len();
double_click_label(&mut u, a);
assert_eq!(u.app.ui.rename_label.map(|r| r.id), Some(a), "a field over the section's label");
assert_eq!(u.app.session.selection(), vec![a]);
u.frame(vec![Event::Text("Zone".into())]);
let at = mid(&label(&u, a));
let away = pos2(at.x + 200.0, at.y + 500.0);
u.button(away, true);
u.button(away, false);
u.frame(vec![]);
assert_eq!(name(&u, a), "Zone", "a click on the canvas commits");
assert!(u.app.ui.rename_label.is_none());
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), undo + 1, "one undo step");
}
+22
View File
@@ -151,6 +151,28 @@ pub fn draw(app: &FigApp, painter: &Painter, t: &Tokens, doc: &Document, id: Nod
Some([area.left_top(), area.right_top(), area.right_bottom(), area.left_bottom()].map(|p| tl + rot * (p - tl)))
}
/// The field over a frame's or section's name label being renamed (`UiState::rename_label`, from a
/// double-click on the label): Enter or a click elsewhere renames the layer, Escape keeps the name.
/// The rename ends when the field lets go of the keyboard or the label is gone.
pub fn rename_field(app: &mut FigApp, ui: &mut egui::Ui) {
let Some(r) = app.ui.rename_label else { return };
let quad = app.session.tool.labels.iter().find(|(i, _)| *i == r.id).map(|(_, q)| q.map(|p| app.to_screen(p.x, p.y)));
let name = app.session.doc().ok().and_then(|d| d.node(r.id)).map(|n| n.name.clone());
let (Some(quad), Some(name)) = (quad, name) else {
app.ui.rename_label = None;
return;
};
let area = Rect::from_points(&quad);
let font = regular(11.0);
let text_w = ui.painter().layout_no_wrap(name.clone(), font.clone(), Color32::WHITE).size().x;
let rect = Rect::from_min_size(pos2(area.min.x, area.max.y - 16.0), vec2((text_w + 24.0).max(area.width()).clamp(80.0, 480.0), 16.0));
let (done, open) = crate::widgets::rename_field(ui, egui::Id::new(("frame_label_rename", r.id)), &name, rect, font, r.start);
app.ui.rename_label = open.then_some(crate::layers::Rename { id: r.id, start: false });
if let Some(new) = done {
app.run("object.rename", serde_json::json!({"ids": [r.id], "name": new}));
}
}
#[cfg(test)]
mod tests {
use super::*;
+103 -22
View File
@@ -51,6 +51,8 @@ impl Section {
#[derive(Default)]
pub struct Home {
pub section: Section,
/// Searches known files and starter templates together from the home sidebar.
pub file_query: String,
/// The Templates search text and kind filter.
pub query: String,
pub kind: Option<Kind>,
@@ -143,7 +145,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
let left = Rect::from_min_size(rect.min, vec2(SIDEBAR, rect.height()));
ui.painter().line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.border));
sidebar(app, ui, &t, left);
if app.ui.home_view.section == Section::Online {
if app.ui.home_view.file_query.trim().is_empty() && app.ui.home_view.section == Section::Online {
// Your online files lay themselves out in the area (and scroll their own list).
let area = Rect::from_min_max(pos2(left.max.x + 40.0, rect.min.y + 24.0), rect.max - vec2(40.0, 24.0));
crate::online::section(app, ui, area, &t);
@@ -155,9 +157,14 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
egui::ScrollArea::vertical().id_salt("home").auto_shrink([false, false]).show(ui, |ui| {
egui::Frame::NONE.inner_margin(egui::Margin::symmetric(40, 24)).show(ui, |ui| {
ui.spacing_mut().item_spacing = vec2(GAP, 0.0);
match app.ui.home_view.section {
Section::Templates => templates(app, ui, &t),
_ => recents(app, ui, &t),
if app.ui.home_view.file_query.trim().is_empty() {
match app.ui.home_view.section {
Section::Templates => templates(app, ui, &t),
_ => recents(app, ui, &t),
}
} else {
search_results(app, ui, &t);
opened_join(app);
}
});
});
@@ -198,10 +205,20 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) {
icons::paint(ui.painter(), Rect::from_min_size(pos2(left.min.x + 16.0, y), vec2(20.0, 20.0)), Icon::Logo, ACCENT);
ui.painter().text(pos2(left.min.x + 44.0, y + 10.0), Align2::LEFT_CENTER, "FigCraft", semibold(14.0), t.text);
y += 48.0;
let search = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, widgets::FIELD_H));
ui.scope_builder(egui::UiBuilder::new().max_rect(search), |ui| {
let response =
widgets::text_input(ui, Id::new("home_file_search"), &mut app.ui.home_view.file_query, search.width(), tr("Search files and templates"));
a11y::text(&response, tr("Search files and templates"), &app.ui.home_view.file_query);
if (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
app.ui.home_view.file_query.clear();
}
});
y += widgets::FIELD_H + 12.0;
for (section, icon, label) in Section::ALL {
let r = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, 32.0));
let resp = ui.interact(r, ui.id().with(("home_section", label)), Sense::click());
let current = app.ui.home_view.section == section;
let current = app.ui.home_view.file_query.trim().is_empty() && app.ui.home_view.section == section;
a11y::tab(&resp, tr(label), current);
if current || resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(5), if current { t.selected_row } else { t.hover });
@@ -211,11 +228,65 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) {
ui.painter().text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, tr(label), regular(12.0), t.text);
if resp.clicked() {
app.ui.home_view.section = section;
app.ui.home_view.file_query.clear();
}
y += 34.0;
}
}
fn file_matches(name: &str, location: &str, query: &str) -> bool {
name.to_lowercase().contains(query) || location.to_lowercase().contains(query)
}
/// One search over known files and starter templates. It never sends a query or owner token to a
/// server; online files are opened through their existing owner-token command only on click.
fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
heading(ui, t, tr("Search results"));
let query = app.ui.home_view.file_query.trim().to_lowercase();
let mut count = open_files(app, ui, t, &query);
count += crate::home_recents::section(app, ui, t, &query);
let online: Vec<_> = app.session.prefs.online_files.iter().filter(|f| file_matches(&f.name, &f.server, &query)).cloned().collect();
let mut open_online = None;
if !online.is_empty() {
subheading(ui, t, tr("Online files"));
for f in &online {
count += 1;
let host = figcraft_engine::online::server_host(&f.server);
let icon = crate::online::mark(f.shared.is_some());
let row = FileRow {
mark: Some((icon, if f.shared.is_some() { t.success } else { t.icon_secondary })),
name: &f.name,
ink: t.text,
detail: host,
detail_size: 11.0,
detail_ink: t.text_secondary,
};
if listed(ui, t, ui.id().with(("search_online", &f.server, &f.file)), 36.0, &row) {
open_online = Some(f.clone());
}
}
}
let templates: Vec<&Template> = TEMPLATES.iter().filter(|template| matches(template, &query, None)).collect();
if !templates.is_empty() {
subheading(ui, t, tr("Templates"));
template_grid(app, ui, t, &templates);
}
if count == 0 && templates.is_empty() {
ui.label(egui::RichText::new(tr("No files or templates match your search")).font(semibold(13.0)).color(t.text));
ui.add_space(6.0);
widgets::label(ui, tr("Try another search, or browse templates."), t.text_secondary);
ui.add_space(16.0);
if widgets::button(ui, tr("Show all templates"), false, true).clicked() {
app.ui.home_view.file_query.clear();
app.ui.home_view.section = Section::Templates;
}
}
if let Some(file) = open_online {
crate::online::open(app, &file);
}
}
/// A ring around a card or row that has the keyboard.
fn focus_ring(ui: &Ui, t: &Tokens, rect: Rect, resp: &Response, radius: u8) {
if resp.has_focus() {
@@ -290,22 +361,7 @@ fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
ui.add_space(28.0);
getting_started(app, ui, t);
}
if !app.session.docs.is_empty() {
subheading(ui, t, tr("Open files"));
let mut switch = None;
for (i, d) in app.session.docs.iter().enumerate() {
let detail = d.path.as_deref().unwrap_or_default();
let row =
FileRow { mark: Some((Icon::Logo, ACCENT)), name: &d.doc.name, ink: t.text, detail, detail_size: 11.0, detail_ink: t.text_secondary };
if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) {
switch = Some(i);
}
}
if let Some(i) = switch {
app.run("doc.switch", json!({"index": i}));
app.ui.home = false;
}
}
open_files(app, ui, t, "");
if crate::home_recents::listed(&app.session.prefs.recent_files).next().is_none() {
// A first run: nothing to reopen yet, so offer something to start from.
subheading(ui, t, tr("Start from a template"));
@@ -317,7 +373,32 @@ fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
}
return;
}
crate::home_recents::section(app, ui, t);
crate::home_recents::section(app, ui, t, "");
}
/// The Open files list: the documents whose name or path has `query` (lower case; all for ""), a
/// row each; choosing one shows its tab. How many are listed.
fn open_files(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize {
let hit = |d: &&figcraft_engine::DocState| file_matches(&d.doc.name, d.path.as_deref().unwrap_or_default(), query);
let count = app.session.docs.iter().filter(hit).count();
if count == 0 {
return 0;
}
subheading(ui, t, tr("Open files"));
let mut switch = None;
for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| hit(d)) {
let detail = d.path.as_deref().unwrap_or_default();
let row =
FileRow { mark: Some((Icon::Logo, ACCENT)), name: &d.doc.name, ink: t.text, detail, detail_size: 11.0, detail_ink: t.text_secondary };
if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) {
switch = Some(i);
}
}
if let Some(i) = switch {
app.run("doc.switch", json!({"index": i}));
app.ui.home = false;
}
count
}
/// The file joined from the home screen (a code, or one of your online files) opened in a tab of
+16 -4
View File
@@ -554,16 +554,27 @@ enum Action {
View(View),
}
/// The Recent files heading, its toolbar and the files as cards or a table.
pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
let paths: Vec<String> = listed(&app.session.prefs.recent_files).take(SHOWN).cloned().collect();
/// The Recent files heading, its toolbar and the files as cards or a table. With a `query` (lower
/// case, from the Home search) only the files not open whose path has it, from every recent file;
/// nothing at all when none has it. How many files are shown.
pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize {
let searching = !query.is_empty();
// A search reaches every recent file; the files shown without one stay known through it.
let paths: Vec<String> = listed(&app.session.prefs.recent_files).take(if searching { usize::MAX } else { SHOWN }).cloned().collect();
let time = ui.input(|i| i.time);
let now = figcraft_engine::cmd::comment::now_secs();
let reduce_motion = app.reduce_motion();
let (view, sort) = (View::of(&app.session.prefs), Sort::of(&app.session.prefs));
let state = &mut app.ui.home_view.recents;
state.refresh(ui.ctx(), &paths, time);
let files = sorted(state.shown(&paths), sort);
let mut files = sorted(state.shown(&paths), sort);
if searching {
let docs = &app.session.docs;
files.retain(|f| f.path.to_lowercase().contains(query) && !docs.iter().any(|d| d.path.as_deref() == Some(f.path.as_str())));
if files.is_empty() {
return 0;
}
}
let with_created = files.iter().any(|f| f.stat.is_some_and(|s| s.created.is_some()));
let mut action = toolbar(ui, t, view, sort, with_created);
ui.add_space(16.0);
@@ -593,6 +604,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
}
None => {}
}
files.len()
}
/// "Recent files", and at the right the sort menu and the grid and list buttons.
+156
View File
@@ -6,6 +6,7 @@
use egui::accesskit::Role;
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, Shape};
use figcraft_engine::Session;
use figcraft_engine::online::OnlineFile;
use figcraft_engine::templates::TEMPLATES;
use serde_json::json;
@@ -184,6 +185,161 @@ fn typing_in_the_search_field_filters() {
assert!(u.app.session.docs.is_empty(), "typing opens nothing");
}
#[test]
fn home_search_finds_known_files_and_opens_an_existing_tab() {
let mut session = Session::new();
session.execute("file.rename", &json!({"name": "North Trail"})).unwrap();
session.docs[0].path = Some("C:/designs/North Trail.figcraft".into());
session.prefs.recent_files =
vec!["C:/designs/North Trail.figcraft".into(), "C:/designs/South Trail.figcraft".into(), "C:/designs/Other.figcraft".into()];
session.prefs.online_files.push(OnlineFile {
server: "wss://share.example/".into(),
file: "trail".into(),
name: "Trail Live".into(),
owner: "test-only-secret".into(),
..OnlineFile::default()
});
let mut u = Ui::new(session);
u.app.ui.home = true;
u.app.ui.home_view.file_query = " TRAIL ".into();
let s = settle(&mut u);
assert!(has_text(&s, "Search results"));
assert!(has_text(&s, "North Trail") && has_text(&s, "South Trail") && has_text(&s, "Trail Live"));
assert!(!has_text(&s, "Other") && !has_text(&s, "test-only-secret"));
assert_eq!(texts(&s).iter().filter(|(name, pos, _)| name == "North Trail" && pos.y > 90.0).count(), 1, "an open file is not repeated in recents");
assert_eq!(u.app.inspect()["homeSearch"], " TRAIL ");
u.app.ui.home_view.file_query = "nothing matches".into();
let s = shapes(&mut u);
assert!(has_text(&s, "No files or templates match your search"));
assert!(!has_text(&s, "Trail Live") && !has_text(&s, "South Trail"));
assert!(!texts(&s).iter().any(|(name, pos, _)| name == "North Trail" && pos.y > 90.0));
u.app.ui.home_view.file_query = "north".into();
let s = shapes(&mut u);
click(&mut u, text_center(&s, "North Trail"));
assert!(!u.app.ui.home, "choosing an open result shows its editor tab");
assert_eq!(u.app.session.docs.len(), 1);
}
#[test]
fn home_search_shows_a_saved_files_preview() {
let dir = std::env::temp_dir().join(format!("figcraft-home-search-preview-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Search preview.figcraft");
let image = image::RgbaImage::from_pixel(64, 32, image::Rgba([30, 140, 210, 255]));
let mut png = Vec::new();
image.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let doc = figcraft_doc::Document::new("Search preview");
std::fs::write(&path, figcraft_format::save_bytes(&doc, Some(&png)).unwrap()).unwrap();
let mut session = Session::empty();
session.prefs.recent_files.push(path.to_string_lossy().into_owned());
let mut u = Ui::new(session);
u.app.ui.home_view.file_query = "Search preview".into();
for _ in 0..400 {
u.frame(vec![]);
if u.app.inspect()["homeRecents"]["files"][0]["picture"] == "picture" {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
let s = shapes(&mut u);
assert!(has_text(&s, "Search preview"));
assert!(s.iter().any(|s| matches!(s, Shape::Mesh(m) if m.texture_id != egui::TextureId::default())), "the saved preview is drawn");
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_dir(dir);
}
#[test]
fn home_search_field_filters_as_typed_and_a_section_click_clears_it() {
let mut u = first_run();
u.app.session.prefs.recent_files = vec!["C:/designs/Glacier.figcraft".into(), "C:/designs/Forest.figcraft".into()];
let s = shapes(&mut u);
click(&mut u, text_center(&s, "Search files and templates"));
u.frame(vec![Event::Text("glacier".into())]);
let s = settle(&mut u);
assert_eq!(u.app.ui.home_view.file_query, "glacier");
assert!(has_text(&s, "Glacier") && !has_text(&s, "Forest"));
click(&mut u, text_center(&s, "Recents"));
assert!(u.app.ui.home_view.file_query.is_empty());
let s = shapes(&mut u);
assert!(has_text(&s, "Forest"));
// Escape in the field clears the search too.
click(&mut u, text_center(&s, "Search files and templates"));
u.frame(vec![Event::Text("forest".into())]);
assert_eq!(u.app.ui.home_view.file_query, "forest");
u.frame(vec![Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
assert!(u.app.ui.home_view.file_query.is_empty(), "Escape clears the search");
}
/// A search keeps what the recent files it doesn't match know: back from it, their pictures and
/// the selection are still there (nothing is read again).
#[test]
fn a_search_keeps_the_other_recent_files_pictures_and_the_selection() {
let dir = std::env::temp_dir().join(format!("figcraft-home-search-cache-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut png = Vec::new();
image::RgbaImage::from_pixel(8, 8, image::Rgba([30, 140, 210, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let paths: Vec<String> = ["Alpine", "Basin"]
.into_iter()
.map(|name| {
let path = dir.join(format!("{name}.figcraft"));
std::fs::write(&path, figcraft_format::save_bytes(&figcraft_doc::Document::new(name), Some(&png)).unwrap()).unwrap();
path.to_string_lossy().into_owned()
})
.collect();
let mut session = Session::empty();
session.prefs.recent_files = paths.clone();
let mut u = Ui::new(session);
let pictures = |u: &mut Ui| -> Vec<serde_json::Value> {
u.app.inspect()["homeRecents"]["files"].as_array().unwrap().iter().map(|f| f["picture"].clone()).collect()
};
for _ in 0..400 {
u.frame(vec![]);
if pictures(&mut u).iter().all(|p| p == "picture") {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(pictures(&mut u).iter().all(|p| p == "picture"), "both pictures are in");
u.app.ui.home_view.recents.selected = Some(paths[1].clone());
u.app.ui.home_view.file_query = "alpine".into();
let s = shapes(&mut u);
assert!(has_text(&s, "Alpine") && !has_text(&s, "Basin"));
u.app.ui.home_view.file_query.clear();
u.frame(vec![]);
assert!(pictures(&mut u).iter().all(|p| p == "picture"), "no picture is read again");
assert_eq!(u.app.ui.home_view.recents.selected.as_deref(), Some(paths[1].as_str()), "the selection stays");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn home_search_includes_templates_and_opens_a_matching_card() {
let mut u = first_run();
u.app.ui.home_view.file_query = "MOBILE".into();
let s = settle(&mut u);
assert!(has_text(&s, "Templates") && has_text(&s, "Mobile app"));
assert!(!has_text(&s, "Dashboard"));
click(&mut u, text_center(&s, "Mobile app"));
assert_eq!(u.app.session.doc().unwrap().name, "Mobile app");
assert!(!u.app.ui.home);
}
#[test]
fn empty_home_search_can_browse_all_templates() {
let mut u = first_run();
u.app.ui.home_view.file_query = "no such design".into();
let s = shapes(&mut u);
assert!(has_text(&s, "No files or templates match your search"));
click(&mut u, text_center(&s, "Show all templates"));
assert!(u.app.ui.home_view.file_query.is_empty());
assert_eq!(u.app.ui.home_view.section, Section::Templates);
assert!(has_text(&settle(&mut u), "Dashboard"));
}
#[test]
fn thumbnails_are_cached_per_theme() {
let mut u = first_run();
+1 -1
View File
@@ -108,7 +108,7 @@ fn escape_closes_the_import_report_like_other_simple_dialogs() {
assert!(u.app.ui.import_report.is_none(), "Escape closes the report");
}
fn shown_texts(shapes: Vec<egui::epaint::ClippedShape>) -> Vec<String> {
pub(crate) fn shown_texts(shapes: Vec<egui::epaint::ClippedShape>) -> Vec<String> {
fn walk(shape: &egui::Shape, out: &mut Vec<String>) {
match shape {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
+25
View File
@@ -170,3 +170,28 @@ fn a_rename_scrolled_out_of_view_goes_on() {
press(&mut u, Key::Enter, Modifiers::NONE);
assert_eq!(name(&u, top), "Far");
}
#[test]
fn a_key_typed_in_the_frame_a_rename_starts_replaces_the_name() {
// The app draws no frame between the double-click's last release (or F2, or File ▸ Rename) and
// the first key, so the name field first shows in the frame that types into it.
let (mut u, a, _, _, c) = setup();
let p = u.row_at(c);
for _ in 0..2 {
u.button(p, true);
u.button(p, false);
}
type_text(&mut u, "Hero");
press(&mut u, Key::Enter, Modifiers::NONE);
assert_eq!(name(&u, c), "Hero", "a double-click rename");
u.app.session.execute("select.set", &json!({"ids": [a]})).unwrap();
u.frame(vec![]);
press(&mut u, Key::F2, Modifiers::NONE);
type_text(&mut u, "Base");
press(&mut u, Key::Enter, Modifiers::NONE);
assert_eq!(name(&u, a), "Base", "an F2 rename");
crate::menus::invoke(&mut u.app, "file.rename");
type_text(&mut u, "Brand kit");
press(&mut u, Key::Enter, Modifiers::NONE);
assert_eq!(u.app.session.doc().unwrap().name, "Brand kit", "File ▸ Rename");
}
+20
View File
@@ -40,6 +40,7 @@ pub mod live;
pub mod live_text;
pub mod measure_overlay;
pub mod menus;
pub mod missing_fonts;
pub mod online;
pub mod pending_open;
pub mod plugins_ui;
@@ -149,6 +150,8 @@ mod menu_aim_tests;
#[cfg(test)]
mod menu_input_tests;
#[cfg(test)]
mod missing_fonts_tests;
#[cfg(test)]
mod online_tests;
#[cfg(test)]
mod open_issues_tests;
@@ -434,6 +437,8 @@ pub struct UiState {
/// The layer being renamed in the layers panel.
pub rename: Option<layers::Rename>,
pub rename_page: Option<(NodeId, String)>,
/// The frame or section whose name label on the canvas is being renamed (a double-click on it).
pub rename_label: Option<layers::Rename>,
pub main_menu: bool,
pub palette: Option<String>,
/// The Actions palette's tab.
@@ -506,6 +511,9 @@ pub struct UiState {
pub pending_open_frames: u8,
/// The latest successful file import's summary and compatibility notes.
pub import_report: Option<import_report::Report>,
/// Files the user opened whose fonts are yet to be checked (by document uid).
pub font_checks: Vec<u64>,
pub missing_fonts: Option<missing_fonts::Shown>,
pub screenshot_requests: Vec<(u64, String)>,
pub frame_ms: f64,
pub render_ms: f64,
@@ -591,6 +599,7 @@ impl Default for UiState {
expanded: BTreeSet::new(),
rename: None,
rename_page: None,
rename_label: None,
main_menu: false,
palette: None,
palette_tab: menus::PaletteTab::All,
@@ -633,6 +642,8 @@ impl Default for UiState {
pending_opens: Vec::new(),
pending_open_frames: 0,
import_report: None,
font_checks: Vec::new(),
missing_fonts: None,
screenshot_requests: Vec::new(),
frame_ms: 0.0,
render_ms: 0.0,
@@ -761,6 +772,7 @@ impl FigApp {
if let Some(result) = &result {
import_report::capture(self, id, &params, result);
}
missing_fonts::check_later(self, id, result.as_ref());
result
}
@@ -875,6 +887,7 @@ impl FigApp {
self.ui.about_tab = dialogs::AboutTab::About;
}
"dialog.preferences" => self.ui.preferences = true,
"dialog.missingFonts" => missing_fonts::request(self),
"panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel,
"palette.actions" => menus::toggle_palette(&mut self.ui),
"panel.design" => self.ui.right_tab = RightTab::Design,
@@ -965,6 +978,9 @@ impl FigApp {
"layers.collapseAll" => layers::collapse_all(self),
"contextMenu" => self.ui.key_menu = true,
"selectArea" => self.start_key_box(),
"canvas.renameLabel" => {
self.ui.rename_label = r.data.get("id").and_then(figcraft_engine::params::id_of).map(|id| layers::Rename { id, start: true });
}
"layers.rename" => {
let ids: Vec<NodeId> =
r.data.get("ids").and_then(Value::as_array).into_iter().flatten().filter_map(figcraft_engine::params::id_of).collect();
@@ -1378,6 +1394,8 @@ impl FigApp {
}
self.background_done(&ctx);
}
// Files that arrived without a command (joined, recovered) take the kept View toggles too.
self.session.apply_view_prefs();
windows::begin(self, &ctx);
let (files, prefs) = (self.session.docs.len(), self.session.prefs.clone());
self.frame(ui);
@@ -1660,7 +1678,9 @@ impl FigApp {
"lastError": self.ui.last_error,
"importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect),
});
state["homeSearch"] = json!(self.ui.home_view.file_query);
state["pendingOpens"] = json!(self.ui.pending_opens.len());
state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect));
state
}
}
+3
View File
@@ -114,6 +114,9 @@ pub fn record_recent(app: &mut FigApp, key: &str) {
pub fn invoke(app: &mut FigApp, id: &str) {
match id {
// Edit ▸ Delete (and the Delete binding) with an interaction open in the Prototype tab
// removes that interaction, not the layer (#767).
"edit.delete" if app.ui_context.clone().is_some_and(|ctx| crate::proto_panel::delete_selected_interaction(app, &ctx)) => {}
"file.open" => app.open_dialog(),
"plugin.import" => crate::plugins_ui::import_dialog(app),
"file.import" => app.import_dialog(false),
+115
View File
@@ -0,0 +1,115 @@
//! The Missing fonts window (#752): the fonts a file uses that aren't installed, whose text is
//! drawn in a fallback font. It opens by itself after the user opens a file that has any (once
//! the installed fonts are indexed and any import report is closed), and from Text ▸ Missing fonts.
use egui::RichText;
use figcraft_engine::fonts::{Missing, missing};
use serde_json::{Value, json};
use crate::FigApp;
use crate::i18n::{tr, trn};
use crate::theme::{Tokens, regular};
#[derive(Clone, Debug)]
pub struct Shown {
/// The document it lists: the window goes when that document closes.
pub uid: u64,
pub families: Vec<Missing>,
/// Asked for before the installed fonts were indexed: the list comes once they are.
pub indexing: bool,
}
impl Shown {
pub fn inspect(&self) -> Value {
json!({
"families": self.families.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::<Vec<_>>(),
"indexing": self.indexing,
})
}
}
/// After the user opened a file, or imported pages into one (results with a `summary`; not a
/// placed image or SVG, a file that was open already, or Clear recent): look for missing fonts in
/// it once that can be told ([`show`]). Not in the browser, which has only its built-in fonts.
pub fn check_later(app: &mut FigApp, command: &str, result: Option<&Value>) {
if !cfg!(target_arch = "wasm32")
&& matches!(command, "file.open" | "file.openRecent" | "file.import")
&& result.is_some_and(|r| r.get("summary").is_some())
&& let Ok(ds) = app.session.ds()
{
app.ui.font_checks.push(ds.uid);
}
}
/// The window for document `uid`, listing what it misses (even nothing, when asked for).
fn list(app: &mut FigApp, uid: u64) -> Option<Vec<Missing>> {
let ds = app.session.docs.iter().find(|d| d.uid == uid)?;
let families = missing(&ds.doc);
app.ui.missing_fonts = Some(Shown { uid, families: families.clone(), indexing: false });
Some(families)
}
/// Text ▸ Missing fonts (`dialog.missingFonts`): the window for the file in front.
pub fn request(app: &mut FigApp) {
let Ok(uid) = app.session.ds().map(|d| d.uid) else { return };
if figcraft_text::installed_fonts_known() {
list(app, uid);
} else {
app.ui.missing_fonts = Some(Shown { uid, families: Vec::new(), indexing: true });
}
}
pub fn show(app: &mut FigApp, ctx: &egui::Context) {
// Whether the installed fonts are indexed is asked only while a list waits for it.
let waiting = app.ui.missing_fonts.as_ref().filter(|m| m.indexing).map(|m| m.uid);
if (waiting.is_some() || !app.ui.font_checks.is_empty()) && figcraft_text::installed_fonts_known() {
if let Some(uid) = waiting {
list(app, uid);
}
if !app.ui.font_checks.is_empty() && app.ui.import_report.is_none() && app.ui.missing_fonts.is_none() {
let uid = app.ui.font_checks.remove(0);
if list(app, uid).is_some_and(|f| f.is_empty()) {
app.ui.missing_fonts = None;
}
}
}
if app.ui.missing_fonts.as_ref().is_some_and(|m| !app.session.docs.iter().any(|d| d.uid == m.uid)) {
app.ui.missing_fonts = None;
}
let Some(shown) = &app.ui.missing_fonts else { return };
let t = Tokens::get(ctx);
let mut open = true;
let mut close_clicked = false;
crate::dialogs::window(tr("Missing fonts"), ctx).open(&mut open).show(ctx, |ui| {
ui.set_width(380.0);
if shown.indexing {
ui.label(tr("Checking the fonts installed on this computer…"));
} else if shown.families.is_empty() {
ui.label(tr("All the fonts this file uses are installed"));
} else {
ui.label(
RichText::new(tr(
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them.",
))
.color(t.text_secondary)
.font(regular(11.0)),
);
ui.add_space(6.0);
egui::ScrollArea::vertical().id_salt("missing_fonts").max_height(260.0).show(ui, |ui| {
for m in &shown.families {
ui.horizontal(|ui| {
ui.label(RichText::new(&m.family).strong());
let used =
if m.layers == 0 { tr("only in a text style or variable").to_string() } else { trn("{n} text layers", m.layers, &[]) };
ui.label(RichText::new(used).color(t.text_secondary));
});
}
});
}
ui.add_space(12.0);
close_clicked = crate::widgets::button(ui, tr("Close"), true, true).clicked();
});
if !open || close_clicked {
app.ui.missing_fonts = None;
}
}
+149
View File
@@ -0,0 +1,149 @@
use figcraft_engine::Session;
use serde_json::{Value, json};
use crate::import_report::Report;
use crate::layers_drag_tests::Ui;
/// A saved file with text in `families` (the first two layers share the first family).
fn saved(name: &str, families: &[&str]) -> String {
let mut s = Session::new();
for (i, f) in families.iter().chain(families.first()).enumerate() {
s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 40 * i, "props": {"fontFamily": f}})).unwrap();
}
let path = std::env::temp_dir().join(format!("figcraft-missing-fonts-{name}-{}.figcraft", std::process::id()));
let path = path.to_string_lossy().into_owned();
s.execute("file.saveAs", &json!({"path": path})).unwrap();
path
}
/// The installed fonts are indexed, as the desktop app does at startup.
fn fonts_indexed() -> bool {
figcraft_text::scan_system_fonts();
figcraft_text::installed_fonts_known()
}
fn listed(u: &Ui) -> Value {
u.app.inspect()["missingFonts"].clone()
}
#[test]
fn opening_a_file_lists_its_missing_fonts_and_not_the_installed_ones() {
if !fonts_indexed() {
eprintln!("skipped: system fonts opted out");
return;
}
let installed = figcraft_text::family_names().into_iter().next();
let mut families = vec!["FigCraft Missing UI"];
families.extend(installed.as_deref());
let path = saved("open", &families);
let mut u = Ui::new(Session::new());
// Programmatic opens (control channel, MCP, scripts) never open the window.
u.app.run("file.open", json!({"path": path}));
u.frame(vec![]);
assert_eq!(listed(&u), Value::Null);
u.app.run("file.close", json!({}));
u.app.run_user("file.open", json!({"path": path}));
std::fs::remove_file(&path).unwrap();
u.frame(vec![]);
assert_eq!(listed(&u), json!({"families": [{"family": "FigCraft Missing UI", "layers": 2}], "indexing": false}));
u.frame(vec![]);
let shown = u.frame_output(vec![]).shapes;
assert!(crate::import_report_tests::shown_texts(shown).iter().any(|t| t == "2 text layers"));
// It goes with its file.
u.app.run("file.close", json!({}));
u.frame(vec![]);
assert_eq!(listed(&u), Value::Null);
}
#[test]
fn a_file_without_missing_fonts_opens_no_window() {
if !fonts_indexed() {
eprintln!("skipped: system fonts opted out");
return;
}
let Some(installed) = figcraft_text::family_names().into_iter().next() else {
eprintln!("skipped: no fonts on this machine");
return;
};
let path = saved("clean", &[&installed]);
let mut u = Ui::new(Session::new());
u.app.run_user("file.open", json!({"path": path}));
std::fs::remove_file(&path).unwrap();
u.frame(vec![]);
assert_eq!(listed(&u), Value::Null);
assert!(u.app.ui.font_checks.is_empty(), "the check ran");
}
#[test]
fn it_waits_for_the_import_report_to_close() {
if !fonts_indexed() {
eprintln!("skipped: system fonts opted out");
return;
}
let path = saved("report", &["FigCraft Missing Report"]);
let mut u = Ui::new(Session::new());
u.app.run_user("file.open", json!({"path": path}));
std::fs::remove_file(&path).unwrap();
let mut report =
Report::from_result("file.open", &json!({}), &json!({"warnings": ["Synthetic note"], "summary": {"name": "Synthetic"}})).unwrap();
report.uid = u.app.session.ds().unwrap().uid;
u.app.ui.import_report = Some(report);
u.frame(vec![]);
assert_eq!(listed(&u), Value::Null, "the import report comes first");
u.app.ui.import_report = None;
u.frame(vec![]);
assert_eq!(listed(&u)["families"][0]["family"], "FigCraft Missing Report");
}
#[test]
fn text_menu_shows_the_window_for_the_file_in_front_even_with_nothing_missing() {
assert!(fonts_indexed());
let mut u = Ui::new(Session::new());
assert!(u.app.run("text.missingFonts", json!({})).is_some());
assert_eq!(listed(&u), json!({"families": [], "indexing": false}));
u.frame(vec![]);
let shown = u.frame_output(vec![]).shapes;
assert!(crate::import_report_tests::shown_texts(shown).iter().any(|t| t == "All the fonts this file uses are installed"));
let escape =
vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }];
u.frame(escape);
assert_eq!(listed(&u), Value::Null, "Escape closes it");
}
#[test]
fn asked_for_before_the_fonts_are_indexed_it_lists_them_once_they_are() {
assert!(fonts_indexed());
let path = saved("indexing", &["FigCraft Missing Indexing"]);
let mut u = Ui::new(Session::new());
u.app.run("file.open", json!({"path": path}));
std::fs::remove_file(&path).unwrap();
// As Text ▸ Missing fonts leaves it while the startup scan is still running.
let uid = u.app.session.ds().unwrap().uid;
u.app.ui.missing_fonts = Some(crate::missing_fonts::Shown { uid, families: Vec::new(), indexing: true });
u.frame(vec![]);
assert_eq!(listed(&u), json!({"families": [{"family": "FigCraft Missing Indexing", "layers": 2}], "indexing": false}));
}
#[test]
fn placing_images_or_clearing_recents_does_not_bring_it_back() {
assert!(fonts_indexed());
let path = saved("place", &["FigCraft Missing Place"]);
let svg = std::env::temp_dir().join(format!("figcraft-missing-fonts-place-{}.svg", std::process::id()));
std::fs::write(&svg, r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>"#).unwrap();
let mut u = Ui::new(Session::new());
u.app.run_user("file.open", json!({"path": path}));
u.frame(vec![]);
assert_eq!(listed(&u)["families"][0]["family"], "FigCraft Missing Place");
u.app.ui.missing_fonts = None;
// A placed SVG, the same file opened again (it only comes to the front), Clear recent.
assert!(u.app.run_user("file.import", json!({"path": svg.to_string_lossy()})).is_some());
assert!(u.app.run_user("file.open", json!({"path": path})).is_some());
u.app.run_user("file.openRecent", json!({"clear": true}));
std::fs::remove_file(&path).unwrap();
std::fs::remove_file(&svg).unwrap();
u.frame(vec![]);
assert!(u.app.ui.font_checks.is_empty());
assert_eq!(listed(&u), Value::Null);
}
+1 -1
View File
@@ -72,7 +72,7 @@ fn manage(app: &mut FigApp, ctx: &egui::Context, f: (&str, &str), action: &str,
}
/// Open recorded file `f` as its owner; its tab shows once the file arrives.
fn open(app: &mut FigApp, f: &OnlineFile) {
pub(crate) fn open(app: &mut FigApp, f: &OnlineFile) {
let Some(r) = app.run("collab.openFile", json!({"server": f.server, "file": f.file})) else { return };
if r["alreadyOpen"] == true && !r["document"].is_null() {
app.ui.home = false;
+3 -11
View File
@@ -1788,8 +1788,7 @@ fn navigation_bar(p: &mut Presenter, ctx: &egui::Context, now: f64) -> Option<St
let scale_menu = ui.menu_button(format!("{} ", tr(p.view.scale.label(settings.device().is_some()))), |ui| {
navigation_menu(ui, |ui| {
for scale in PresentScale::options(settings.device().is_some()) {
let label = egui::RichText::new(tr(scale.label(settings.device().is_some()))).color(t.menu_text);
if ui.selectable_label(p.view.scale == *scale, label).clicked() {
if ui.selectable_label(p.view.scale == *scale, tr(scale.label(settings.device().is_some()))).clicked() {
p.set_scale(*scale);
ui.close();
}
@@ -1812,15 +1811,8 @@ fn paint_menu_chevron(painter: &egui::Painter, button: Rect, color: Color32) {
}
fn navigation_menu(ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
let t = Tokens::get(ui.ctx());
ui.visuals_mut().override_text_color = Some(t.menu_text);
egui::Frame::NONE.fill(t.menu).stroke(Stroke::new(1.0, t.border)).corner_radius(CornerRadius::same(7)).inner_margin(egui::Margin::same(6)).show(
ui,
|ui| {
ui.set_min_width(212.0);
add(ui);
},
);
ui.set_min_width(212.0);
add(ui);
}
/// How often the player polls the controllers while its prototype has gamepad triggers (seconds).
+3
View File
@@ -272,6 +272,9 @@ pub fn input(app: &mut FigApp, ui: &egui::Ui, resp: &egui::Response) -> bool {
crate::proto_panel::open_editor(&ctx, Some((n.source, n.index)));
return true;
}
if pressed && crate::proto_panel::editor(&ctx).is_some() {
crate::proto_panel::open_editor(&ctx, None);
}
false
}
+78
View File
@@ -226,3 +226,81 @@ fn the_presenter_inspects_the_layer_under_the_pointer() {
assert_eq!(presenter(&u)["inspected"], json!(a));
assert_eq!(u.app.inspect()["presentOptions"]["inspect"], json!(true));
}
#[test]
fn deleting_a_prototype_interaction_removes_the_interaction_not_the_component() {
let (s, _a, b, btn) = setup();
let mut u = Ui::new(s);
u.app.ui.right_tab = crate::RightTab::Prototype;
u.run("select.set", json!({"ids": [btn]}));
crate::proto_panel::open_editor(&u.ctx, Some((btn, 0)));
u.frames(1, Vec::new);
assert!(u.app.session.doc().unwrap().node(btn).is_some());
assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 1);
assert_eq!(crate::proto_panel::editor(&u.ctx), Some((btn, 0)));
// Pressing Delete when an interaction is selected should delete the interaction, NOT the component.
u.key(pos2(100.0, 100.0), egui::Key::Delete);
assert!(u.app.session.doc().unwrap().node(btn).is_some(), "Bug #767 regression: component was deleted when deleting prototype interaction!");
assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 0, "the interaction should be removed");
assert_eq!(crate::proto_panel::editor(&u.ctx), None);
// Re-add interaction and test Backspace as well.
u.run("proto.interaction.add", json!({"id": btn, "dest": b}));
assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 1);
crate::proto_panel::open_editor(&u.ctx, Some((btn, 0)));
u.frames(1, Vec::new);
u.key(pos2(100.0, 100.0), egui::Key::Backspace);
assert!(u.app.session.doc().unwrap().node(btn).is_some());
assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 0);
assert_eq!(crate::proto_panel::editor(&u.ctx), None);
// When NO interaction is active, Delete deletes the component as normal.
u.key(pos2(100.0, 100.0), egui::Key::Delete);
assert!(u.app.session.doc().unwrap().node(btn).is_none(), "when no interaction is selected, Delete removes the layer");
}
/// Edit ▸ Delete from the menu bar does the same as the Delete key (#767).
#[test]
fn edit_delete_from_the_menu_removes_the_open_interaction_not_the_layer() {
let (s, _a, _b, btn) = setup();
let mut u = Ui::new(s);
u.app.ui.right_tab = crate::RightTab::Prototype;
u.run("select.set", json!({"ids": [btn]}));
crate::proto_panel::open_editor(&u.ctx, Some((btn, 0)));
u.frames(1, Vec::new);
crate::menus::invoke(&mut u.app, "edit.delete");
let doc = u.app.session.doc().unwrap();
assert!(doc.node(btn).is_some_and(|n| n.interactions.is_empty()), "the interaction goes, the layer stays");
assert_eq!(crate::proto_panel::editor(&u.ctx), None);
crate::menus::invoke(&mut u.app, "edit.delete");
assert!(u.app.session.doc().unwrap().node(btn).is_none(), "with no interaction open, Edit ▸ Delete deletes the layer");
}
#[test]
fn escape_closes_prototype_editor_without_deselecting_layer() {
let (s, a, _b, btn) = setup();
let mut u = Ui::new(s);
u.app.ui.right_tab = crate::RightTab::Prototype;
u.run("select.set", json!({"ids": [btn]}));
crate::proto_panel::open_editor(&u.ctx, Some((btn, 0)));
u.frames(1, Vec::new);
assert_eq!(crate::proto_panel::editor(&u.ctx), Some((btn, 0)));
assert_eq!(u.app.session.selection(), vec![btn]);
// First Escape closes the interaction editor; layer remains selected.
u.key(pos2(100.0, 100.0), egui::Key::Escape);
assert_eq!(crate::proto_panel::editor(&u.ctx), None);
assert_eq!(u.app.session.selection(), vec![btn], "layer remains selected");
// Second Escape steps out to parent frame.
u.key(pos2(100.0, 100.0), egui::Key::Escape);
assert_eq!(u.app.session.selection(), vec![a], "steps out to parent frame");
// Third Escape deselects all.
u.key(pos2(100.0, 100.0), egui::Key::Escape);
assert!(u.app.session.selection().is_empty(), "selection cleared on third escape");
}
+32 -4
View File
@@ -40,6 +40,37 @@ pub fn editor(ctx: &egui::Context) -> Option<(NodeId, usize)> {
ctx.data(|d| d.get_temp(editor_id()))
}
/// Delete the selected interaction (when one is active in the Prototype tab).
/// Returns true if an interaction was deleted.
pub fn delete_selected_interaction(app: &mut FigApp, ctx: &egui::Context) -> bool {
if app.ui.right_tab != crate::RightTab::Prototype {
return false;
}
let Some((node, index)) = editor(ctx) else { return false };
let Ok(ds) = app.session.ds() else { return false };
if ds.doc.node(node).and_then(|n| n.interactions.get(index)).is_none() {
open_editor(ctx, None);
return false;
}
open_editor(ctx, None);
let ids = targets_for(app, node);
app.run("proto.interaction.remove", json!({"ids": ids, "index": index}));
true
}
/// Close the interaction editor if open. Returns true if it was open.
pub fn close_editor(ctx: &egui::Context) -> bool {
if capturing(ctx) || crate::variable_ui::picker_open(ctx) {
return false;
}
if editor(ctx).is_some() {
open_editor(ctx, None);
true
} else {
false
}
}
const TRIGGERS: [&str; 10] = [
tr_noop("On click"),
tr_noop("On drag"),
@@ -607,8 +638,6 @@ fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, nod
#[allow(clippy::too_many_arguments)]
fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: NodeId, node: NodeId, k: usize, it: &Interaction, at: Pos2) {
let t = Tokens::get(ctx);
// Escape in a field (it cancels the edit) doesn't close the editor.
let typing = ctx.egui_wants_keyboard_input();
let width = 280.0;
let screen = ctx.content_rect();
let pos = pos2(at.x - width, at.y.min(screen.max.y - 420.0).max(screen.min.y + 48.0));
@@ -652,8 +681,7 @@ fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: N
});
});
});
let escape = ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !typing && !capturing(ctx) && !crate::variable_ui::picker_open(ctx);
if close || escape {
if close {
open_editor(ctx, None);
}
}
+4 -1
View File
@@ -135,6 +135,9 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) {
}
ui.painter().galley(pos2(r.min.x + 8.0, r.center().y - galley.size().y / 2.0), galley, if active { t.text } else { t.text_secondary });
if resp.clicked() {
if app.ui.right_tab != tab {
crate::proto_panel::open_editor(ui.ctx(), None);
}
app.ui.right_tab = tab;
}
x = r.max.x + 2.0;
@@ -1887,7 +1890,7 @@ fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &
row(ui, 32.0, |ui| {
let ed = widgets::number_field(ui, Id::new(("fx_gl", i)), Lead::Text(tr("Light|glass")), Some(g.light_angle.round()), "°", half, 1.0);
edit(app, ed, &|v| with(&|gl| gl.light_angle = v.clamp(-180.0, 180.0)));
let ed = widgets::number_field(ui, Id::new(("fx_gi", i)), Lead::None, pct(g.light_intensity), "%", half, 1.0);
let ed = widgets::number_field(ui, Id::new(("fx_gi", i)), Lead::Text(tr("Intensity")), pct(g.light_intensity), "%", half, 1.0);
edit(app, ed, &|v| with(&|gl| gl.light_intensity = unit(v)));
});
row(ui, 32.0, |ui| {
+12
View File
@@ -212,6 +212,11 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> Swallowed {
pub fn run_binding(app: &mut FigApp, id: &str) {
match id {
"select.none" => {
if let Some(ctx) = &app.ui_context
&& crate::proto_panel::close_editor(ctx)
{
return;
}
if let Err(e) = app.session.escape() {
app.toast(e.to_string());
}
@@ -230,6 +235,9 @@ pub fn run_binding(app: &mut FigApp, id: &str) {
}
fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, repeat: bool, now: f64, ctx: &Context) -> bool {
if key == Key::Escape && crate::proto_panel::close_editor(ctx) {
return true;
}
if key == Key::Escape && crate::selection_spacing::cancel(app, ctx) {
return true;
}
@@ -240,6 +248,10 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, re
if matches!(key, Key::Backspace | Key::Delete) && m.is_none() && app.ui.picker.is_some() && crate::handles::remove_selected_stop(app, ctx) {
return true;
}
// An interaction selected in the Prototype tab: Delete removes that interaction, not the component.
if matches!(key, Key::Backspace | Key::Delete) && m.is_none() && crate::proto_panel::delete_selected_interaction(app, ctx) {
return true;
}
// Vector edit mode and the shape builder have their own keys (tools, nudging points, delete,
// join, Enter to finish); the rest fall through.
match app.session.tool_key(key.name(), m.shift, m.alt, m.command) {
+62 -1
View File
@@ -273,7 +273,9 @@ pub fn install_fonts(ctx: &Context, system: bool, cjk: u8) {
.filter_map(|f| figcraft_text::find_face(f, "Regular"))
.any(|x| x.has_char(c))
};
extra.extend(crate::bidi::script_fallbacks(&embedded));
let mut taken = Vec::new();
extra.extend(crate::bidi::script_fallbacks(&embedded, &mut taken));
extra.extend(key_symbol_fallbacks(&embedded, &mut taken));
extra.extend(crate::cjk::faces(cjk, lang, &embedded).iter().map(|f| (format!("system-{}-{}", f.family, f.style), font_data(f))));
}
extra.extend(craft(true));
@@ -288,6 +290,38 @@ pub fn install_fonts(ctx: &Context, system: bool, cjk: u8) {
ctx.set_fonts(fonts);
}
/// The glyphs macOS shortcuts are written with ([`figcraft_engine::keymap::display`]).
const KEY_SYMBOLS: &str = "⌃⌥⇧⌘⌫↵⇥⇞⇟←→↑↓";
/// System families that have them, in order of preference.
const KEY_FAMILIES: [&str; 6] = ["Lucida Grande", "Apple Symbols", "Segoe UI Symbol", "DejaVu Sans", "Noto Sans Symbols 2", "FreeSans"];
/// System faces for the shortcut glyphs the UI's own fonts lack (egui's default face has ⌘ but
/// not ⌥ or ↵); `taken` as for [`system_fallbacks`].
fn key_symbol_fallbacks(have: &dyn Fn(char) -> bool, taken: &mut Vec<Arc<figcraft_text::Face>>) -> Vec<(String, FontData)> {
system_fallbacks(KEY_SYMBOLS.chars(), &KEY_FAMILIES, have, taken)
}
/// System faces, the first of `families` that has each, for the `samples` neither the UI's own
/// fonts (`have`) nor the faces already `taken` draw. Each face found joins `taken`, so a face that
/// several lists name is added once.
pub(crate) fn system_fallbacks(
samples: impl IntoIterator<Item = char>,
families: &[&str],
have: &dyn Fn(char) -> bool,
taken: &mut Vec<Arc<figcraft_text::Face>>,
) -> Vec<(String, FontData)> {
let mut out = Vec::new();
for c in samples {
if have(c) || taken.iter().any(|f| f.has_char(c)) {
continue;
}
let Some(face) = figcraft_text::face_with_char(c, families) else { continue };
out.push((format!("system-{}-{}", face.family, face.style), font_data(&face)));
taken.push(face);
}
out
}
/// egui font data over a face's bytes, without copying them (CJK faces are 10–20 MB). Font
/// database faces are never dropped, so a system face's shared bytes are kept for good: leaking
/// one more reference to them costs a pointer and gives egui the `'static` slice it needs.
@@ -331,3 +365,30 @@ pub fn doc_c32(ctx: &Context, c: figcraft_color::Color) -> Color32 {
let conv = ctx.data(|d| d.get_temp::<figcraft_color::ProfileConversion>(screen_conversion_id()));
c32(conv.map_or(c, |k| k.apply(c)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ui_font_draws_every_mac_shortcut_glyph() {
figcraft_text::scan_system_fonts();
let lacking: String = KEY_SYMBOLS.chars().filter(|c| figcraft_text::face_with_char(*c, &KEY_FAMILIES).is_none()).collect();
if !lacking.is_empty() {
eprintln!("no system face here draws {lacking}; skipping");
return;
}
let ctx = Context::default();
install_fonts(&ctx, true, 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();
ctx.fonts_mut(|f| {
for c in KEY_SYMBOLS.chars() {
for font in [regular(11.0), semibold(11.0)] {
assert!(f.has_glyphs(&font, &c.to_string()), "UI font draws {c:?}");
}
}
});
}
}
+40 -14
View File
@@ -202,7 +202,7 @@ fn number_field_impl(
let mut out: Option<Edit> = None;
let lead_w = match lead {
Lead::None | Lead::Named(_) => 6.0,
Lead::Text(s) => (12.0 + s.chars().count() as f32 * 7.0).max(24.0),
Lead::Text(s) => text_lead_width(ui, s, width),
Lead::Icon(..) => 24.0,
};
let lead_rect = Rect::from_min_size(rect.min, vec2(lead_w, FIELD_H));
@@ -223,7 +223,12 @@ fn number_field_impl(
}
match lead {
Lead::Text(s) => {
ui.painter().text(pos2(lead_rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), t.text_secondary);
let galley = crate::bidi::name_galley(ui.painter(), s, regular(11.0), t.text_secondary, lead_w - 12.0);
// A label cut short shows whole on hover.
if galley.elided {
lead_resp.clone().on_hover_text(s);
}
ui.painter().galley(pos2(lead_rect.min.x + 8.0, rect.center().y - galley.size().y / 2.0), galley, t.text_secondary);
}
Lead::Icon(i, _) => {
icons::paint(ui.painter(), Rect::from_center_size(lead_rect.center() + vec2(1.0, 0.0), vec2(14.0, 14.0)), i, t.icon_secondary)
@@ -347,7 +352,8 @@ fn number_field_impl(
None => empty.to_string(),
};
let col = if value.is_some() { t.text } else { t.text_secondary };
ui.painter().text(pos2(edit_rect.min.x, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), col);
// A value too long for its room ends in "…", so a cut number never reads as another.
crate::bidi::paint_name(ui.painter(), pos2(edit_rect.min.x, rect.center().y), &s, regular(11.0), col, edit_rect.width());
}
}
// The variable button shows while the pointer is over a bindable field.
@@ -369,6 +375,13 @@ fn number_field_impl(
(out, bind)
}
/// The width a text label takes at the start of a field `field_w` wide: the label as drawn plus
/// its padding, leaving the value room for a few characters (a longer label ends in an ellipsis).
pub(crate) fn text_lead_width(ui: &Ui, label: &str, field_w: f32) -> f32 {
let text = ui.painter().layout_no_wrap(label.to_string(), regular(11.0), Color32::PLACEHOLDER).size().x;
(12.0 + text).min(field_w - 32.0).max(24.0)
}
/// A field bound to a variable: the lead, then the variable's name in a chip; the chip opens the
/// picker and the detach button unbinds.
/// A field showing the variable `name` bound to it in `rect`: click to change, detach to unbind.
@@ -383,8 +396,9 @@ pub fn bound_field(ui: &mut Ui, id: Id, lead: Lead, rect: Rect, name: &str) -> O
let lead_w = match lead {
Lead::None | Lead::Named(_) => 4.0,
Lead::Text(s) => {
ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), t.text_secondary);
(12.0 + s.chars().count() as f32 * 7.0).max(24.0)
let w = text_lead_width(ui, s, rect.width());
crate::bidi::paint_name(ui.painter(), pos2(rect.min.x + 8.0, rect.center().y), s, regular(11.0), t.text_secondary, w - 12.0);
w
}
Lead::Icon(i, _) => {
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.min.x + 13.0, rect.center().y), vec2(14.0, 14.0)), i, t.icon_secondary);
@@ -501,7 +515,7 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str
if started {
editing = Some(value.to_string());
ui.memory_mut(|m| m.request_focus(id.with("te")));
select_all_next(ui, id.with("te"));
select_all_next(ui, id.with("te"), value);
}
let mut out = None;
match editing {
@@ -593,21 +607,33 @@ pub fn select_all_on_focus(ui: &Ui, r: &Response, text: &str) {
ui.data_mut(|d| d.remove::<bool>(pending));
return;
}
if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), r.id) {
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count()));
state.cursor.set_char_range(Some(all));
state.store(ui.ctx(), r.id);
ui.ctx().request_repaint();
// A key typed in the frame the edit started already replaced the selection laid beforehand.
if r.changed() {
ui.data_mut(|d| d.remove::<bool>(pending));
return;
}
select_all(ui, r.id, text);
ui.ctx().request_repaint();
if !down {
ui.data_mut(|d| d.remove::<bool>(pending));
}
}
/// Has [`select_all_on_focus`] select all of editor `te_id` from now on (an edit that starts
/// with the editor already focused, which then gains no focus).
fn select_all_next(ui: &Ui, te_id: Id) {
/// with the editor already focused, which then gains no focus). Call it before adding the editor:
/// the text is selected at once, so a key typed in the frame the edit starts (the frame after a
/// double-click or F2 is often the one with the first key) replaces it.
fn select_all_next(ui: &Ui, te_id: Id, text: &str) {
ui.data_mut(|d| d.insert_temp(select_all_id(te_id), true));
select_all(ui, te_id, text);
}
/// Selects all of `text` in editor `te_id`.
fn select_all(ui: &Ui, te_id: Id, text: &str) {
let mut state = egui::TextEdit::load_state(ui.ctx(), te_id).unwrap_or_default();
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count()));
state.cursor.set_char_range(Some(all));
state.store(ui.ctx(), te_id);
}
fn select_all_id(te_id: Id) -> Id {
@@ -1015,7 +1041,7 @@ pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egu
if started {
buf = Some(value.to_string());
ui.memory_mut(|m| m.request_focus(te_id));
select_all_next(ui, te_id);
select_all_next(ui, te_id, value);
}
match buf {
Some(mut text) => {
+3 -1
View File
@@ -29,7 +29,9 @@ be open to the public: [server.md](server.md#running-a-public-server)). See [ser
### `info <file> [--json]`
Pages, node counts, top-level frames (id, type, name, size), style/variable/image counts. Repairs
of a damaged file and import warnings go to stderr as `note:` lines (`notes` with `--json`).
of a damaged file and import warnings go to stderr as `note:` lines (`notes` with `--json`). Fonts the
file names that aren't installed are listed as `missing font` lines (`missingFonts: [{family,
layers}]` with `--json`); their text renders in a fallback font.
Every subcommand that reads a file also takes the public REST API file JSON (the response of
`GET /v1/files/:key` or of its nodes endpoint, written to a `.json` file): it is imported on the fly.
+5 -3
View File
@@ -198,7 +198,8 @@ A mouse gesture with the current tool. `kind`: `down`, `move`, `up`, `click`, `d
(`x`, `y`) or screen pixels (`sx`, `sy`). `pressure` (0–1) is pen pressure (the Pencil records
it); a drag's pressure goes from `pressure` to `pressureTo`. Returns `{selection, tool}`. With the Move and Scale
tools, a press on a frame's or section's name label (as the canvas last drew it, along a rotated
frame's top edge) selects and drags that frame.
frame's top edge) selects and drags that frame; a double-click there renames the frame in place in
the desktop app (the name field opens over the label).
```json
{"id":4,"method":"engine.execute","params":{"command":"tool.set","params":{"tool":"ellipse"}}}
@@ -247,12 +248,13 @@ separate calls. Returns `{queued}`.
Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`.
### `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginsTab?, pluginConfirm?, pluginForm?}`
### `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeSearch?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginsTab?, pluginConfirm?, pluginForm?}`
Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`,
`home` (bool), `homeSection` (`recents`, `online` or `templates`: which section the home screen shows, as `ui.inspect` reports it; opening
`online` asks each server once what became of your online files), `homeRecentsView` (`grid|list`), `homeRecentsSort` (`modified|name|created`) and `homeRecentsOrder` (`desc|asc`): how the home screen shows its recent files (kept in the preferences; `ui.inspect` reports them under `homeRecents` with each file and whether its picture is in), `palette` (bool: the Actions palette), `mainMenu` (bool, or a top-level menu such
as `"Plugins"` to open the main menu with it open), `pluginsWindow` (bool: Plugins ▸ Manage
as `"Plugins"` to open the main menu with it open), `homeSearch` (text, at most 256 characters: filters locally known open, recent and online files plus starter templates in Home; an empty string returns to the selected section),
`pluginsWindow` (bool: Plugins ▸ Manage
plugins…), `pluginsTab` (`installed|browse`: that window's tab), `pluginConfirm` (a plugin id of the
directory listed last: its install confirmation shows, as its Install button would; `null` closes
it), `pluginForm` (`{id, command?}`: start that plugin command like its menu item, opening
+2
View File
@@ -233,6 +233,8 @@ Or in a project's `.mcp.json`:
user units, e.g. `{"wght": 550, "wdth": 85}` (`null` = back to what the style name implies);
`text.setVariations {variations, replace?}` merges instead. `text.fonts {family, style?, axes: true}`
lists a font's axes and named instances (named instances are also valid `fontStyle` values).
`text.missingFonts` lists the families the document names that aren't installed
(`{families: [{family, layers}]}`); their text draws in a fallback font.
OpenType features: `features {"liga": false, "tnum": true}`.
Decorations: `textDecorationStyle` (`solid`, `dotted`, `wavy`), `textDecorationThickness` and
`textDecorationOffset` (`auto`, px, or `"10%"` of the font size; the offset runs from the baseline
+3 -2
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@@ -27,7 +27,7 @@ interaction feel not yet compared.
| Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored |
| Multiplayer avatars, Share button | missing | waits for multiplayer |
| Colour names in hex fields | done | every hex field (fills, strokes, text ranges, effects, layout grids, colour variables, page and prototype backgrounds, the colour picker's Hex) takes a CSS colour name as well: `red` becomes `FF0000`; case, spaces and hyphens are ignored (`Light Blue`); names are opaque and keep the colour's opacity; hex wins where both could match. The picker's CSS field takes every CSS name too (`Color::from_hex_or_name`, `color::named`) |
| Fields select their text on focus | done | a click or Tab into a one-line field selects all of its text, so typing replaces the value: hex and colour fields, number fields, inline fields (a fill's hex, variable values, renames), names, search boxes and dialog fields; a drag from the press selects only what it covers; multi-line fields (descriptions, comments) keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard; it took a second click (`widgets::select_all_on_focus`, `select_all_tests`) |
| Fields select their text on focus | done | a click or Tab into a one-line field selects all of its text, so typing replaces the value: hex and colour fields, number fields, inline fields (a fill's hex, variable values, renames), names, search boxes and dialog fields; a drag from the press selects only what it covers; multi-line fields (descriptions, comments) keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard; it took a second click. A rename (double-click, F2, File ▸ Rename, a page) selects the whole name before its field first draws, so a key typed in that same frame replaces the name; it was appended (`widgets::select_all_on_focus`, `select_all_tests`, `layers_rename_tests`) |
| Colour picker on a new fill or stroke | done | + opens the picker on the new paint when the selection shares one paint list; on a mixed selection + adds a paint to each layer and no picker opens (#404) |
| Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas |
@@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| Date | Change | Summary |
|---|---|---|
| 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel |
| 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged |
| 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME |
| 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged |