Merge remote-tracking branch 'origin/main' into feat/comment-people-mentions

# Conflicts:
#	ROADMAP.md
#	crates/engine/src/cmd/comment.rs
#	crates/engine/src/comment_mentions_tests.rs
#	docs/collaboration-parity.md
#	docs/roadmap.md
This commit is contained in:
bflatastic
2026-10-30 07:15:44 +07:00
49 changed files with 567 additions and 37 deletions
Generated
+1
View File
@@ -2100,6 +2100,7 @@ dependencies = [
"egui",
"figcraft-collab-net",
"figcraft-engine",
"figcraft-format",
"figcraft-text",
"figcraft-ui-egui",
"js-sys",
+3 -1
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -15,9 +15,11 @@ path = "src/main.rs"
[dependencies]
figcraft-engine = { workspace = true }
figcraft-format = { workspace = true }
figcraft-ui-egui = { workspace = true }
figcraft-collab-net = { workspace = true }
figcraft-text = { workspace = true }
base64 = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
@@ -26,7 +28,6 @@ serde_json = { workspace = true }
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
base64 = { workspace = true }
js-sys = "0.3"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "Storage"] }
+4
View File
@@ -10,9 +10,13 @@
#figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
#figcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #6b6b6b; font: 14px system-ui, sans-serif; pointer-events: none; }
/* Shown once the page stopped on an internal error (web.rs: show_stopped). */
#figcraft_stopped { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; box-sizing: border-box;
padding: 16px; text-align: center; color: #1e1e1e; background: rgba(245, 245, 245, 0.92); font: 14px/1.5 system-ui, sans-serif; }
@media (prefers-color-scheme: dark) {
html, body { background: #2c2c2c; }
#figcraft_loading { color: #b8b8b8; }
#figcraft_stopped { color: #f0f0f0; background: rgba(44, 44, 44, 0.92); }
}
</style>
</head>
+6
View File
@@ -9,6 +9,8 @@
//! - no native file dialogs yet: the session starts with one new document;
//! - links open in a new browser tab;
//! - of the preferences, the page keeps only your name across reloads (`kept.rs`);
//! - an internal error (a panic) can't be caught, so it stops the page: the files with unsaved
//! changes are kept in the page's storage and open again on the next reload (`recovery.rs`);
//! - fonts: the built-in ones, and on request (the font menu's "Use fonts installed on this
//! computer…", Chromium's Local Font Access) the computer's, each read when text uses it
//! (`local_fonts.rs`);
@@ -37,6 +39,10 @@ mod query;
#[cfg(test)]
mod query_tests;
#[cfg(any(target_arch = "wasm32", test))]
mod recovery;
#[cfg(test)]
mod recovery_tests;
#[cfg(any(target_arch = "wasm32", test))]
mod saved_files;
#[cfg(target_arch = "wasm32")]
mod web;
+85
View File
@@ -0,0 +1,85 @@
//! Recovery copies in the browser. The browser build can't catch a panic (wasm32 always aborts),
//! so a command or frame that panics ends the page instead of failing as on the desktop. The page
//! follows its files with unsaved changes after every frame ([`Unsaved::follow`]: cheap shared
//! copies, taken only when a file changed); the panic hook writes them to the page's local storage
//! ([`store`]) before the page stops, and the next page opens them again as unsaved files
//! ([`restore`], `file.recover`), as the desktop app does with its recovery copies.
use std::sync::Arc;
use base64::Engine as _;
use figcraft_engine::branches::{self, needs_copy, stamp};
use figcraft_engine::doc::Document;
use figcraft_engine::{DocState, Session};
use figcraft_format::{Branch, Version};
use serde_json::json;
/// A file with unsaved changes as of the last frame.
struct Copy {
uid: u64,
/// [`stamp`]: the revisions this copy is of.
stamp: Vec<u64>,
doc: Arc<Document>,
versions: Vec<Version>,
branches: Vec<Branch>,
}
impl Copy {
fn of(s: &Session, d: &DocState) -> Self {
let (versions, branches) = (d.versions.clone(), branches::current(s, d.uid));
Self { uid: d.uid, stamp: stamp(s, d), doc: d.document_itself().clone(), versions, branches }
}
}
/// The page's files with unsaved changes, as of the last frame.
#[derive(Default)]
pub struct Unsaved(Vec<Copy>);
impl Unsaved {
/// Catch up with `s`: a file that changed is taken again, the others are kept as they are.
pub fn follow(&mut self, s: &Session) {
let mut before = std::mem::take(&mut self.0);
for d in s.docs.iter().filter(|d| needs_copy(s, d)) {
let now = stamp(s, d);
let copy = match before.iter().position(|c| c.uid == d.uid && c.stamp == now) {
Some(i) => before.swap_remove(i),
None => Copy::of(s, d),
};
self.0.push(copy);
}
}
/// How many files have unsaved changes.
pub fn count(&self) -> usize {
self.0.len()
}
/// Each file as a saved `.figcraft` file in base64 (one that can't be written is left out).
pub fn files(&self) -> Vec<String> {
let b64 = base64::engine::general_purpose::STANDARD;
self.0
.iter()
.filter_map(|c| figcraft_format::save_bytes_with_history(&c.doc, None, &c.versions, &c.branches).ok().map(|b| b64.encode(b)))
.collect()
}
}
/// Keep as many of `files` as the storage takes, in order: `put` stores the list's JSON and says
/// whether it fitted (a refused write leaves what was stored before). Returns how many were kept.
pub fn store(files: Vec<String>, mut put: impl FnMut(&str) -> bool) -> usize {
let mut kept = Vec::new();
for f in files {
kept.push(f);
if !put(&json!(kept).to_string()) {
kept.pop();
}
}
kept.len()
}
/// Open the copies the last page kept (`kept`: what [`store`] stored, if anything) as files with
/// unsaved changes. Returns how many came back; junk in the storage is skipped.
pub fn restore(s: &mut Session, kept: Option<String>) -> usize {
let files: Vec<String> = kept.and_then(|k| serde_json::from_str(&k).ok()).unwrap_or_default();
files.iter().filter(|f| s.execute("file.recover", &json!({"data": f})).is_ok()).count()
}
+98
View File
@@ -0,0 +1,98 @@
//! Recovery copies in the browser: what a page kept when it stopped opens again, unsaved, on the
//! next page.
use figcraft_engine::Session;
use figcraft_ui_egui::browser_stopped;
use serde_json::json;
use crate::recovery::{Unsaved, restore, store};
fn edited(name: &str) -> Session {
let mut s = Session::new();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
s.execute("file.rename", &json!({"name": name})).unwrap();
s
}
/// The page's copies, stored as the panic hook stores them.
fn stored(u: &Unsaved) -> Option<String> {
let mut out = None;
let kept = store(u.files(), |v| {
out = Some(v.to_string());
true
});
assert_eq!(kept, u.count());
out
}
#[test]
fn a_stopped_pages_unsaved_files_open_again_unsaved() {
let s = edited("Poster");
let mut u = Unsaved::default();
u.follow(&s);
assert_eq!(u.count(), 1);
let mut next = Session::new();
let before = next.docs.len();
assert_eq!(restore(&mut next, stored(&u)), 1);
assert_eq!(next.docs.len(), before + 1);
let d = next.ds().unwrap();
assert_eq!(d.doc.name, "Poster");
assert!(d.is_dirty(), "a recovered file has unsaved changes");
assert!(d.path.is_none(), "saving it asks where");
}
#[test]
fn the_latest_edit_is_kept_and_a_saved_file_is_not() {
let mut s = edited("First");
let mut u = Unsaved::default();
u.follow(&s);
u.follow(&s);
assert_eq!(u.count(), 1, "following again without a change keeps the one copy");
s.execute("file.rename", &json!({"name": "Later"})).unwrap();
u.follow(&s);
let mut next = Session::new();
assert_eq!(restore(&mut next, stored(&u)), 1);
assert_eq!(next.ds().unwrap().doc.name, "Later");
s.execute("file.markSaved", &json!({})).unwrap();
u.follow(&s);
assert_eq!(u.count(), 0);
assert!(u.files().is_empty());
}
#[test]
fn what_the_storage_has_no_room_for_is_left_out() {
let files = vec!["a".repeat(10), "b".repeat(100), "c".repeat(10)];
let mut kept = String::new();
let n = store(files, |v| {
let fits = v.len() < 60;
if fits {
kept = v.to_string();
}
fits
});
assert_eq!(n, 2);
assert_eq!(kept, json!(["a".repeat(10), "c".repeat(10)]).to_string());
assert_eq!(store(vec!["x".into()], |_| false), 0);
}
#[test]
fn junk_in_the_storage_opens_nothing() {
let mut s = Session::new();
let before = s.docs.len();
for junk in [None, Some("not json"), Some("{}"), Some(r#"["!!"]"#), Some(r#"["aGVsbG8="]"#), Some("[1, null]")] {
assert_eq!(restore(&mut s, junk.map(String::from)), 0, "{junk:?}");
}
assert_eq!(s.docs.len(), before);
}
#[test]
fn the_stopped_page_says_what_was_kept() {
let says = |unsaved, kept, what: &str| {
let text = browser_stopped(unsaved, kept);
assert!(text.starts_with("FigCraft stopped because of an internal error. ") && text.contains(what), "{text}");
};
says(0, 0, "start again");
says(2, 2, "get your unsaved files back");
says(2, 0, "too large for the browser to keep");
says(3, 1, "the browser had room for");
}
+51
View File
@@ -6,11 +6,23 @@ use wasm_bindgen::JsCast as _;
const CANVAS_ID: &str = "figcraft_canvas";
const LOADING_ID: &str = "figcraft_loading";
/// What the page shows once it stopped on an internal error (styled in `index.html`).
const STOPPED_ID: &str = "figcraft_stopped";
/// The local storage key your name is kept under ([`crate::kept`]).
const NAME_KEY: &str = "figcraft.userName";
/// The local storage key the recovery copies are kept under ([`crate::recovery`]): a JSON list of
/// base64 `.figcraft` files.
const RECOVERY_KEY: &str = "figcraft.recovery";
thread_local! {
/// The files with unsaved changes as of the last frame, for the panic hook ([`crate::recovery`]).
static UNSAVED: std::cell::RefCell<crate::recovery::Unsaved> = std::cell::RefCell::default();
}
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
// Before the runner's own hook, which logs the panic and then calls this one.
keep_unsaved_on_panic();
wasm_bindgen_futures::spawn_local(async {
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
log::error!("no document");
@@ -39,11 +51,13 @@ pub fn start() {
let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
let mut session = Session::new();
crate::kept::restore_name(&mut session, local_storage().and_then(|s| s.get_item(NAME_KEY).ok().flatten()));
let recovered = restore_unsaved(&mut session);
// Live sessions: the browser joins sessions and publishes files to servers;
// whatever arrives on the socket wakes the page for a frame.
let ctx = cc.egui_ctx.clone();
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
fig.note_recovered(recovered);
// `#join=<link>`: join that session, as opening an invite link does on the desktop.
let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default();
if let Some(link) = crate::query::join_link(&hash) {
@@ -70,6 +84,41 @@ pub fn start() {
});
}
/// The browser can't catch a panic (wasm32 aborts), so the page ends with it: keep the files with
/// unsaved changes in the page's storage for the next page, and say so on the page.
fn keep_unsaved_on_panic() {
static ONCE: std::sync::Once = std::sync::Once::new();
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
previous(info);
// Callbacks that run after the page stopped can panic again: the files are kept once.
ONCE.call_once(|| {
let (unsaved, files) = UNSAVED.with(|u| u.try_borrow().map(|u| (u.count(), u.files())).unwrap_or_default());
let kept = local_storage().map_or(0, |s| crate::recovery::store(files, |v| s.set_item(RECOVERY_KEY, v).is_ok()));
show_stopped(&figcraft_ui_egui::browser_stopped(unsaved, kept));
});
}));
}
/// Open the files the last page kept when it stopped; how many came back.
fn restore_unsaved(session: &mut Session) -> usize {
let Some(storage) = local_storage() else { return 0 };
let kept = storage.get_item(RECOVERY_KEY).ok().flatten();
// Gone before they open: a copy that stops the page again must not come back on every reload
// (once a frame ran, the files it opened are followed as any unsaved file).
let _ = storage.remove_item(RECOVERY_KEY);
crate::recovery::restore(session, kept)
}
/// Say on the page that it stopped (the canvas stays as it was).
fn show_stopped(text: &str) {
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
let (Ok(el), Some(body)) = (document.create_element("div"), document.body()) else { return };
el.set_id(STOPPED_ID);
el.set_text_content(Some(text));
let _ = body.append_child(&el);
}
/// The page's local storage (`None` where the browser keeps none, e.g. storage turned off).
fn local_storage() -> Option<web_sys::Storage> {
web_sys::window().and_then(|w| w.local_storage().ok().flatten())
@@ -90,6 +139,8 @@ impl eframe::App for WebShell {
/// still advance autosave, live and control deadlines without painting. `FigApp::ui` does
/// not repeat it.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Every frame, hidden or not, starts here: what the last one left is what a panic keeps.
UNSAVED.with(|u| u.try_borrow_mut().map(|mut u| u.follow(&self.fig.session))).ok();
figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));
}
+1 -17
View File
@@ -25,6 +25,7 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard};
use std::thread::JoinHandle;
use figcraft_engine::branches::{needs_copy, stamp};
use figcraft_engine::{DocState, Session};
use figcraft_format::{Branch, Version};
use serde_json::json;
@@ -321,23 +322,6 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option<u64> {
s.execute("collab.recoverFile", &params).ok()?.get("session")?.as_u64()
}
/// A file whose changes would be lost: in its document, its history or one of its branch tabs
/// (a branch tab's edits are saved with its main file, so the tab has no copy of its own).
fn needs_copy(s: &Session, d: &DocState) -> bool {
d.branch_of.is_none() && (d.is_dirty() || branch_tabs(s, d.uid).any(DocState::is_dirty))
}
/// The tabs showing branches of the file in tab `uid`.
fn branch_tabs(s: &Session, uid: u64) -> impl Iterator<Item = &DocState> {
s.docs.iter().filter(move |t| t.branch_of.as_ref().is_some_and(|(main, _)| *main == uid))
}
/// The revisions a copy of `d` is of: its own and its branch tabs' (a branch tab's edits change
/// only its own revision). A new copy is due when they change.
fn stamp(s: &Session, d: &DocState) -> Vec<u64> {
std::iter::once(d.revision).chain(branch_tabs(s, d.uid).map(|t| t.revision)).collect()
}
/// Write `doc` with its version history and branches to `path` through a temporary file; false
/// when it couldn't be written (the copy before it is still there).
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool {
+9 -4
View File
@@ -33,7 +33,7 @@ use load::font_report;
#[cfg(test)]
pub(crate) use load::{installed_family, sketch_line};
pub use load::{load, load_bytes, load_path};
pub(crate) use load::{load_fig, read_capped, recovered, variables_param, write};
pub(crate) use load::{load_fig, read_capped, recovered, recovered_from, variables_param, write};
pub use open::MAX_RECENT;
#[cfg(test)]
use open::append_pages;
@@ -181,11 +181,16 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.recover",
"Recover file",
"{path} open a recovery copy (written while the app runs, read back after a crash) as a document with unsaved changes and no path: saving asks where → {index}",
"{path | data (base64: the copy's bytes, as the browser keeps them)} open a recovery copy (written while the app runs, read back after a crash) as a document with unsaved changes and no path: saving asks where → {index}",
false,
|s, p| {
let path = req_string("file.recover", p, "path")?;
s.docs.push(recovered(&path)?);
let ds = match string(p, "data") {
Some(d) => recovered_from(
&base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad("file.recover", format!("`data`: {e}")))?,
)?,
None => recovered(&req_string("file.recover", p, "path")?)?,
};
s.docs.push(ds);
s.active = s.docs.len() - 1;
Ok(json!({"index": s.active}))
}
+7 -1
View File
@@ -4,7 +4,13 @@ use super::{DocState, Document, EngineError, LineHeight, NodeId, NodeKind, Resul
/// The recovery copy at `path` as a document with unsaved changes and no path.
pub(crate) fn recovered(path: &str) -> Result<DocState> {
let loaded = read(path).and_then(|b| figcraft_format::load_bytes_with_versions(&b).map_err(|e| EngineError::Other(e.to_string())))?;
recovered_from(&read(path)?)
}
/// A recovery copy's bytes as a document with unsaved changes and no path (the browser keeps its
/// copies in the page's storage, not in files).
pub(crate) fn recovered_from(bytes: &[u8]) -> Result<DocState> {
let loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
Ok(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved())
}
+7
View File
@@ -369,6 +369,13 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("pref.renameDuplicates", "Rename duplicated layers", "{on?}", false, |s, p| pref(s, p, |x| &mut x.rename_duplicates)),
spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)),
spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)),
spec!(
"pref.scrollWheelZoom",
"Scroll wheel zoom",
"{on?} a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls it (Shift+wheel still scrolls sideways; trackpads are unchanged) → {on}",
false,
|s, p| pref(s, p, |x| &mut x.scroll_wheel_zoom)
),
spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)),
spec!(
"pref.uiScale",
+4
View File
@@ -1,5 +1,9 @@
//! The last line of defence: a panic inside a command becomes an [`EngineError::Internal`] and
//! the caller rolls the document back. It exists for bugs and doesn't replace `Result`.
//!
//! The browser build can't catch a panic (wasm32 always aborts, and stable Rust has no unwinding
//! std for it), so there a panicking command stops the page: its panic hook keeps the files with
//! unsaved changes for the next page instead (`apps/figcraft-web/src/recovery.rs`).
use crate::{EngineError, Result};
+4
View File
@@ -29,6 +29,9 @@ pub struct Prefs {
pub highlight_on_hover: bool,
pub number_keys_opacity: bool,
pub show_dimensions: bool,
/// A mouse wheel zooms the canvas about the pointer, and Cmd/Ctrl+wheel scrolls it (off: the
/// other way round). Trackpads are unchanged.
pub scroll_wheel_zoom: bool,
/// "system" by default; an explicit "light" or "dark" choice stays saved.
pub theme: String,
/// The widths of the main window's left and right panels (the interface keeps them within its
@@ -122,6 +125,7 @@ impl Default for Prefs {
highlight_on_hover: true,
number_keys_opacity: true,
show_dimensions: true,
scroll_wheel_zoom: false,
theme: "system".into(),
panel_widths: (240.0, 240.0),
pages_height: None,
+18
View File
@@ -180,6 +180,24 @@ pub fn current(s: &Session, uid: u64) -> Vec<Branch> {
branches
}
/// The tabs showing branches of the file in tab `uid`.
pub fn tabs(s: &Session, uid: u64) -> impl Iterator<Item = &DocState> {
s.docs.iter().filter(move |t| t.branch_of.as_ref().is_some_and(|(main, _)| *main == uid))
}
/// A file whose changes would be lost, so it needs a recovery copy: in its document, its history
/// or one of its branch tabs (a branch tab's edits are saved with its main file, so the tab has no
/// copy of its own).
pub fn needs_copy(s: &Session, d: &DocState) -> bool {
d.branch_of.is_none() && (d.is_dirty() || tabs(s, d.uid).any(DocState::is_dirty))
}
/// The revisions a recovery copy of `d` is of: its own and its branch tabs' (a branch tab's edits
/// change only its own revision). A new copy is due when they change.
pub fn stamp(s: &Session, d: &DocState) -> Vec<u64> {
std::iter::once(d.revision).chain(tabs(s, d.uid).map(|t| t.revision)).collect()
}
/// The tab a save writes: the main file's for a branch tab, else the active tab.
pub fn file_tab(s: &Session) -> usize {
s.ds().ok().and_then(|d| d.branch_of.as_ref()).and_then(|(m, _)| s.docs.iter().position(|d| d.uid == *m)).unwrap_or(s.active)
+2 -1
View File
@@ -81,7 +81,8 @@ pub use figcraft_geom::measure::finite_path as finite;
/// Run the sweep library on `f`. Version 0.4 can panic on rare near-degenerate input (an internal
/// `unwrap` while ordering curves, hit by some font outlines): that is bad input, not a crash.
// shortcut: no catch where panics abort (wasm32); drop this once a linesweeper release no longer panics there.
// shortcut: wasm32 can't catch it (panics abort there): the page stops and its panic hook keeps the
// unsaved files (apps/figcraft-web/src/recovery.rs); drop this once linesweeper no longer panics.
fn sweep<T>(f: impl FnOnce() -> Result<T, PathOpsError>) -> Result<T, PathOpsError> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or(Err(PathOpsError::BadInput))
}
+2
View File
@@ -808,6 +808,7 @@ status = "draft — needs native review"
"Main menu" = "القائمة الرئيسية"
"Make a component" = "إنشاء مكوّن"
"Make slot" = "إنشاء خانة محتوى"
"Scroll wheel zoom" = "التكبير بعجلة التمرير"
"Slot name" = "اسم خانة المحتوى"
"Mar" = "مارس"
"Margin" = "الهامش"
@@ -2192,6 +2193,7 @@ status = "draft — needs native review"
"pref.language" = "لغة الواجهة"
"pref.nudge" = "مقدار الإزاحة الطفيفة"
"pref.numberKeysOpacity" = "استخدام مفاتيح الأرقام للعتامة"
"pref.scrollWheelZoom" = "التكبير بعجلة التمرير"
"pref.publishServer" = "خادم المشاركة عبر الإنترنت"
"pref.reduceMotion" = "تقليل الحركة"
"pref.renameDuplicates" = "إعادة تسمية الطبقات المكررة"
+2
View File
@@ -776,6 +776,7 @@ status = "draft — needs native review"
"Main menu" = "প্রধান মেনু"
"Make a component" = "একটি কম্পোনেন্ট তৈরি করুন"
"Make slot" = "স্লট তৈরি করুন"
"Scroll wheel zoom" = "স্ক্রল হুইল দিয়ে জুম"
"Slot name" = "স্লটের নাম"
"Mar" = "মার্চ"
"Margin" = "মার্জিন"
@@ -1846,3 +1847,4 @@ status = "draft — needs native review"
"tool.sticky" = "স্টিকি নোট"
"widget.menu" = "উইজেটের মেনু"
"widget.setText" = "উইজেটের ঘর সম্পাদনা"
"pref.scrollWheelZoom" = "স্ক্রল হুইল দিয়ে জুম"
+2
View File
@@ -761,6 +761,7 @@ status = "draft — needs native review"
"Main menu" = "Hlavní nabídka"
"Make a component" = "Vytvořit komponentu"
"Make slot" = "Vytvořit slot"
"Scroll wheel zoom" = "Přibližování kolečkem myši"
"Slot name" = "Název slotu"
"Mar" = "března"
"Margin" = "Vnější okraj"
@@ -2085,6 +2086,7 @@ status = "draft — needs native review"
"pref.language" = "Jazyk rozhraní"
"pref.nudge" = "Velikost posunu"
"pref.numberKeysOpacity" = "Nastavovat krytí číselnými klávesami"
"pref.scrollWheelZoom" = "Přibližování kolečkem myši"
"pref.publishServer" = "Server pro online sdílení"
"pref.reduceMotion" = "Omezit pohyb"
"pref.renameDuplicates" = "Přejmenovávat duplikované vrstvy"
+2
View File
@@ -740,6 +740,7 @@ status = "draft — needs native review"
"Main component deleted" = "Hauptkomponente gelöscht"
"Main menu" = "Hauptmenü"
"Make slot" = "Slot erstellen"
"Scroll wheel zoom" = "Mit dem Mausrad zoomen"
"Slot name" = "Slot-Name"
"Margin" = "Rand"
"Mark all as read" = "Alle als gelesen markieren"
@@ -2039,6 +2040,7 @@ status = "draft — needs native review"
"pref.language" = "Sprache der Benutzeroberfläche"
"pref.nudge" = "Schrittweite"
"pref.numberKeysOpacity" = "Zifferntasten für Deckkraft verwenden"
"pref.scrollWheelZoom" = "Mit dem Mausrad zoomen"
"pref.publishServer" = "Server für das Online-Teilen"
"pref.reduceMotion" = "Bewegung reduzieren"
"pref.renameDuplicates" = "Duplizierte Ebenen umbenennen"
+6
View File
@@ -540,6 +540,7 @@ name = "English"
"Fetching from the server…" = "Fetching from the server…"
"FigCraft couldn't open its window: {error}" = "FigCraft couldn't open its window: {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 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 stopped because of an internal error." = "FigCraft stopped because of an internal error."
"FigCraft {version} is currently the newest version available." = "FigCraft {version} is currently the newest version available."
"File" = "File"
"File name" = "File name"
@@ -813,6 +814,7 @@ name = "English"
"Main menu" = "Main menu"
"Make a component" = "Make a component"
"Make slot" = "Make slot"
"Scroll wheel zoom" = "Scroll wheel zoom"
"Slot name" = "Slot name"
"Mar" = "Mar"
"Margin" = "Margin"
@@ -1147,6 +1149,9 @@ name = "English"
"Regular expression" = "Regular expression"
"Relative to" = "Relative to"
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relative to your system's display scaling (100% is the system's own size). Document zoom is separate."
"Reload the page to get back the unsaved files the browser had room for." = "Reload the page to get back the unsaved files the browser had room for."
"Reload the page to get your unsaved files back." = "Reload the page to get your unsaved files back."
"Reload the page to start again." = "Reload the page to start again."
"Remove" = "Remove"
"Remove all" = "Remove all"
"Remove annotation" = "Remove annotation"
@@ -1660,6 +1665,7 @@ name = "English"
"Your copy stays open; the file stays on the server" = "Your copy stays open; the file stays on the server"
"Your name" = "Your name"
"Your system asks for reduced motion: animations stay off." = "Your system asks for reduced motion: animations stay off."
"Your unsaved files were too large for the browser to keep." = "Your unsaved files were too large for the browser to keep."
"Z" = "Z"
"Z to A" = "Z to A"
"Zoom" = "Zoom"
+2
View File
@@ -32,6 +32,7 @@ status = "draft — needs native review"
"Couldn't download the variables" = "No se pudieron descargar las variables"
"Couldn't download {name}" = "No se pudo descargar {name}"
"Relative to" = "Relativa a"
"Scroll wheel zoom" = "Zoom con la rueda del ratón"
"Trigger layer" = "Capa que la abre"
"Whole screen" = "Pantalla completa"
@@ -2181,6 +2182,7 @@ status = "draft — needs native review"
"pref.language" = "Idioma de la interfaz"
"pref.nudge" = "Cantidad de desplazamiento"
"pref.numberKeysOpacity" = "Usar teclas numéricas para la opacidad"
"pref.scrollWheelZoom" = "Zoom con la rueda del ratón"
"pref.publishServer" = "Servidor para compartir en línea"
"pref.reduceMotion" = "Reducir movimiento"
"pref.renameDuplicates" = "Renombrar capas duplicadas"
+2
View File
@@ -778,6 +778,7 @@ status = "draft — needs native review"
"Main menu" = "Päävalikko"
"Make a component" = "Luo komponentti"
"Make slot" = "Luo sisältöpaikka"
"Scroll wheel zoom" = "Zoomaus hiiren rullalla"
"Slot name" = "Sisältöpaikan nimi"
"Mar" = "maalisk."
"Margin" = "Marginaali"
@@ -2131,6 +2132,7 @@ status = "draft — needs native review"
"pref.language" = "Käyttöliittymän kieli"
"pref.nudge" = "Siirtoaskeleen koko"
"pref.numberKeysOpacity" = "Säädä peittävyyttä numeronäppäimillä"
"pref.scrollWheelZoom" = "Zoomaus hiiren rullalla"
"pref.publishServer" = "Verkkojakamisen palvelin"
"pref.reduceMotion" = "Vähennä liikettä"
"pref.renameDuplicates" = "Nimeä monistetut tasot uudelleen"
+2
View File
@@ -778,6 +778,7 @@ status = "draft — needs native review"
"Main menu" = "Menu principal"
"Make a component" = "Créer un composant"
"Make slot" = "Créer un emplacement"
"Scroll wheel zoom" = "Zoom avec la molette"
"Slot name" = "Nom de l'emplacement"
"Mar" = "mars"
"Margin" = "Marge"
@@ -2132,6 +2133,7 @@ status = "draft — needs native review"
"pref.language" = "Langue de l’interface"
"pref.nudge" = "Pas de décalage"
"pref.numberKeysOpacity" = "Utiliser les touches numériques pour l’opacité"
"pref.scrollWheelZoom" = "Zoom avec la molette"
"pref.publishServer" = "Serveur pour le partage en ligne"
"pref.reduceMotion" = "Réduire les animations"
"pref.renameDuplicates" = "Renommer les calques dupliqués"
+2
View File
@@ -9,6 +9,7 @@ status = "draft — needs native review"
[ui]
"Relative to" = "Relatif terhadap"
"Scroll wheel zoom" = "Zoom dengan roda gulir"
"Trigger layer" = "Layer pemicu"
"Whole screen" = "Seluruh layar"
@@ -2089,6 +2090,7 @@ status = "draft — needs native review"
"pref.language" = "Bahasa antarmuka"
"pref.nudge" = "Jarak geser"
"pref.numberKeysOpacity" = "Gunakan tombol angka untuk opasitas"
"pref.scrollWheelZoom" = "Zoom dengan roda gulir"
"pref.publishServer" = "Server untuk berbagi online"
"pref.reduceMotion" = "Kurangi gerakan"
"pref.renameDuplicates" = "Ganti nama layer duplikat"
+2
View File
@@ -751,6 +751,7 @@ status = "draft — needs native review"
"Main menu" = "Menu principale"
"Make a component" = "Crea un componente"
"Make slot" = "Crea slot"
"Scroll wheel zoom" = "Zoom con la rotella del mouse"
"Slot name" = "Nome dello slot"
"Mar" = "mar"
"Margin" = "Margine"
@@ -2045,6 +2046,7 @@ status = "draft — needs native review"
"pref.language" = "Lingua dell'interfaccia"
"pref.nudge" = "Entità dello spostamento"
"pref.numberKeysOpacity" = "Usa i tasti numerici per l'opacità"
"pref.scrollWheelZoom" = "Zoom con la rotella del mouse"
"pref.publishServer" = "Server per la condivisione online"
"pref.reduceMotion" = "Riduci animazioni"
"pref.renameDuplicates" = "Rinomina i livelli duplicati"
+2
View File
@@ -772,6 +772,7 @@ status = "draft — needs native review"
"Main menu" = "メインメニュー"
"Make a component" = "コンポーネントを作成"
"Make slot" = "スロットにする"
"Scroll wheel zoom" = "スクロールホイールでズーム"
"Slot name" = "スロット名"
"Mar" = "3月"
"Margin" = "マージン"
@@ -2111,6 +2112,7 @@ status = "draft — needs native review"
"pref.language" = "インターフェースの言語"
"pref.nudge" = "微調整の量"
"pref.numberKeysOpacity" = "数字キーで不透明度を変更"
"pref.scrollWheelZoom" = "スクロールホイールでズーム"
"pref.publishServer" = "オンライン共有用のサーバー"
"pref.reduceMotion" = "動きを減らす"
"pref.renameDuplicates" = "複製したレイヤーの名前を変更"
+2
View File
@@ -785,6 +785,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Main menu" = "მთავარი მენიუ"
"Make a component" = "შექმენით კომპონენტი"
"Make slot" = "სლოტად გადაქცევა"
"Scroll wheel zoom" = "მასშტაბირება გადახვევის ბორბლით"
"Slot name" = "სლოტის სახელი"
"Mar" = "მარ"
"Margin" = "ველი"
@@ -2184,6 +2185,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"pref.language" = "ინტერფეისის ენა"
"pref.nudge" = "მცირედ გადაწევის ბიჯი"
"pref.numberKeysOpacity" = "გაუმჭვირვალობისთვის ციფრული კლავიშების გამოყენება"
"pref.scrollWheelZoom" = "მასშტაბირება გადახვევის ბორბლით"
"pref.publishServer" = "ონლაინ გაზიარების სერვერი"
"pref.reduceMotion" = "მოძრაობის შემცირება"
"pref.renameDuplicates" = "დუბლირებული შრეების სახელის შეცვლა"
+2
View File
@@ -736,6 +736,7 @@ status = "draft — needs native review"
"Main component deleted" = "메인 컴포넌트가 삭제됨"
"Main menu" = "메인 메뉴"
"Make slot" = "슬롯으로 만들기"
"Scroll wheel zoom" = "스크롤 휠로 확대/축소"
"Slot name" = "슬롯 이름"
"Margin" = "여백"
"Mark all as read" = "모두 읽음으로 표시"
@@ -2145,6 +2146,7 @@ status = "draft — needs native review"
"pref.language" = "인터페이스 언어"
"pref.nudge" = "미세 이동 거리"
"pref.numberKeysOpacity" = "숫자 키로 불투명도 변경"
"pref.scrollWheelZoom" = "스크롤 휠로 확대/축소"
"pref.publishServer" = "온라인 공유용 서버"
"pref.reduceMotion" = "동작 줄이기"
"pref.renameDuplicates" = "복제한 레이어 이름 바꾸기"
+2
View File
@@ -778,6 +778,7 @@ status = "draft — needs native review"
"Main menu" = "Hoofdmenu"
"Make a component" = "Maak een component"
"Make slot" = "Slot maken"
"Scroll wheel zoom" = "Zoomen met scrollwiel"
"Slot name" = "Slotnaam"
"Mar" = "mrt."
"Margin" = "Marge"
@@ -2130,6 +2131,7 @@ status = "draft — needs native review"
"pref.language" = "Interfacetaal"
"pref.nudge" = "Stapafstand"
"pref.numberKeysOpacity" = "Cijfertoetsen gebruiken voor dekking"
"pref.scrollWheelZoom" = "Zoomen met scrollwiel"
"pref.publishServer" = "Server voor online delen"
"pref.reduceMotion" = "Beweging verminderen"
"pref.renameDuplicates" = "Gedupliceerde lagen hernoemen"
+2
View File
@@ -780,6 +780,7 @@ name = "Polski"
"Main menu" = "Menu główne"
"Make a component" = "Utwórz komponent"
"Make slot" = "Utwórz gniazdo (slot)"
"Scroll wheel zoom" = "Powiększanie kółkiem myszy"
"Slot name" = "Nazwa gniazda"
"Mar" = "Mar"
"Margin" = "Margines"
@@ -2143,6 +2144,7 @@ name = "Polski"
"pref.language" = "Język interfejsu"
"pref.nudge" = "Wartości przesunięcia (Nudge)"
"pref.numberKeysOpacity" = "Klawisze numeryczne zmieniają krycie"
"pref.scrollWheelZoom" = "Powiększanie kółkiem myszy"
"pref.publishServer" = "Serwer do udostępniania online"
"pref.reduceMotion" = "Ogranicz animacje"
"pref.renameDuplicates" = "Zmieniaj nazwy duplikatów"
+2
View File
@@ -735,6 +735,7 @@ status = "draft — needs native review"
"Main component deleted" = "Componente principal excluído"
"Main menu" = "Menu principal"
"Make slot" = "Transformar em slot"
"Scroll wheel zoom" = "Zoom com a roda do mouse"
"Slot name" = "Nome do slot"
"Margin" = "Margem"
"Mark all as read" = "Marcar tudo como lido"
@@ -2142,6 +2143,7 @@ status = "draft — needs native review"
"pref.language" = "Idioma da interface"
"pref.nudge" = "Valor do deslocamento"
"pref.numberKeysOpacity" = "Usar teclas numéricas para a opacidade"
"pref.scrollWheelZoom" = "Zoom com a roda do mouse"
"pref.publishServer" = "Servidor para compartilhamento online"
"pref.reduceMotion" = "Reduzir movimento"
"pref.renameDuplicates" = "Renomear camadas duplicadas"
+2
View File
@@ -780,6 +780,7 @@ name = "Русский"
"Main menu" = "Главное меню"
"Make a component" = "Создать компонент"
"Make slot" = "Сделать слотом"
"Scroll wheel zoom" = "Масштабирование колесом мыши"
"Slot name" = "Имя слота"
"Mar" = "мар."
"Margin" = "Внешний отступ"
@@ -2143,6 +2144,7 @@ name = "Русский"
"pref.language" = "Язык интерфейса"
"pref.nudge" = "Шаг сдвига"
"pref.numberKeysOpacity" = "Задавать непрозрачность цифровыми клавишами"
"pref.scrollWheelZoom" = "Масштабирование колесом мыши"
"pref.publishServer" = "Сервер для онлайн-публикации"
"pref.reduceMotion" = "Уменьшить движение"
"pref.renameDuplicates" = "Переименовывать дубликаты слоёв"
+2
View File
@@ -802,6 +802,7 @@ name = "Svenska"
"Main menu" = "Huvudmeny"
"Make a component" = "Skapa en komponent"
"Make slot" = "Gör till plats"
"Scroll wheel zoom" = "Zooma med scrollhjulet"
"Slot name" = "Platsens namn"
"Mar" = "Mar"
"Margin" = "Marginal"
@@ -2188,6 +2189,7 @@ name = "Svenska"
"pref.language" = "Gränssnittsspråk"
"pref.nudge" = "Steglängd"
"pref.numberKeysOpacity" = "Använd siffertangenterna för opacitet"
"pref.scrollWheelZoom" = "Zooma med scrollhjulet"
"pref.publishServer" = "Server för delning online"
"pref.reduceMotion" = "Minska rörelse"
"pref.renameDuplicates" = "Byt namn på duplicerade lager"
+2
View File
@@ -774,6 +774,7 @@ status = "draft — needs native review"
"Main menu" = "เมนูหลัก"
"Make a component" = "สร้างคอมโพเนนต์"
"Make slot" = "สร้างสล็อต"
"Scroll wheel zoom" = "ซูมด้วยล้อเลื่อนเมาส์"
"Slot name" = "ชื่อสล็อต"
"Mar" = "มี.ค."
"Margin" = "ระยะขอบนอก"
@@ -2107,6 +2108,7 @@ status = "draft — needs native review"
"pref.language" = "ภาษาของอินเทอร์เฟซ"
"pref.nudge" = "ระยะขยับ"
"pref.numberKeysOpacity" = "ใช้ปุ่มตัวเลขกำหนดความทึบ"
"pref.scrollWheelZoom" = "ซูมด้วยล้อเลื่อนเมาส์"
"pref.publishServer" = "เซิร์ฟเวอร์สำหรับการแชร์ออนไลน์"
"pref.reduceMotion" = "ลดการเคลื่อนไหว"
"pref.renameDuplicates" = "เปลี่ยนชื่อเลเยอร์ที่ทำสำเนา"
+2
View File
@@ -765,6 +765,7 @@ name = "Türkçe"
"Main menu" = "Ana menü"
"Make a component" = "Bileşen yap"
"Make slot" = "Yuva yap"
"Scroll wheel zoom" = "Kaydırma tekerleğiyle yakınlaştırma"
"Slot name" = "Yuva adı"
"Mar" = "Mar"
"Margin" = "Kenar boşluğu"
@@ -2089,6 +2090,7 @@ name = "Türkçe"
"pref.language" = "Arayüz dili"
"pref.nudge" = "Kaydırma miktarı"
"pref.numberKeysOpacity" = "Opaklık için rakam tuşlarını kullan"
"pref.scrollWheelZoom" = "Kaydırma tekerleğiyle yakınlaştırma"
"pref.publishServer" = "Çevrimiçi paylaşım sunucusu"
"pref.reduceMotion" = "Hareketi azalt"
"pref.renameDuplicates" = "Çoğaltılan katmanları yeniden adlandır"
+2
View File
@@ -752,6 +752,7 @@ status = "draft — needs native review"
"Main menu" = "Trình đơn chính"
"Make a component" = "Tạo thành phần"
"Make slot" = "Tạo ô chứa"
"Scroll wheel zoom" = "Thu phóng bằng con lăn chuột"
"Slot name" = "Tên ô chứa"
"Mar" = "Thg 3"
"Margin" = "Lề"
@@ -2046,6 +2047,7 @@ status = "draft — needs native review"
"pref.language" = "Ngôn ngữ giao diện"
"pref.nudge" = "Mức dịch chuyển nhẹ"
"pref.numberKeysOpacity" = "Dùng phím số để chỉnh độ mờ"
"pref.scrollWheelZoom" = "Thu phóng bằng con lăn chuột"
"pref.publishServer" = "Máy chủ chia sẻ trực tuyến"
"pref.reduceMotion" = "Giảm chuyển động"
"pref.renameDuplicates" = "Đổi tên các lớp nhân bản"
+2
View File
@@ -741,6 +741,7 @@ status = "draft — needs native review"
"Main component deleted" = "主组件已删除"
"Main menu" = "主菜单"
"Make slot" = "设为插槽"
"Scroll wheel zoom" = "滚轮缩放"
"Slot name" = "插槽名称"
"Margin" = "边距"
"Mark all as read" = "全部标为已读"
@@ -2042,6 +2043,7 @@ status = "draft — needs native review"
"pref.language" = "界面语言"
"pref.nudge" = "微移量"
"pref.numberKeysOpacity" = "用数字键设置不透明度"
"pref.scrollWheelZoom" = "滚轮缩放"
"pref.publishServer" = "在线分享所用的服务器"
"pref.reduceMotion" = "减少动态效果"
"pref.renameDuplicates" = "重命名副本图层"
+6
View File
@@ -81,6 +81,8 @@ pub struct CanvasState {
/// The pen pressure last reported during the current press (pen and stylus touches; the
/// winit backends and browsers that report it).
pressure: Option<f32>,
/// The latest scroll came from a mouse wheel rather than a trackpad (Scroll wheel zoom).
wheel_is_mouse: bool,
}
impl Default for CanvasState {
@@ -107,6 +109,7 @@ impl Default for CanvasState {
hovering: false,
preedit: None,
pressure: None,
wheel_is_mouse: false,
}
}
}
@@ -367,5 +370,8 @@ mod builder_poly_tests;
#[cfg(test)]
mod outline_tests;
#[cfg(test)]
mod wheel_zoom_tests;
#[cfg(test)]
mod zoom_preview_tests;
+53 -3
View File
@@ -25,6 +25,53 @@ fn zoom_tool(app: &mut FigApp, ui: &Ui, resp: &egui::Response, pos: Pos2, mods:
true
}
/// The wheel's scroll and the zoom for this frame. With Preferences ▸ Scroll wheel zoom on, a
/// mouse wheel zooms and Cmd/Ctrl+wheel scrolls ([`swap_wheel`]); Shift+wheel still scrolls
/// sideways, and trackpads, pinches and touch screens are left as they are.
fn wheel_zoom(app: &mut FigApp, ctx: &egui::Context, zoom: f32, shift: bool) -> (egui::Vec2, f32) {
let (wheel, latest, pinch) = ctx.input(|i| {
let latest = i.events.iter().rev().find_map(|e| match e {
egui::Event::MouseWheel { unit, source, .. } => Some(is_mouse_wheel(*source, *unit)),
_ => None,
});
let pinch = i.multi_touch().is_some() || i.events.iter().any(|e| matches!(e, egui::Event::Zoom(_)));
(i.smooth_scroll_delta, latest, pinch)
});
let speed = ctx.options(|o| o.input_options.scroll_zoom_speed);
// egui spreads a wheel step over a few frames: the kind holds until the next scroll event.
if let Some(mouse) = latest {
app.canvas.wheel_is_mouse = mouse;
}
if !app.session.prefs.scroll_wheel_zoom || !app.canvas.wheel_is_mouse || pinch {
return (wheel, zoom);
}
swap_wheel(wheel, zoom, shift, speed)
}
/// Whether a scroll event came from a mouse wheel. Only macOS says which device scrolled; elsewhere
/// a wheel scrolls by lines and a trackpad by points, which is the best guess there is.
pub(crate) fn is_mouse_wheel(source: egui::MouseWheelSource, unit: egui::MouseWheelUnit) -> bool {
match source {
egui::MouseWheelSource::Wheel => true,
egui::MouseWheelSource::Unknown => unit != egui::MouseWheelUnit::Point,
_ => false,
}
}
/// Scroll wheel zoom on a mouse wheel's frame: egui reads Cmd/Ctrl+wheel as `zoom`, which becomes
/// a scroll down (across with Shift: `ViewState::scroll`), and a plain
/// `wheel` scroll becomes a zoom at egui's own Cmd/Ctrl+wheel rate (`speed`). Shift+wheel is
/// left a sideways scroll.
pub(crate) fn swap_wheel(wheel: egui::Vec2, zoom: f32, shift: bool, speed: f32) -> (egui::Vec2, f32) {
if zoom > 0.0 && (zoom - 1.0).abs() > 1e-6 && speed > 0.0 {
return (egui::vec2(0.0, zoom.ln() / speed), 1.0);
}
if !shift && wheel != egui::Vec2::ZERO {
return (egui::Vec2::ZERO, (speed * (wheel.x + wheel.y)).exp());
}
(wheel, zoom)
}
fn import_dropped(app: &mut FigApp, ctx: &egui::Context, hover_pos: Option<egui::Pos2>) {
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
let mut refused = false;
@@ -65,10 +112,12 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo
}
let ctx = ui.ctx().clone();
// Two fingers pan as well as pinch (a touch screen has no scroll wheel).
let (mods, scroll, zoom_delta, hover_pos, space) = ctx.input(|i| {
let (mods, pan, zoom_delta, hover_pos, space) = ctx.input(|i| {
let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |m| m.translation_delta);
(i.modifiers, i.smooth_scroll_delta + pan, i.zoom_delta(), i.pointer.hover_pos(), i.key_down(egui::Key::Space))
(i.modifiers, pan, i.zoom_delta(), i.pointer.hover_pos(), i.key_down(egui::Key::Space))
});
let (wheel, zoom_delta) = wheel_zoom(app, &ctx, zoom_delta, mods.shift);
let scroll = wheel + pan;
let editing_text = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false);
let typing_elsewhere = crate::a11y::field_focused(&ctx);
app.canvas.space_pan = space && !editing_text && !typing_elsewhere;
@@ -103,7 +152,8 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo
app.ui.pages_focus = None;
app.ui.key_box = None;
}
// Zoom (pinch, Cmd/Ctrl+scroll) about the pointer; plain scroll pans.
// Zoom (pinch, Cmd/Ctrl+scroll; a plain mouse wheel with Scroll wheel zoom) about the pointer;
// other scrolls pan.
if resp.hovered() {
if (zoom_delta - 1.0).abs() > 1e-4 {
let anchor = app.to_page(pos);
@@ -0,0 +1,128 @@
//! Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and
//! Cmd/Ctrl+wheel scrolls it; Shift+wheel still scrolls sideways, and trackpads and pinches are
//! unchanged. Off (the default), a wheel scrolls and Cmd/Ctrl+wheel zooms.
use egui::{Event, Modifiers, MouseWheelSource, MouseWheelUnit, Pos2, RawInput, Rect, TouchPhase, Vec2, vec2};
use figcraft_engine::Session;
use serde_json::json;
use super::input::{is_mouse_wheel, swap_wheel};
use crate::{FigApp, NoServices};
/// The view's zoom, cx and cy.
type View = (f64, f64, f64);
struct Canvas {
ctx: egui::Context,
app: FigApp,
time: f64,
}
/// A notch of a mouse wheel (lines, as Windows and Linux report one) or a trackpad scroll (points).
fn wheel(unit: MouseWheelUnit, dy: f32, modifiers: Modifiers) -> Event {
Event::MouseWheel { unit, source: MouseWheelSource::Unknown, delta: vec2(0.0, dy), phase: TouchPhase::Move, modifiers }
}
impl Canvas {
fn new(scroll_wheel_zoom: bool) -> Self {
let mut c = Self { ctx: egui::Context::default(), app: FigApp::new(Session::new(), Box::new(NoServices)), time: 0.0 };
c.app.run("pref.scrollWheelZoom", json!({"on": scroll_wheel_zoom}));
for _ in 0..3 {
c.frame(vec![], Modifiers::NONE);
}
c
}
/// The middle of the canvas (of the window until the canvas is laid out).
fn centre(&self) -> Pos2 {
let r = self.app.canvas.rect;
if r.is_positive() { r.center() } else { Pos2::new(720.0, 450.0) }
}
fn frame(&mut self, mut events: Vec<Event>, modifiers: Modifiers) {
self.time += 1.0 / 60.0;
events.splice(0..0, [Event::ModifiersChanged(modifiers), Event::PointerMoved(self.centre())]);
let input =
RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() };
let app = &mut self.app;
let mut out = self.ctx.run_ui(input, |ui| app.ui(ui));
out.textures_delta.clear();
}
/// `event` over the middle of the canvas, then frames until egui's smoothing settles; the
/// view's (zoom, cx, cy) before and after, and the page point under the pointer before and after.
fn gesture(&mut self, event: Event, modifiers: Modifiers) -> (View, View, (Vec2, Vec2)) {
let view = |c: &Self| c.app.session.ds().map(|d| (d.view.zoom, d.view.cx, d.view.cy)).unwrap();
let under = |c: &Self| {
let p = c.app.to_page(c.centre());
vec2(p.x as f32, p.y as f32)
};
let (before, at) = (view(self), under(self));
self.frame(vec![event], modifiers);
for _ in 0..40 {
self.frame(vec![], modifiers);
}
(before, view(self), (at, under(self)))
}
}
#[test]
fn off_a_wheel_scrolls_and_cmd_or_ctrl_wheel_zooms() {
let mut c = Canvas::new(false);
let (a, b, _) = c.gesture(wheel(MouseWheelUnit::Line, -3.0, Modifiers::NONE), Modifiers::NONE);
assert_eq!(a.0, b.0, "no zoom");
assert!(b.2 > a.2 + 1.0, "scrolled down: {a:?} → {b:?}");
let (a, b, _) = c.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::COMMAND), Modifiers::COMMAND);
assert!(b.0 > a.0 * 1.05, "zoomed in: {a:?} → {b:?}");
}
#[test]
fn on_a_wheel_zooms_about_the_pointer_and_cmd_or_ctrl_wheel_scrolls() {
let mut c = Canvas::new(true);
let (a, b, (at, after)) = c.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::NONE), Modifiers::NONE);
assert!(b.0 > a.0 * 1.05, "zoomed in: {a:?} → {b:?}");
assert!((at - after).length() < 0.5, "about the pointer: {at:?} → {after:?}");
let (a, b, _) = c.gesture(wheel(MouseWheelUnit::Line, -3.0, Modifiers::NONE), Modifiers::NONE);
assert!(b.0 < a.0 / 1.05, "zoomed out: {a:?} → {b:?}");
let (a, b, _) = c.gesture(wheel(MouseWheelUnit::Line, -3.0, Modifiers::COMMAND), Modifiers::COMMAND);
assert!((b.0 - a.0).abs() < 1e-9 && b.2 > a.2 + 1.0 && (b.1 - a.1).abs() < 1e-9, "scrolled down: {a:?} → {b:?}");
let (a, b, _) = c.gesture(wheel(MouseWheelUnit::Line, -3.0, Modifiers::SHIFT), Modifiers::SHIFT);
assert!((b.0 - a.0).abs() < 1e-9 && b.1 > a.1 + 1.0 && (b.2 - a.2).abs() < 1e-9, "scrolled sideways: {a:?} → {b:?}");
}
#[test]
fn on_trackpads_and_pinches_are_unchanged() {
let mut c = Canvas::new(true);
let (a, b, _) = c.gesture(wheel(MouseWheelUnit::Point, -6.0, Modifiers::NONE), Modifiers::NONE);
assert!((b.0 - a.0).abs() < 1e-9 && b.2 > a.2, "two fingers pan: {a:?} → {b:?}");
let (a, b, _) = c.gesture(wheel(MouseWheelUnit::Point, 6.0, Modifiers::COMMAND), Modifiers::COMMAND);
assert!(b.0 > a.0, "Cmd/Ctrl+two fingers zoom: {a:?} → {b:?}");
// A pinch right after a mouse wheel is still a pinch.
c.gesture(wheel(MouseWheelUnit::Line, 1.0, Modifiers::NONE), Modifiers::NONE);
let (a, b, _) = c.gesture(Event::Zoom(1.5), Modifiers::NONE);
assert!((b.0 / a.0 - 1.5).abs() < 1e-3, "pinch: {a:?} → {b:?}");
}
#[test]
fn which_scrolls_count_as_a_mouse_wheel() {
assert!(is_mouse_wheel(MouseWheelSource::Wheel, MouseWheelUnit::Point));
assert!(is_mouse_wheel(MouseWheelSource::Unknown, MouseWheelUnit::Line));
assert!(is_mouse_wheel(MouseWheelSource::Unknown, MouseWheelUnit::Page));
assert!(!is_mouse_wheel(MouseWheelSource::Unknown, MouseWheelUnit::Point));
assert!(!is_mouse_wheel(MouseWheelSource::Trackpad, MouseWheelUnit::Line));
assert!(!is_mouse_wheel(MouseWheelSource::Momentum, MouseWheelUnit::Point));
}
#[test]
fn the_swap_keeps_egui_rates() {
let speed = 1.0 / 200.0;
// Cmd/Ctrl+wheel: egui's zoom back into the scroll it came from.
let (s, z) = swap_wheel(Vec2::ZERO, (speed * 40.0f32).exp(), false, speed);
assert!((s.y - 40.0).abs() < 1e-3 && s.x == 0.0 && z == 1.0);
// A plain wheel: the zoom egui gives Cmd/Ctrl+wheel.
let (s, z) = swap_wheel(vec2(0.0, 40.0), 1.0, false, speed);
assert!(s == Vec2::ZERO && (z - (speed * 40.0f32).exp()).abs() < 1e-6);
// Shift+wheel and nothing at all are left alone.
assert_eq!(swap_wheel(vec2(40.0, 0.0), 1.0, true, speed), (vec2(40.0, 0.0), 1.0));
assert_eq!(swap_wheel(Vec2::ZERO, 1.0, false, speed), (Vec2::ZERO, 1.0));
}
+1
View File
@@ -238,6 +238,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
(tr("Rename duplicated layers"), prefs.rename_duplicates, "pref.renameDuplicates"),
(tr("Show dimensions on objects"), prefs.show_dimensions, "pref.showDimensions"),
(tr("Use number keys for opacity"), prefs.number_keys_opacity, "pref.numberKeysOpacity"),
(tr("Scroll wheel zoom"), prefs.scroll_wheel_zoom, "pref.scrollWheelZoom"),
(tr("Reduce motion"), prefs.reduce_motion, "pref.reduceMotion"),
] {
if crate::widgets::checkbox(ui, val, label) {
+2 -2
View File
@@ -312,8 +312,8 @@ pub(crate) use clock::repaint_in;
pub use clock::{AUTO_SAVE_SECS, AUTOSAVE_SECS, FrameClock, ToastClock};
pub(crate) use open::{drop_overlay, dropped_bytes, is_document, opens};
pub use services::{
ContentsFuture, DialogFuture, MAX_CLIPBOARD_PNG_BYTES, NoServices, ReadFuture, Services, browser_can_read, egui_copy_png, egui_copy_text,
unreadable,
ContentsFuture, DialogFuture, MAX_CLIPBOARD_PNG_BYTES, NoServices, ReadFuture, Services, browser_can_read, browser_stopped, egui_copy_png,
egui_copy_text, unreadable,
};
use services::{DialogDone, PickFuture, Picked, RepaintWaker};
pub use state::{
+15
View File
@@ -124,6 +124,21 @@ pub fn unreadable(name: &str) -> String {
i18n::trf("Couldn't read {name}.", &[("name", &name)])
}
/// What the page says once the browser build stopped on an internal error (in the browser a panic
/// ends the page): `unsaved` files had unsaved changes and `kept` of them fitted in its storage.
pub fn browser_stopped(unsaved: usize, kept: usize) -> String {
let next = if unsaved == 0 {
i18n::tr("Reload the page to start again.")
} else if kept >= unsaved {
i18n::tr("Reload the page to get your unsaved files back.")
} else if kept == 0 {
i18n::tr("Your unsaved files were too large for the browser to keep.")
} else {
i18n::tr("Reload the page to get back the unsaved files the browser had room for.")
};
format!("{} {next}", i18n::tr("FigCraft stopped because of an internal error."))
}
/// The largest file read in the browser: wasm has at most 4 GiB of memory, and a file's contents
/// are held about three times on their way to `file.open` (the bytes, base64, decoded again).
/// As large as one member of a `.figcraft` file may be.
+2 -1
View File
@@ -124,7 +124,8 @@ pub fn with_app<R>(f: impl FnOnce(&mut FigApp) -> R) -> Option<R> {
}
/// Last-resort guard around a frame: a panic becomes a message and recovery copies of unsaved
/// files, not a lost document.
/// files, not a lost document. (In the browser a panic can't be caught: the page's panic hook
/// keeps the copies instead, `apps/figcraft-web/src/recovery.rs`.)
pub fn guard(app: &mut FigApp, ctx: &Context, f: impl FnOnce(&mut FigApp)) {
// Started with automation roots, everything the window and the control channel do is confined.
let roots = app.automation_roots.clone();
+1 -1
View File
@@ -75,7 +75,7 @@ content. Exports: PNG, JPG, WebP, SVG, PDF, code. There is no `.fig` reader yet
Workspace lints deny `unwrap`, `expect`, `panic!` and friends in shipped code; `unsafe_code` is
forbidden. `figcraft_engine::guard` turns a panicking command into an error and rolls the document
back; the desktop app also guards each UI frame. Junk-parameter sweeps run every command with bad
back; the desktop app also guards each UI frame. The browser build can't catch a panic (wasm32 aborts), so there a panic stops the page and its panic hook keeps the files with unsaved changes in local storage for the next page. Junk-parameter sweeps run every command with bad
input.
## Revision history
+3 -2
View File
@@ -1,6 +1,6 @@
# Hardware and platform parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
@@ -44,13 +44,14 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
| Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR. Draws with Direct3D 12 (FXC shaders) or OpenGL; when the graphics fail before the first frame it starts again on Vulkan, then OpenGL, then the power-saving GPU with lower limits (#965) |
| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) |
| FreeBSD | none | tarball | |
| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps) |
| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps); an internal error stops the page (a panic can't be caught there), but its unsaved files are kept in the page's local storage (about 5 MB) and open again on reload (#1123) |
| iOS / Android | apps: view, comment, mirror prototypes | none (the web build is not touch-first) | 20–40 h for a viewer |
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | M8, #1123: the browser can't catch a panic, so an internal error stopped the page and lost its unsaved changes. Its panic hook now keeps the files with unsaved changes in the page's local storage (as many as fit, about 5 MB) and says so on the page; reloading opens them again as unsaved files. Numbers unchanged. |
| 2026-10-30 | minor | M1, #1320: settings in portable mode. The settings folder now comes from craft-settings, the suite's rules: `FIGCRAFT_CONFIG_DIR` names a folder (set but empty: none, nothing read or written); a `portable` file next to the executable (next to the `.app` on macOS) puts the preferences, recovery copies and installed plugins in `config/` beside it; otherwise they stay in `FigCraft` in the platform folder, where every earlier version kept them, so nothing moves for an installed app. The Windows portable zip now ships the `portable` file. A portable folder without preferences yet starts with a copy of the platform folder's `state.json` (owner-only, the original left for an installed app), so a portable copy that kept its settings in `%APPDATA%` before keeps them after the update. Checked on Windows 11 with a portable copy beside an installed copy's settings: the first start took them (its name and start screen), a name changed there was kept in `config\` only, and without the marker the app read the installed settings again. Tests: `settings_dir_tests`. Numbers unchanged |
| 2026-10-30 | minor | M1, #965, #1438: "Parent device is lost" at start. Both reports lost the device while wgpu compiled its indirect-call validation shaders, which it builds with every device: on Windows Direct3D 12 took another program's `dxcompiler.dll` from the search path and failed without its `dxil.dll`; Intel Macs' Metal compiler aborts on them. Direct3D 12 now compiles with FXC (part of Windows; `WGPU_DX12_COMPILER` still chooses), and the validation shaders are left out (FigCraft draws nothing indirectly; `WGPU_VALIDATION_INDIRECT_CALL` turns them back on). When the graphics still fail before the window's first frame, the app starts itself again with the next attempt, from a fixed list: on Windows Direct3D 12/OpenGL, Vulkan, OpenGL, then the first again on the power-saving GPU with WebGL2-level device limits; on Linux and FreeBSD the defaults, OpenGL, then lower requirements; on macOS the defaults, then lower requirements. The last failure's message names what was tried; the log names the backend and GPU that drew. `WGPU_BACKEND` is still the one attempt. A restarted app waits up to 5 s for its `--control` port. Checked on Windows 11 with a local build that made the first one, two, three and then all four attempts fail: each fallback opened the window (Vulkan, OpenGL, Direct3D 12 on the integrated GPU) and four failures ended with the message. Not run on the reporters' machines or on macOS. Tests: `graphics_tests`, `startup_tests`. Numbers unchanged |
| 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged |
+3 -1
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long