mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-11 22:21:58 +00:00
Merge remote-tracking branch 'origin/main' into feat/devmode-tail
# Conflicts: # crates/ui-egui/src/lib.rs # docs/gaps.md
This commit is contained in:
@@ -43,8 +43,9 @@ jobs:
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
# dejavu: the text tests need one real font, as the Ubuntu runner has.
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 dejavu
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
set -e
|
||||
|
||||
Generated
+3
@@ -1681,6 +1681,7 @@ dependencies = [
|
||||
"figcraft-geom",
|
||||
"harfrust 0.13.3",
|
||||
"log",
|
||||
"png",
|
||||
"skrifa 0.47.0",
|
||||
"unicode-bidi",
|
||||
"unicode-segmentation",
|
||||
@@ -1708,6 +1709,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"toml",
|
||||
"wat",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1719,6 +1721,7 @@ dependencies = [
|
||||
"figcraft-collab-net",
|
||||
"figcraft-engine",
|
||||
"figcraft-ui-egui",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
|
||||
@@ -227,7 +227,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
|
||||
- **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust.
|
||||
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
|
||||
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
|
||||
- **Languages:** English and Spanish (a draft waiting for a native speaker's review), picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
|
||||
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
|
||||
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
|
||||
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
|
||||
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
|
||||
|
||||
@@ -1489,6 +1489,8 @@ impl Backend for Store {
|
||||
Admin::Blocked => AdminReply::Blocked(self.blocked()).into(),
|
||||
Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()),
|
||||
Admin::Owned { files } => self.owned(&files).into(),
|
||||
// The hub removes people itself (it knows who is connected); it never asks the store.
|
||||
Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()),
|
||||
Admin::Remove { file } => {
|
||||
if !self.files.contains_key(&file) {
|
||||
return failed("this file isn't on the server (it may have been removed)".into());
|
||||
|
||||
@@ -24,5 +24,6 @@ log = { 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"
|
||||
js-sys = "0.3"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
|
||||
|
||||
@@ -69,9 +69,9 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser services. File dialogs return nothing for now (the trait's defaults). Copies go to the
|
||||
/// system clipboard through egui's output; pastes arrive as paste events with their text (a page
|
||||
/// can't read the clipboard when it likes).
|
||||
/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved
|
||||
/// as downloads. Copies go to the system clipboard through egui's output; pastes arrive as paste
|
||||
/// events with their text (a page can't read the clipboard when it likes).
|
||||
struct WebServices {
|
||||
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
|
||||
reduce_motion: Option<web_sys::MediaQueryList>,
|
||||
@@ -79,6 +79,19 @@ struct WebServices {
|
||||
}
|
||||
|
||||
impl Services for WebServices {
|
||||
fn downloads(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// A Blob behind a temporary `<a download>` link, clicked and removed.
|
||||
fn download(&mut self, name: &str, bytes: &[u8]) -> bool {
|
||||
match download(name, bytes) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::error!("couldn't download {name}: {e:?}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
fn clipboard_set_text(&mut self, s: &str) {
|
||||
figcraft_ui_egui::egui_copy_text(&self.ctx, s);
|
||||
}
|
||||
@@ -116,3 +129,32 @@ impl Services for WebServices {
|
||||
held
|
||||
}
|
||||
}
|
||||
|
||||
fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
let body = document.body().ok_or("no body")?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &{
|
||||
let o = web_sys::BlobPropertyBag::new();
|
||||
o.set_type("application/octet-stream");
|
||||
o
|
||||
})?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob)?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a")?.dyn_into()?;
|
||||
a.set_href(&url);
|
||||
a.set_download(name);
|
||||
body.append_child(&a)?;
|
||||
a.click();
|
||||
a.remove();
|
||||
// Revoked later, not at once: some browsers cancel a download whose URL goes before it starts.
|
||||
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
// Without the timer the URL just stays until the page closes.
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), REVOKE_DOWNLOAD_MS);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How long a download's object URL is kept (milliseconds).
|
||||
const REVOKE_DOWNLOAD_MS: i32 = 40_000;
|
||||
|
||||
@@ -107,7 +107,7 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
|
||||
},
|
||||
Err(_) => {
|
||||
cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
json!({"id": id, "ok": false, "error": "timed out waiting for the UI (is the window visible?)"})
|
||||
json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ mod gamepad;
|
||||
mod graphics;
|
||||
mod recovery;
|
||||
mod services;
|
||||
mod startup;
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod apple_events_tests;
|
||||
@@ -30,6 +31,8 @@ mod recovery_online_tests;
|
||||
mod recovery_tests;
|
||||
#[cfg(test)]
|
||||
mod services_tests;
|
||||
#[cfg(test)]
|
||||
mod startup_tests;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -62,6 +65,7 @@ impl eframe::App for App {
|
||||
}
|
||||
let title = with_app(|fig| {
|
||||
control_server::drain_queue(&self.queue, &mut fig.control_queue);
|
||||
figcraft_ui_egui::control::drain_hidden(fig, ctx);
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events::poll(&self.apple_events, fig, ctx);
|
||||
// Closing the window asks about unsaved changes (and keeps the app running while other
|
||||
@@ -90,9 +94,9 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
let ctx = ui.ctx().clone();
|
||||
with_app(|fig| figcraft_ui_egui::windows::guard(fig, &ctx, |fig| fig.ui(ui)));
|
||||
services::parented(frame, || with_app(|fig| figcraft_ui_egui::windows::guard(fig, &ctx, |fig| fig.ui(ui))));
|
||||
}
|
||||
|
||||
fn on_exit(&mut self) {
|
||||
@@ -166,6 +170,9 @@ fn main() -> eframe::Result {
|
||||
if session.docs.is_empty() && !first_run {
|
||||
let _ = session.execute("file.new", &json!({}));
|
||||
}
|
||||
// The interface language from the first frame on, and for the message when the window can't
|
||||
// open (no frame then).
|
||||
figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref());
|
||||
let mut fig = FigApp::new(session, Box::new(svc));
|
||||
fig.ui.home = false;
|
||||
fig.note_recovered(recovered.copies);
|
||||
@@ -177,8 +184,11 @@ fn main() -> eframe::Result {
|
||||
control_server::start(p, queue.clone(), ctx_slot.clone());
|
||||
}
|
||||
let icon = services::window_icon();
|
||||
// The app id is the Wayland app_id and the X11 window class on Linux: the desktop entry's name
|
||||
// and StartupWMClass (FreeBSD's package matches the binary's name instead).
|
||||
let mut viewport = egui::ViewportBuilder::default()
|
||||
.with_title("FigCraft")
|
||||
.with_app_id("ai.storyteller.figcraft")
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([800.0, 500.0])
|
||||
.with_drag_and_drop(true);
|
||||
@@ -203,7 +213,7 @@ fn main() -> eframe::Result {
|
||||
let apple_events = apple_events::AppleEvents::install();
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = &apple_events;
|
||||
eframe::run_native(
|
||||
let result = eframe::run_native(
|
||||
"FigCraft",
|
||||
options,
|
||||
Box::new(move |_cc| {
|
||||
@@ -216,5 +226,10 @@ fn main() -> eframe::Result {
|
||||
apple_events: apple_events.connect(&_cc.egui_ctx),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
);
|
||||
// Say why instead of quitting silently: release builds on Windows have no console.
|
||||
if let Err(e) = &result {
|
||||
startup::failed(e, port.is_some());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
+147
-71
@@ -4,7 +4,10 @@
|
||||
//! in the config folder, in the background, at most every [`INTERVAL`] seconds, and the copy goes
|
||||
//! once the document is saved or closed. The app holds a lock on its folder. At startup, the folders
|
||||
//! whose lock is free belong to runs that ended without closing: their copies open again as
|
||||
//! unsaved documents (`file.recover`) and the folder goes. Closing the app normally removes its own.
|
||||
//! unsaved documents (`file.recover`). Each such copy **stays until this run has its own copy of
|
||||
//! the document** (or it is saved or closed): until then it is the only copy on disk, and a run
|
||||
//! that can't keep copies of its own leaves it for the next start. Then it goes, and its folder
|
||||
//! with its last copy. Closing the app normally removes its own.
|
||||
//!
|
||||
//! A document that is one of your online files has a note beside its copy (`<uid>.online`, written
|
||||
//! after the copy it describes): the server, the file's id, and who the app was in the file with
|
||||
@@ -22,7 +25,8 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::{DocState, Session};
|
||||
use figcraft_format::{Branch, Version};
|
||||
use serde_json::json;
|
||||
|
||||
/// Seconds between copies of a document that keeps changing.
|
||||
@@ -52,35 +56,50 @@ pub struct Recovery {
|
||||
/// The state of the copy of one document: only a write that succeeded counts as a copy.
|
||||
#[derive(Default)]
|
||||
struct Copy {
|
||||
/// The revision whose copy was written.
|
||||
done: Option<u64>,
|
||||
/// The write in flight: the revision it copies, and whether it succeeded once it ends.
|
||||
flight: Option<(u64, JoinHandle<bool>)>,
|
||||
/// The [`stamp`] whose copy was written.
|
||||
done: Option<Vec<u64>>,
|
||||
/// The write in flight: the stamp it copies, and whether it succeeded once it ends.
|
||||
flight: Option<(Vec<u64>, JoinHandle<bool>)>,
|
||||
}
|
||||
|
||||
impl Copy {
|
||||
/// Take in the result of the write in flight once it ended; a failed one leaves its revision uncopied.
|
||||
fn reap(&mut self) {
|
||||
if self.flight.as_ref().is_some_and(|f| f.1.is_finished())
|
||||
&& let Some((revision, h)) = self.flight.take()
|
||||
&& h.join().unwrap_or(false)
|
||||
{
|
||||
self.done = Some(revision);
|
||||
if self.flight.as_ref().is_some_and(|f| f.1.is_finished()) {
|
||||
self.join();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `revision` has neither been copied nor is being copied.
|
||||
fn pending(&self, revision: u64) -> bool {
|
||||
self.done != Some(revision) && self.flight.as_ref().is_none_or(|f| f.0 != revision)
|
||||
/// Wait for the write in flight and take in its result.
|
||||
fn join(&mut self) {
|
||||
if let Some((stamp, h)) = self.flight.take()
|
||||
&& h.join().unwrap_or(false)
|
||||
{
|
||||
self.done = Some(stamp);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `stamp` has neither been copied nor is being copied.
|
||||
fn pending(&self, stamp: &[u64]) -> bool {
|
||||
self.done.as_deref() != Some(stamp) && self.flight.as_ref().is_none_or(|f| f.0 != stamp)
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy of one of your online files whose file is being joined again as session `session`.
|
||||
/// A copy from an earlier run, kept until what it became is safe.
|
||||
pub struct Waiting {
|
||||
session: u64,
|
||||
until: Until,
|
||||
copy: PathBuf,
|
||||
/// The lock of the folder it is in: no other start takes the copy meanwhile.
|
||||
_lock: Arc<File>,
|
||||
/// The lock of the folder it is in: no other start takes the copy meanwhile (none for a copy
|
||||
/// left directly in the root by older versions).
|
||||
_lock: Option<Arc<File>>,
|
||||
}
|
||||
|
||||
/// What a kept copy waits for.
|
||||
enum Until {
|
||||
/// One of your online files, joined again as this session: its outcome.
|
||||
Session(u64),
|
||||
/// Opened as document `uid`: this run's own copy of it (or its save or close).
|
||||
Copied(u64),
|
||||
}
|
||||
|
||||
/// What [`recover_all`] brought back.
|
||||
@@ -88,21 +107,26 @@ pub struct Waiting {
|
||||
pub struct Recovered {
|
||||
/// Documents opened from their copies.
|
||||
pub copies: usize,
|
||||
/// Copies of online files waiting for their file ([`Recovery::adopt`]).
|
||||
/// The copies kept until what they became is safe ([`Recovery::adopt`]).
|
||||
pub waiting: Vec<Waiting>,
|
||||
}
|
||||
|
||||
impl Recovery {
|
||||
/// Take this run's folder under `root` (`None` when it can't be created or locked: no copies).
|
||||
/// A crashed run with the same process id may still hold its folder (its copies kept): then
|
||||
/// another name is taken.
|
||||
pub fn start(root: &Path) -> Option<Self> {
|
||||
let dir = root.join(std::process::id().to_string());
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
let lock = File::create(dir.join(LOCK)).ok()?;
|
||||
lock.try_lock().ok()?;
|
||||
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() })
|
||||
let pid = std::process::id();
|
||||
(0..10).find_map(|n| {
|
||||
let dir = root.join(if n == 0 { pid.to_string() } else { format!("{pid}-{n}") });
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).ok()?;
|
||||
lock.try_lock().ok()?;
|
||||
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() })
|
||||
})
|
||||
}
|
||||
|
||||
/// Keep earlier runs' copies of online files until their outcome is known.
|
||||
/// Keep earlier runs' copies until what they became is safe.
|
||||
pub fn adopt(&mut self, waiting: Vec<Waiting>) {
|
||||
self.waiting.extend(waiting);
|
||||
}
|
||||
@@ -111,10 +135,12 @@ impl Recovery {
|
||||
/// were saved or closed. Returns how long until it needs to run again (`None`: nothing pending,
|
||||
/// an idle window can sleep).
|
||||
pub fn tick(&mut self, s: &Session, now: f64) -> Option<f64> {
|
||||
self.settle(s);
|
||||
self.written.values_mut().for_each(Copy::reap);
|
||||
// A join on its way settles within its timeouts: look again soon.
|
||||
let settling = (!self.waiting.is_empty()).then_some(0.5);
|
||||
self.settle(s);
|
||||
// A join on its way settles within its timeouts: look again soon. A copy waiting for this
|
||||
// run's own is looked at again with the next copies.
|
||||
let joining = self.waiting.iter().any(|w| matches!(w.until, Until::Session(_)));
|
||||
let settling = if joining { Some(0.5) } else { (!self.waiting.is_empty()).then_some(INTERVAL) };
|
||||
if now < self.next {
|
||||
return self.pending(s).then_some(self.next - now).or(settling);
|
||||
}
|
||||
@@ -126,29 +152,30 @@ impl Recovery {
|
||||
|
||||
/// Whether a document with unsaved changes has a revision that is neither copied nor being copied.
|
||||
fn pending(&self, s: &Session) -> bool {
|
||||
s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.pending(d.revision)))
|
||||
s.docs.iter().any(|d| needs_copy(s, d) && self.written.get(&d.uid).is_none_or(|w| w.pending(&stamp(s, d))))
|
||||
}
|
||||
|
||||
/// Remove earlier runs' copies whose outcome is known, once what they became is copied here.
|
||||
fn settle(&mut self, s: &Session) {
|
||||
if self.waiting.iter().all(|w| s.live.recovering(w.session)) {
|
||||
return;
|
||||
/// Remove earlier runs' copies once what they became is safe: copied here (the write
|
||||
/// succeeded), saved or closed. An online file's copy goes once its outcome is known and every
|
||||
/// document is safe, since which of them it became isn't known.
|
||||
pub fn settle(&mut self, s: &Session) {
|
||||
let joined = |w: &Waiting| matches!(w.until, Until::Session(id) if !s.live.recovering(id));
|
||||
if self.waiting.iter().any(joined) {
|
||||
self.write(s, true);
|
||||
}
|
||||
self.write(s, true);
|
||||
let (done, waiting): (Vec<Waiting>, Vec<Waiting>) =
|
||||
std::mem::take(&mut self.waiting).into_iter().partition(|w| !s.live.recovering(w.session));
|
||||
let all_safe = s.docs.iter().all(|d| self.safe(s, d));
|
||||
let (done, waiting): (Vec<Waiting>, Vec<Waiting>) = std::mem::take(&mut self.waiting).into_iter().partition(|w| match w.until {
|
||||
Until::Session(_) => joined(w) && all_safe,
|
||||
Until::Copied(uid) => s.docs.iter().find(|d| d.uid == uid).is_none_or(|d| self.safe(s, d)),
|
||||
});
|
||||
self.waiting = waiting;
|
||||
for w in done {
|
||||
let _ = std::fs::remove_file(&w.copy);
|
||||
let _ = std::fs::remove_file(w.copy.with_extension(ONLINE));
|
||||
// The folder goes with its last copy (while another waits there its lock is held).
|
||||
let dir = w.copy.parent().map(Path::to_path_buf).filter(|_| Arc::strong_count(&w._lock) == 1);
|
||||
drop(w);
|
||||
if let Some(dir) = dir {
|
||||
let _ = std::fs::remove_file(dir.join(LOCK));
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
}
|
||||
}
|
||||
done.into_iter().for_each(remove_kept);
|
||||
}
|
||||
|
||||
/// Whether document `d`'s work is on disk: saved (or nothing to keep), or copied here (a write
|
||||
/// that succeeded) with no write in flight.
|
||||
fn safe(&self, s: &Session, d: &DocState) -> bool {
|
||||
!needs_copy(s, d) || self.written.get(&d.uid).is_some_and(|w| w.done.is_some() && w.flight.is_none())
|
||||
}
|
||||
|
||||
/// Copy every document with unsaved changes; `wait`: before returning (after a panic).
|
||||
@@ -156,27 +183,36 @@ impl Recovery {
|
||||
for d in &s.docs {
|
||||
let path = self.dir.join(format!("{}.figcraft", d.uid));
|
||||
let note = path.with_extension(ONLINE);
|
||||
if !needs_copy(d) {
|
||||
if self.written.remove(&d.uid).is_some() {
|
||||
if !needs_copy(s, d) {
|
||||
if let Some(mut w) = self.written.remove(&d.uid) {
|
||||
// A write still running would bring the copy back after it was removed.
|
||||
w.join();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(¬e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let entry = self.written.entry(d.uid).or_default();
|
||||
entry.reap();
|
||||
// An emergency save must copy the newest revision: let the write in flight finish first.
|
||||
if wait {
|
||||
entry.join();
|
||||
} else {
|
||||
entry.reap();
|
||||
}
|
||||
// A write still in flight (for any revision) is waited for; a failed one is tried again.
|
||||
if entry.done == Some(d.revision) || entry.flight.is_some() {
|
||||
let stamp = stamp(s, d);
|
||||
if entry.done.as_ref() == Some(&stamp) || entry.flight.is_some() {
|
||||
continue;
|
||||
}
|
||||
let doc = d.doc.clone();
|
||||
let (versions, branches) = (d.versions.clone(), figcraft_engine::branches::current(s, d.uid));
|
||||
// Which online file it is and what of the server's this very copy has (a line each:
|
||||
// the server, the file's id, then the session, client id and last change), while it
|
||||
// is one. Written after the copy, never before: a note must not promise more of the
|
||||
// server's changes than its copy holds.
|
||||
let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq));
|
||||
let job = move || {
|
||||
if !write_atomic(&path, &doc) {
|
||||
if !write_atomic(&path, &doc, &versions, &branches) {
|
||||
return false;
|
||||
}
|
||||
let _ = match online {
|
||||
@@ -185,21 +221,22 @@ impl Recovery {
|
||||
};
|
||||
true
|
||||
};
|
||||
// The revision counts as copied only once its write succeeded.
|
||||
// The stamp counts as copied only once its write succeeded.
|
||||
if wait {
|
||||
if job() {
|
||||
entry.done = Some(d.revision);
|
||||
entry.done = Some(stamp);
|
||||
}
|
||||
} else {
|
||||
entry.flight = std::thread::Builder::new().name("recovery".into()).spawn(job).ok().map(|h| (d.revision, h));
|
||||
entry.flight = std::thread::Builder::new().name("recovery".into()).spawn(job).ok().map(|h| (stamp, h));
|
||||
}
|
||||
}
|
||||
// Closed documents.
|
||||
let open: Vec<u64> = s.docs.iter().map(|d| d.uid).collect();
|
||||
let dir = &self.dir;
|
||||
self.written.retain(|uid, _| {
|
||||
self.written.retain(|uid, w| {
|
||||
let keep = open.contains(uid);
|
||||
if !keep {
|
||||
w.join();
|
||||
let _ = std::fs::remove_file(dir.join(format!("{uid}.figcraft")));
|
||||
let _ = std::fs::remove_file(dir.join(format!("{uid}.{ONLINE}")));
|
||||
}
|
||||
@@ -207,10 +244,18 @@ impl Recovery {
|
||||
});
|
||||
}
|
||||
|
||||
/// Test hook: pretend a write of `uid` at `revision` is in flight in `writer`.
|
||||
#[cfg(test)]
|
||||
pub fn hold(&mut self, uid: u64, revision: u64, writer: JoinHandle<bool>) {
|
||||
self.written.insert(uid, Copy { done: None, flight: Some((vec![revision], writer)) });
|
||||
}
|
||||
|
||||
/// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier
|
||||
/// runs' copies still waiting for their outcome stay for the next start.)
|
||||
pub fn finish(self) {
|
||||
let Self { dir, _lock, written, .. } = self;
|
||||
let Self { dir, _lock, written, waiting, .. } = self;
|
||||
// The documents earlier runs' copies became were saved or discarded too.
|
||||
waiting.into_iter().filter(|w| matches!(w.until, Until::Copied(_))).for_each(remove_kept);
|
||||
// Windows removes a folder only once nothing in it is open: copies being written first.
|
||||
for (_, h) in written.into_values().filter_map(|w| w.flight) {
|
||||
let _ = h.join();
|
||||
@@ -220,6 +265,20 @@ impl Recovery {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a kept copy and its note, and its folder with its last copy (while another is kept
|
||||
/// there, its lock is held).
|
||||
fn remove_kept(w: Waiting) {
|
||||
let _ = std::fs::remove_file(&w.copy);
|
||||
let _ = std::fs::remove_file(w.copy.with_extension(ONLINE));
|
||||
let last = w._lock.as_ref().is_some_and(|l| Arc::strong_count(l) == 1);
|
||||
let dir = w.copy.parent().map(Path::to_path_buf).filter(|_| last);
|
||||
drop(w);
|
||||
if let Some(dir) = dir {
|
||||
let _ = std::fs::remove_file(dir.join(LOCK));
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// The extension of the note beside the copy of a document that is one of your online files.
|
||||
const ONLINE: &str = "online";
|
||||
|
||||
@@ -236,15 +295,27 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option<u64> {
|
||||
s.execute("collab.recoverFile", ¶ms).ok()?.get("session")?.as_u64()
|
||||
}
|
||||
|
||||
/// A document whose changes would be lost (a branch tab's edits are in its main file).
|
||||
fn needs_copy(d: &figcraft_engine::DocState) -> bool {
|
||||
d.is_dirty() && d.branch_of.is_none()
|
||||
/// 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))
|
||||
}
|
||||
|
||||
/// Write `doc` 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) -> bool {
|
||||
let Ok(bytes) = figcraft_format::save_bytes(doc, None) else { return false };
|
||||
/// 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 {
|
||||
let Ok(bytes) = figcraft_format::save_bytes_with_history(doc, None, versions, branches) else { return false };
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes).is_ok() && std::fs::rename(&tmp, path).is_ok()
|
||||
}
|
||||
@@ -257,9 +328,9 @@ pub fn recover(root: &Path, s: &mut Session) -> usize {
|
||||
}
|
||||
|
||||
/// Open the copies left by runs that ended without closing (folders under `root` whose lock is
|
||||
/// free, and copies directly in `root` from older versions) as unsaved documents, then remove them.
|
||||
/// A copy that can't be read stays where it is. A copy of one of your online files is kept
|
||||
/// instead while its file is joined again ([`Recovered::waiting`]).
|
||||
/// free, and copies directly in `root` from older versions) as unsaved documents. Each is kept
|
||||
/// until this run has its own copy of the document ([`Recovered::waiting`]); a copy of one of your
|
||||
/// online files, while its file is joined again. A copy that can't be read stays where it is.
|
||||
pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
|
||||
let mut out = Recovered::default();
|
||||
let Ok(entries) = std::fs::read_dir(root) else { return out };
|
||||
@@ -269,10 +340,15 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
|
||||
}
|
||||
// One of your online files comes back from its server, its copy kept until then.
|
||||
if let Some((session, lock)) = lock.and_then(|l| Some((rejoin_online(file, s)?, l))) {
|
||||
out.waiting.push(Waiting { session, copy: file.to_path_buf(), _lock: lock.clone() });
|
||||
} else if s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() {
|
||||
out.waiting.push(Waiting { until: Until::Session(session), copy: file.to_path_buf(), _lock: Some(lock.clone()) });
|
||||
return;
|
||||
}
|
||||
let before: Vec<u64> = s.docs.iter().map(|d| d.uid).collect();
|
||||
if s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() {
|
||||
out.copies += 1;
|
||||
let _ = std::fs::remove_file(file);
|
||||
if let Some(uid) = s.docs.iter().map(|d| d.uid).find(|u| !before.contains(u)) {
|
||||
out.waiting.push(Waiting { until: Until::Copied(uid), copy: file.to_path_buf(), _lock: lock.cloned() });
|
||||
}
|
||||
}
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
@@ -300,7 +376,7 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
|
||||
for f in files.iter().filter(|f| f.extension().is_some_and(|e| e == ONLINE) && !f.with_extension("figcraft").exists()) {
|
||||
let _ = std::fs::remove_file(f);
|
||||
}
|
||||
// Empty now unless a copy couldn't be read, or waits for its file (kept, its lock held).
|
||||
// Empty now unless a copy couldn't be read, or is kept (its lock held).
|
||||
if Arc::strong_count(&lock) == 1 {
|
||||
drop(lock);
|
||||
let _ = std::fs::remove_file(path.join(LOCK));
|
||||
|
||||
@@ -223,6 +223,30 @@ fn a_copy_ahead_of_the_server_has_its_edits_applied_and_sent() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_online_copy_stays_while_what_it_became_cant_be_copied() {
|
||||
let root = root("ahead-blocked");
|
||||
let (mut s, script, mut server, _) = shared();
|
||||
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
script.lock().unwrap().sent.clear();
|
||||
let left = crash(&s, &root);
|
||||
let (mut next, script, mut r, _) = restart(&s, &root);
|
||||
arrive(&mut next, &script, &mut server);
|
||||
assert!(next.docs[1].is_dirty(), "the copy's edit is on top, not saved anywhere yet");
|
||||
// Something is in the way of this run's copy: writing it fails.
|
||||
let mine = root.join(std::process::id().to_string()).join(format!("{}.figcraft", next.docs[1].uid));
|
||||
std::fs::create_dir_all(&mine).unwrap();
|
||||
r.tick(&next, 1.0);
|
||||
assert_eq!((copies(&left, "figcraft"), copies(&left, "online")), (1, 1), "kept: the work is nowhere else on disk");
|
||||
// Once it can be written, the old copy goes.
|
||||
std::fs::remove_dir(&mine).unwrap();
|
||||
next.execute("shape.rectangle", &json!({"x": 50, "y": 0, "width": 4, "height": 4})).unwrap();
|
||||
r.tick(&next, 2.0);
|
||||
assert!(!left.exists());
|
||||
drop(r);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_the_server_already_has_is_discarded() {
|
||||
let root = root("equal");
|
||||
@@ -332,7 +356,7 @@ fn without_its_record_the_copy_opens_as_any_other() {
|
||||
assert_eq!(recover(&root, &mut next), 1);
|
||||
assert!(script.lock().unwrap().joined.is_empty());
|
||||
assert!(next.docs.first().is_some_and(|d| d.is_dirty() && d.path.is_none()));
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "the folder is gone");
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_some(), "the copy stays until a run has its own");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::recovery::{Recovery, recover};
|
||||
use crate::recovery::{Recovery, recover, recover_all};
|
||||
|
||||
fn root(name: &str) -> std::path::PathBuf {
|
||||
let r = std::env::temp_dir().join(format!("figcraft-recovery-{name}-{}", std::process::id()));
|
||||
@@ -34,7 +34,7 @@ fn a_crashed_runs_unsaved_documents_come_back() {
|
||||
let ds = next.docs.first().unwrap();
|
||||
assert!(ds.is_dirty() && ds.path.is_none());
|
||||
assert_eq!(next.doc().unwrap().node(next.doc().unwrap().pages[0]).map(|p| p.children.len()), Some(1));
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "the folder is gone");
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_some(), "the copy stays until a run has its own");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,239 @@ fn copies_follow_saves_and_a_normal_quit_leaves_nothing() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A run that ended without closing, leaving a copy of `s`'s unsaved document: its folder (as
|
||||
/// another process's).
|
||||
fn crashed(s: &Session, root: &std::path::Path) -> std::path::PathBuf {
|
||||
let mut r = Recovery::start(root).unwrap();
|
||||
r.write(s, true);
|
||||
drop(r);
|
||||
let left = root.join("crashed");
|
||||
std::fs::rename(root.join(std::process::id().to_string()), &left).unwrap();
|
||||
left
|
||||
}
|
||||
|
||||
fn copies_in(d: &std::path::Path) -> usize {
|
||||
std::fs::read_dir(d).map_or(0, |d| d.flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count())
|
||||
}
|
||||
|
||||
/// The next start: the copies come back, and this run's recovery keeps them until it covers them.
|
||||
fn restart(root: &std::path::Path) -> (Session, Recovery) {
|
||||
let mut next = Session::empty();
|
||||
let recovered = recover_all(root, &mut next);
|
||||
assert_eq!(recovered.copies, 1);
|
||||
let mut r = Recovery::start(root).unwrap();
|
||||
r.adopt(recovered.waiting);
|
||||
(next, r)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recovered_copy_stays_until_this_run_has_its_own_copy() {
|
||||
let root = root("handoff");
|
||||
let left = crashed(&edited(), &root);
|
||||
let (next, mut r) = restart(&root);
|
||||
assert_eq!(copies_in(&left), 1, "the only copy on disk until this run writes one");
|
||||
r.write(&next, true);
|
||||
r.tick(&next, 100.0);
|
||||
assert!(!left.exists(), "covered by this run's copy, the old one goes");
|
||||
assert_eq!(copies_in(&root.join(std::process::id().to_string())), 1);
|
||||
r.finish();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recovered_copy_stays_while_its_replacement_cant_be_written() {
|
||||
let root = root("blocked");
|
||||
let left = crashed(&edited(), &root);
|
||||
let (next, mut r) = restart(&root);
|
||||
// Something is in the way of this run's copy: writing it fails.
|
||||
let mine = root.join(std::process::id().to_string()).join(format!("{}.figcraft", next.docs[0].uid));
|
||||
std::fs::create_dir_all(&mine).unwrap();
|
||||
r.write(&next, true);
|
||||
r.tick(&next, 100.0);
|
||||
assert_eq!(copies_in(&left), 1, "kept: the work is nowhere else on disk");
|
||||
// Once it can be written, the old copy goes.
|
||||
std::fs::remove_dir(&mine).unwrap();
|
||||
let mut next = next;
|
||||
next.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 2, "height": 2})).unwrap();
|
||||
r.write(&next, true);
|
||||
r.tick(&next, 200.0);
|
||||
assert!(!left.exists());
|
||||
r.finish();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_recovery_of_its_own_the_copy_stays_for_the_next_start() {
|
||||
let root = root("nocopies");
|
||||
crashed(&edited(), &root);
|
||||
let mut next = Session::empty();
|
||||
let recovered = recover_all(&root, &mut next);
|
||||
assert_eq!(recovered.copies, 1);
|
||||
// This run couldn't take a folder of its own: it ends (or crashes) with nothing copied.
|
||||
drop(recovered);
|
||||
let mut after = Session::empty();
|
||||
assert_eq!(recover(&root, &mut after), 1, "the work comes back again");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recovered_copy_goes_once_its_document_is_saved_or_closed() {
|
||||
for close in [false, true] {
|
||||
let root = root(if close { "closed" } else { "saved" });
|
||||
let left = crashed(&edited(), &root);
|
||||
let (mut next, mut r) = restart(&root);
|
||||
if close {
|
||||
next.docs.clear();
|
||||
} else {
|
||||
next.ds_mut().unwrap().mark_saved();
|
||||
}
|
||||
r.tick(&next, 0.0);
|
||||
assert!(!left.exists(), "close {close}");
|
||||
r.finish();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_normal_quit_removes_the_recovered_copies_it_took_over() {
|
||||
let root = root("quitafter");
|
||||
let left = crashed(&edited(), &root);
|
||||
let (_, r) = restart(&root);
|
||||
// Quitting asked to save or discard every document.
|
||||
r.finish();
|
||||
assert!(!left.exists());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_whose_folder_name_is_still_held_takes_another() {
|
||||
let root = root("pidreuse");
|
||||
// A crashed run with this process's id whose folder the start still holds (its copy waits).
|
||||
let held = root.join(std::process::id().to_string());
|
||||
std::fs::create_dir_all(&held).unwrap();
|
||||
let lock = std::fs::File::create(held.join("lock")).unwrap();
|
||||
lock.try_lock().unwrap();
|
||||
let mut r = Recovery::start(&root).expect("a folder of its own");
|
||||
let s = edited();
|
||||
r.write(&s, true);
|
||||
assert_eq!(copies_in(&held), 0);
|
||||
let dirs = std::fs::read_dir(&root).unwrap().flatten().filter(|e| e.path() != held).count();
|
||||
assert_eq!(dirs, 1);
|
||||
r.finish();
|
||||
drop(lock);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// The rectangles in `doc`.
|
||||
fn rectangles(doc: &figcraft_engine::doc::Document) -> usize {
|
||||
doc.nodes().filter(|n| matches!(n.kind, figcraft_engine::doc::NodeKind::Rectangle)).count()
|
||||
}
|
||||
|
||||
/// The documents a crashed run's copies under `root` open as.
|
||||
fn crash_and_recover(r: Recovery, root: &std::path::Path) -> Session {
|
||||
drop(r);
|
||||
let mut next = Session::empty();
|
||||
assert_eq!(recover(root, &mut next), 1);
|
||||
next
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_keep_the_version_history_and_branches() {
|
||||
// #185: a copy has what a save would write, the latest edits on a branch tab included.
|
||||
let root = root("history");
|
||||
let mut s = edited();
|
||||
s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap();
|
||||
s.execute("branch.create", &json!({"name": "Explore"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
r.write(&s, true);
|
||||
let next = crash_and_recover(r, &root);
|
||||
let ds = next.docs.first().unwrap();
|
||||
assert_eq!(ds.versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["v1"]);
|
||||
assert_eq!(ds.branches.iter().map(|b| (b.name.as_str(), rectangles(&b.head))).collect::<Vec<_>>(), [("Explore", 2)]);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// The one copy under `root` (the run's folder), read back.
|
||||
fn copy(root: &std::path::Path) -> figcraft_format::Loaded {
|
||||
let dir = std::fs::read_dir(root).unwrap().flatten().next().unwrap().path();
|
||||
let file = std::fs::read_dir(dir).unwrap().flatten().map(|f| f.path()).find(|f| f.extension().is_some_and(|e| e == "figcraft")).unwrap();
|
||||
figcraft_format::load_bytes_with_versions(&std::fs::read(file).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_only_on_a_branch_tab_or_to_the_history_is_copied() {
|
||||
let root = root("history-only");
|
||||
let mut s = edited();
|
||||
s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap();
|
||||
s.execute("branch.create", &json!({"name": "Explore"})).unwrap();
|
||||
for d in &mut s.docs {
|
||||
d.mark_saved();
|
||||
}
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
// An edit on the branch tab only: the main file has no changes of its own yet.
|
||||
s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
r.write(&s, true);
|
||||
assert_eq!(copy(&root).branches.iter().map(|b| rectangles(&b.head)).collect::<Vec<_>>(), [2]);
|
||||
s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
r.write(&s, true);
|
||||
assert_eq!(copy(&root).branches.iter().map(|b| rectangles(&b.head)).collect::<Vec<_>>(), [3]);
|
||||
// A version renamed on the main file leaves its document as it was.
|
||||
s.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
r.write(&s, true);
|
||||
let id = s.docs[0].versions[0].id.clone();
|
||||
s.execute("version.rename", &json!({"id": id, "name": "v1 (renamed)"})).unwrap();
|
||||
r.write(&s, true);
|
||||
assert_eq!(copy(&root).versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["v1 (renamed)"]);
|
||||
r.finish();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
fn slow_write(path: std::path::PathBuf) -> std::thread::JoinHandle<bool> {
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
std::fs::write(path, b"stale").is_ok()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_or_closing_waits_for_a_slow_write_so_the_copy_does_not_come_back() {
|
||||
let root = root("slow-cleanup");
|
||||
let mut s = edited();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path();
|
||||
let uid = s.docs[0].uid;
|
||||
let copy = dir.join(format!("{uid}.figcraft"));
|
||||
r.hold(uid, 1, slow_write(copy.clone()));
|
||||
s.ds_mut().unwrap().mark_saved();
|
||||
r.write(&s, false);
|
||||
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||
assert!(!copy.exists(), "a saved document's copy stays removed");
|
||||
// Closed: the same.
|
||||
let s = edited();
|
||||
let uid = s.docs[0].uid;
|
||||
let copy = dir.join(format!("{uid}.figcraft"));
|
||||
r.hold(uid, 1, slow_write(copy.clone()));
|
||||
r.write(&Session::empty(), false);
|
||||
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||
assert!(!copy.exists(), "a closed document's copy stays removed");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_emergency_save_waits_for_the_write_in_flight_and_copies_the_newest_revision() {
|
||||
let root = root("slow-emergency");
|
||||
let s = edited();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path();
|
||||
let uid = s.docs[0].uid;
|
||||
let copy = dir.join(format!("{uid}.figcraft"));
|
||||
r.hold(uid, 0, slow_write(copy.clone()));
|
||||
r.write(&s, true);
|
||||
assert!(std::fs::read(©).is_ok_and(|b| b != b"stale"), "the newest revision replaced the slow write");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// The files named `*.figcraft` in the run folder under `root`.
|
||||
fn copy_count(root: &std::path::Path) -> usize {
|
||||
let Some(dir) = std::fs::read_dir(root).unwrap().flatten().next().map(|e| e.path()) else { return 0 };
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Desktop services: native file dialogs, clipboard, URLs, persisted preferences.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use figcraft_engine::{Prefs, Session};
|
||||
use figcraft_ui_egui::{FigApp, Services};
|
||||
use figcraft_ui_egui::{DialogFuture, FigApp, Services};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub struct Desktop {
|
||||
@@ -198,15 +199,39 @@ fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
f.write_all(bytes)
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// While the main window draws: a dialog parented to it, so a dialog opened from it stays in
|
||||
/// front of it (a sheet on macOS). eframe gives other windows no handle: theirs have no parent.
|
||||
static PARENT: RefCell<Option<rfd::AsyncFileDialog>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Run the main window's frame `f` with the dialogs it opens parented to `frame`'s window.
|
||||
pub fn parented<R>(frame: &eframe::Frame, f: impl FnOnce() -> R) -> R {
|
||||
PARENT.with(|p| p.replace(Some(rfd::AsyncFileDialog::new().set_parent(frame))));
|
||||
let r = f();
|
||||
PARENT.with(|p| p.replace(None));
|
||||
r
|
||||
}
|
||||
|
||||
/// A dialog with `title`, parented to the main window while it draws.
|
||||
fn dialog(title: &str) -> rfd::AsyncFileDialog {
|
||||
PARENT.with(|p| p.borrow().clone()).unwrap_or_default().set_title(title)
|
||||
}
|
||||
|
||||
/// The dialog opens now; its future gives the path picked.
|
||||
fn picked(pick: impl Future<Output = Option<rfd::FileHandle>> + 'static) -> DialogFuture {
|
||||
Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) })
|
||||
}
|
||||
|
||||
impl Services for Desktop {
|
||||
fn pick_open(&mut self, title: &str, exts: &[&str]) -> Option<PathBuf> {
|
||||
rfd::FileDialog::new().set_title(title).add_filter("Supported files", exts).pick_file()
|
||||
fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture {
|
||||
picked(dialog(title).add_filter("Supported files", exts).pick_file())
|
||||
}
|
||||
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> Option<PathBuf> {
|
||||
rfd::FileDialog::new().set_title(title).set_file_name(name).add_filter("File", exts).save_file()
|
||||
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture {
|
||||
picked(dialog(title).set_file_name(name).add_filter("File", exts).save_file())
|
||||
}
|
||||
fn pick_folder(&mut self, title: &str) -> Option<PathBuf> {
|
||||
rfd::FileDialog::new().set_title(title).pick_folder()
|
||||
fn pick_folder(&mut self, title: &str) -> DialogFuture {
|
||||
picked(dialog(title).pick_folder())
|
||||
}
|
||||
fn clipboard_set_text(&mut self, s: &str) {
|
||||
if let Some(c) = self.clipboard.as_mut() {
|
||||
@@ -246,6 +271,8 @@ impl Services for Desktop {
|
||||
fn emergency_save(&mut self, session: &Session) {
|
||||
if let Some(r) = crate::recovery::lock(&self.recovery).as_mut() {
|
||||
r.write(session, true);
|
||||
// Earlier runs' copies now covered go, so the next start doesn't open them twice.
|
||||
r.settle(session);
|
||||
}
|
||||
}
|
||||
fn reduce_motion(&self) -> bool {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! When the window can't open.
|
||||
//!
|
||||
//! Release builds on Windows have no console, so an error from `eframe::run_native` (no usable
|
||||
//! graphics adapter, a surface or device the driver refuses) went to a standard error nobody sees
|
||||
//! and the app quit without a word (#261). Now it is logged and shown in a message box.
|
||||
|
||||
/// Log the failure and, unless `quiet` (started for an agent with `--control`: a modal box would
|
||||
/// keep the caller waiting, and it reads standard error), show it in a message box.
|
||||
pub fn failed(error: &eframe::Error, quiet: bool) {
|
||||
// In the interface language: the text and its catalog entries are the UI crate's.
|
||||
let text = figcraft_ui_egui::dialogs::window_failed(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some());
|
||||
// Standard error even when RUST_LOG turned errors off.
|
||||
if log::log_enabled!(log::Level::Error) {
|
||||
log::error!("{text}");
|
||||
} else {
|
||||
eprintln!("figcraft: {text}");
|
||||
}
|
||||
if quiet {
|
||||
return;
|
||||
}
|
||||
let _ = rfd::MessageDialog::new()
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.set_title("FigCraft")
|
||||
.set_description(text)
|
||||
.set_buttons(rfd::MessageButtons::Ok)
|
||||
.show();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! The message shown when the window can't open: it carries the error, a next step and where to
|
||||
//! report it, instead of the app quitting silently (#261).
|
||||
|
||||
use figcraft_ui_egui::dialogs::window_failed as message;
|
||||
|
||||
const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}";
|
||||
|
||||
#[test]
|
||||
fn the_message_carries_the_error_and_where_to_report_it() {
|
||||
let text = message(ERROR, false);
|
||||
assert!(text.contains(ERROR), "the error itself: {text}");
|
||||
assert!(text.contains("https://github.com/storytold/figcraft/issues"), "where to report it: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_wgpu_backend_it_suggests_the_driver_and_another_graphics_api() {
|
||||
let text = message(ERROR, false);
|
||||
assert!(text.contains("graphics driver"), "{text}");
|
||||
assert!(text.contains("WGPU_BACKEND"), "another graphics API to try: {text}");
|
||||
assert!(!text.contains("unsetting"), "{text}");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn on_windows_it_shows_how_to_start_with_opengl() {
|
||||
let text = message(ERROR, false);
|
||||
assert!(text.contains("set WGPU_BACKEND=gl"), "a release build has no console to look this up from: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_wgpu_backend_set_it_suggests_unsetting_it() {
|
||||
let text = message(ERROR, true);
|
||||
assert!(text.contains("unsetting"), "{text}");
|
||||
assert!(!text.contains("graphics driver"), "the chosen backend is the likelier cause: {text}");
|
||||
}
|
||||
@@ -17,13 +17,25 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.VanTan666]
|
||||
display_name = "VanTan"
|
||||
|
||||
[people.tylerjenningsw]
|
||||
real_name = "Tyler Jennings"
|
||||
display_name = "tylerjenningsw"
|
||||
|
||||
[people.VanTan666]
|
||||
display_name = "VanTan"
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.yyamandevrim]
|
||||
real_name = "Yaman Devrim"
|
||||
sync_github_name = true
|
||||
|
||||
[people.justinjohn0306]
|
||||
real_name = "Justin John"
|
||||
|
||||
[people.Losminek]
|
||||
|
||||
[people.RevDonz]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Addresses removed from a file are kept only while its session lasts, so a long-running server
|
||||
//! doesn't collect them for every file it ever had open.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
use figcraft_collab::{Limits, Server};
|
||||
use figcraft_doc::Document;
|
||||
|
||||
use super::{Admission, Backend, Closing, Hub, InFlight, Room};
|
||||
|
||||
/// A backend that closes a file as soon as everyone left it.
|
||||
struct Closes;
|
||||
|
||||
impl Backend for Closes {
|
||||
fn admit(&mut self, _file: &str, _token: &str) -> Result<Admission, String> {
|
||||
Err("no".into())
|
||||
}
|
||||
|
||||
fn open(&mut self, _file: &str) -> Result<Server, String> {
|
||||
Err("no".into())
|
||||
}
|
||||
|
||||
fn idle(&mut self, _file: &str, _server: &mut Server) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn closing(&self) -> Closing {
|
||||
Closing::Restarting
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_file_forgets_whom_it_barred() {
|
||||
let mut doc = Document::new("File");
|
||||
doc.repair();
|
||||
let mut hub = Hub {
|
||||
in_flight: InFlight(Default::default()),
|
||||
backend: Closes,
|
||||
rooms: HashMap::from([("f".to_string(), Room::new(Server::new(doc, "", Limits::default())))]),
|
||||
conns: HashMap::new(),
|
||||
local: None,
|
||||
outbox: Vec::new(),
|
||||
barred: HashMap::new(),
|
||||
};
|
||||
let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 7));
|
||||
hub.barred.entry("f".into()).or_default().insert(ip);
|
||||
hub.barred.entry("other".into()).or_default().insert(ip);
|
||||
hub.close_if_empty("f");
|
||||
assert!(!hub.rooms.contains_key("f"), "everyone left: the file closed");
|
||||
assert!(!hub.barred.contains_key("f"), "and its barred addresses went with it");
|
||||
assert!(hub.barred.contains_key("other"), "another file's stay");
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use figcraft_collab::{Outgoing, Server, same_secret};
|
||||
use figcraft_doc::Document;
|
||||
use figcraft_engine::live::{Event, PEOPLE};
|
||||
|
||||
pub use crate::hub::REMOVED;
|
||||
use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name, to_local};
|
||||
use crate::inbox::Inbox;
|
||||
pub use crate::join::ENDED;
|
||||
@@ -129,6 +130,12 @@ impl Host {
|
||||
self.hosting.send(msg);
|
||||
}
|
||||
|
||||
/// Remove client `id` from the session: they are disconnected and can't join again from
|
||||
/// their address while it runs. The host can't remove themselves.
|
||||
pub fn remove(&self, id: u32) {
|
||||
self.hosting.remove(id);
|
||||
}
|
||||
|
||||
/// Cut every connection as a network failure would (no close frame).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn cut_connections(&self) {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
//! backend may refuse addresses or limit what they do. A plain `GET /healthz` on the same port is
|
||||
//! answered `200 ok` for uptime checks.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::net::{IpAddr, Ipv6Addr, SocketAddr, TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
@@ -413,6 +413,8 @@ enum HubIn {
|
||||
},
|
||||
Local(Presence),
|
||||
LocalMsg(ClientMsg),
|
||||
/// The person hosting removes client `id` from their file (app sessions).
|
||||
Remove(u32),
|
||||
/// Cut every connection without a word (tests: a network failure).
|
||||
#[cfg(test)]
|
||||
Cut,
|
||||
@@ -461,7 +463,15 @@ impl Hosting {
|
||||
pub(crate) fn start<B: Backend>(listener: TcpListener, backend: B, limits: NetLimits, local: Option<Local>) -> io::Result<Hosting> {
|
||||
let addr = listener.local_addr()?;
|
||||
let in_flight = InFlight(Arc::default());
|
||||
let mut hub = Hub { backend, rooms: HashMap::new(), conns: HashMap::new(), local: None, outbox: Vec::new(), in_flight: in_flight.clone() };
|
||||
let mut hub = Hub {
|
||||
backend,
|
||||
rooms: HashMap::new(),
|
||||
conns: HashMap::new(),
|
||||
local: None,
|
||||
outbox: Vec::new(),
|
||||
in_flight: in_flight.clone(),
|
||||
barred: HashMap::new(),
|
||||
};
|
||||
if let Some(l) = local {
|
||||
let mut room = Room::new(l.server);
|
||||
room.local = Some((l.id, l.inbox));
|
||||
@@ -551,6 +561,12 @@ impl Hosting {
|
||||
let _ = self.hub.send(HubIn::LocalMsg(msg));
|
||||
}
|
||||
|
||||
/// The hosting person removes client `id` from their file (app sessions; see
|
||||
/// [`HubIn::Remove`]).
|
||||
pub(crate) fn remove(&self, id: u32) {
|
||||
let _ = self.hub.send(HubIn::Remove(id));
|
||||
}
|
||||
|
||||
/// Cut every connection as a network failure would (no close frame).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn cut_connections(&self) {
|
||||
@@ -1108,6 +1124,16 @@ fn unsupported(protocol: u32) -> String {
|
||||
/// sharing): the session's end for them.
|
||||
pub const STOPPED: &str = "Sharing was stopped: the link you joined with no longer works. Your copy stays open";
|
||||
|
||||
/// What someone removed from a file is told: the session's end for them (they keep their copy;
|
||||
/// nothing retries).
|
||||
pub const REMOVED: &str = "You were removed from this session. Your copy stays open";
|
||||
|
||||
/// Why a removed person's address can't join the file again.
|
||||
const BARRED: &str = "you were removed from this session";
|
||||
|
||||
/// The most addresses one file keeps barred.
|
||||
const MAX_BARRED: usize = 256;
|
||||
|
||||
/// What everyone in a file is told when its changes couldn't be saved (they reconnect).
|
||||
const UNSAVED: &str = "The server couldn't save the latest changes; reconnecting";
|
||||
|
||||
@@ -1121,6 +1147,9 @@ struct Hub<B> {
|
||||
local: Option<String>,
|
||||
/// What the servers said, sent once the backend made what changed durable.
|
||||
outbox: Vec<(String, Outgoing)>,
|
||||
/// Addresses ([`ip_key`]) removed from each open file: they can't join it again until its
|
||||
/// session closes (the file is closed or removed; the entry goes with it).
|
||||
barred: HashMap<String, HashSet<IpAddr>>,
|
||||
}
|
||||
|
||||
impl<B: Backend> Hub<B> {
|
||||
@@ -1208,6 +1237,13 @@ impl<B: Backend> Hub<B> {
|
||||
self.client_message(&file, id, msg, None);
|
||||
}
|
||||
}
|
||||
HubIn::Remove(id) => {
|
||||
if let Some(file) = self.local.clone()
|
||||
&& let Err(why) = self.remove_peer(&file, id)
|
||||
{
|
||||
log::info!("live session: couldn't remove client {id}: {why}");
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
HubIn::Cut => {
|
||||
for c in self.conns.values() {
|
||||
@@ -1232,6 +1268,14 @@ impl<B: Backend> Hub<B> {
|
||||
self.client_message(&file, id, msg, Some(bytes));
|
||||
}
|
||||
Some(Kind::Hosting { owns }) => match msg {
|
||||
ClientMsg::Admin(Admin::RemovePeer { file, client }) => {
|
||||
let reply = if owns.as_deref() == Some(file.as_str()) {
|
||||
self.removed_reply(&file, client)
|
||||
} else {
|
||||
AdminReply::Failed { reason: "an owner token manages only its own file".into() }
|
||||
};
|
||||
self.answer(conn, &ServerMsg::Admin(Box::new(reply)));
|
||||
}
|
||||
ClientMsg::Admin(req) => {
|
||||
let owns = owns.clone();
|
||||
let rooms = &self.rooms;
|
||||
@@ -1243,6 +1287,10 @@ impl<B: Backend> Hub<B> {
|
||||
_ => self.kick(&[conn], "only publishing and managing your own files are spoken on this connection"),
|
||||
},
|
||||
Some(Kind::Managing) => match msg {
|
||||
ClientMsg::Admin(Admin::RemovePeer { file, client }) => {
|
||||
let reply = self.removed_reply(&file, client);
|
||||
self.answer(conn, &ServerMsg::Admin(Box::new(reply)));
|
||||
}
|
||||
ClientMsg::Admin(req) => self.admin(conn, req),
|
||||
ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong),
|
||||
_ => self.kick(&[conn], "only requests to manage files are spoken on this connection"),
|
||||
@@ -1305,6 +1353,10 @@ impl<B: Backend> Hub<B> {
|
||||
Ok(a) => a,
|
||||
Err(why) => return refuse(&c.out, &why),
|
||||
};
|
||||
if self.barred.get(&hello.file).is_some_and(|b| b.contains(&c.ip)) {
|
||||
log::info!("event=refused ip={} reason=removed", c.ip);
|
||||
return refuse(&c.out, BARRED);
|
||||
}
|
||||
let hosting = reads_hosting(&hello.client_version);
|
||||
// The backend checked the secret: the sessions don't keep it.
|
||||
hello.token.clear();
|
||||
@@ -1362,6 +1414,34 @@ impl<B: Backend> Hub<B> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove client `id` from `file`: they are told the session is over for them and
|
||||
/// disconnected, and their address can't join the file again while its session lasts. The
|
||||
/// person hosting can't be removed.
|
||||
fn remove_peer(&mut self, file: &str, id: u32) -> Result<(), String> {
|
||||
let room = self.rooms.get(file).ok_or("nobody is in this file")?;
|
||||
if room.local.as_ref().is_some_and(|(local, _)| *local == id) {
|
||||
return Err("the person hosting can't be removed".into());
|
||||
}
|
||||
let conn = *room.ids.get(&id).ok_or_else(|| format!("nobody with client id {id} is in this file"))?;
|
||||
let c = self.conns.get(&conn).ok_or_else(|| format!("nobody with client id {id} is in this file"))?;
|
||||
let barred = self.barred.entry(file.to_string()).or_default();
|
||||
if barred.len() < MAX_BARRED {
|
||||
barred.insert(c.ip);
|
||||
}
|
||||
log::info!("event=removed file={file} client={id} ip={}", c.ip);
|
||||
let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into()));
|
||||
self.gone(conn);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`remove_peer`](Self::remove_peer) as a managing connection's answer.
|
||||
fn removed_reply(&mut self, file: &str, id: u32) -> AdminReply {
|
||||
match self.remove_peer(file, id) {
|
||||
Ok(()) => AdminReply::Done,
|
||||
Err(reason) => AdminReply::Failed { reason },
|
||||
}
|
||||
}
|
||||
|
||||
/// Take in connection `c` as `kind` and send it `first`.
|
||||
fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) {
|
||||
self.conns.insert(conn, Conn { out: c.out, sock: c.sock, kind, ip: c.ip });
|
||||
@@ -1479,6 +1559,7 @@ impl<B: Backend> Hub<B> {
|
||||
.collect();
|
||||
// Closed without saving: the file is gone.
|
||||
self.rooms.remove(&file);
|
||||
self.barred.remove(&file);
|
||||
self.kick(&users, "this file was removed from the server");
|
||||
}
|
||||
if let Some(ip) = handled.blocked {
|
||||
@@ -1556,6 +1637,7 @@ impl<B: Backend> Hub<B> {
|
||||
let Some(room) = self.rooms.get_mut(file).filter(|r| r.empty()) else { return };
|
||||
if self.backend.idle(file, &mut room.server) {
|
||||
self.rooms.remove(file);
|
||||
self.barred.remove(file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1661,3 +1743,7 @@ pub(crate) fn to_local(inbox: &Inbox, local: u32, msg: &ServerMsg) {
|
||||
#[cfg(test)]
|
||||
#[path = "queue_tests.rs"]
|
||||
mod queue_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "barred_tests.rs"]
|
||||
mod barred_tests;
|
||||
|
||||
@@ -243,6 +243,18 @@ impl LiveLink for NetLink {
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&mut self, session: SessionId, client: u32) -> Result<(), String> {
|
||||
match self.sessions.get(session) {
|
||||
Some(Active::Host(h)) if h.me().client_id == client => Err("you can't remove yourself: stop the session instead".into()),
|
||||
Some(Active::Host(h)) => {
|
||||
h.remove(client);
|
||||
Ok(())
|
||||
}
|
||||
Some(Active::Guest(_)) => Err("only the person hosting can remove people from this session".into()),
|
||||
None => Err("this session is over".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn leave(&mut self, session: SessionId) {
|
||||
if let Some(active) = self.sessions.remove(session) {
|
||||
active.end();
|
||||
|
||||
@@ -15,7 +15,7 @@ use tungstenite::protocol::WebSocket;
|
||||
use tungstenite::{Bytes, Message};
|
||||
|
||||
use crate::guest::{Guest, GuestConfig};
|
||||
use crate::host::{ENDED, Host, HostConfig};
|
||||
use crate::host::{ENDED, Host, HostConfig, REMOVED};
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::Invite;
|
||||
use crate::net::{Bucket, NetLimits};
|
||||
@@ -611,3 +611,34 @@ fn until_presence_or_pong(r: &mut Raw) -> Option<ServerMsg> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_removes_someone_and_they_cant_come_back() {
|
||||
let (mut host, mut hp, invite) = start();
|
||||
let (_a, mut ap) = join(&invite, "Ada");
|
||||
let (b, mut bp) = join(&invite, "Bo");
|
||||
ap.wait("welcome", welcomed);
|
||||
bp.wait("welcome", welcomed);
|
||||
let (ada, bo) = (ap.me(), bp.me());
|
||||
connections_settle(&host, 2);
|
||||
// The host can't remove themselves, nor someone who isn't there: nothing happens.
|
||||
host.remove(host.me().client_id);
|
||||
host.remove(9999);
|
||||
host.remove(ada);
|
||||
// Ada is told the session is over for her (no reconnecting); the others see her leave.
|
||||
ap.wait("Ada removed", |e| e.iter().any(|e| matches!(e, Event::Ended { reason } if reason == REMOVED)));
|
||||
hp.wait("the host sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada)));
|
||||
bp.wait("Bo sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada)));
|
||||
connections_settle(&host, 1);
|
||||
assert!(!ap.events.iter().any(|e| matches!(e, Event::Status(Status::Reconnecting { .. }))), "a removed guest doesn't reconnect");
|
||||
assert!(!hp.left().contains(&bo), "only Ada was removed");
|
||||
// Her address can't join again with the same invite (every guest here comes from 127.0.0.1).
|
||||
let (_again, mut againp) = join(&invite, "Ada");
|
||||
againp.wait("a refusal", |e| e.iter().any(|e| matches!(e, Event::Status(Status::Failed { reason }) if reason.contains("removed"))));
|
||||
assert!(!welcomed(&againp.events));
|
||||
// Bo, already in, stays and can still talk to the host.
|
||||
b.presence(presence(3.0, 4.0));
|
||||
hp.wait("Bo's cursor", |e| e.iter().any(|e| matches!(e, Event::Presence(id, _) if *id == bo)));
|
||||
host.stop();
|
||||
assert_eq!(host.connections(), 0);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
||||
ClientMsg::Admin(Admin::Block { ip: "203.0.113.9".into() }),
|
||||
ClientMsg::Admin(Admin::Unblock { ip: "2001:db8::".into() }),
|
||||
ClientMsg::Admin(Admin::Blocked),
|
||||
ClientMsg::Admin(Admin::RemovePeer { file: "f".into(), client: 7 }),
|
||||
ClientMsg::Report { reason: "Spam ☕".into() },
|
||||
];
|
||||
let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap();
|
||||
|
||||
@@ -274,6 +274,10 @@ pub enum Admin {
|
||||
/// these `(file id, owner token)` pairs own, at most [`MAX_OWNED`] of them:
|
||||
/// [`AdminReply::Owned`]. Servers older than this request can't read it.
|
||||
Owned { files: Vec<(String, String)> },
|
||||
/// Remove client `client` from `file`'s session: they are disconnected and their address
|
||||
/// can't join the file again while its session lasts. With the server key, or on an open
|
||||
/// hosting connection with `file`'s owner token. Servers older than this request can't read it.
|
||||
RemovePeer { file: String, client: u32 },
|
||||
}
|
||||
|
||||
/// The most files one [`Admin::Owned`] asks about.
|
||||
|
||||
@@ -73,3 +73,52 @@ fn autosaves_keep_their_flag_in_the_file() {
|
||||
assert!(!autosave(&mut t));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_that_save_themselves_still_get_checkpoints() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-autosave-on-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("auto.figcraft").to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
s.execute("pref.autoSave", &json!({"on": true})).unwrap();
|
||||
rect(&mut s, 0.0);
|
||||
s.execute("file.save", &json!({"path": path})).unwrap();
|
||||
assert!(autosave(&mut s), "saved, but changed since the last version");
|
||||
assert!(!autosave(&mut s), "no change since the checkpoint");
|
||||
// Just opened: nothing to checkpoint.
|
||||
let mut t = Session::empty();
|
||||
t.execute("pref.autoSave", &json!({"on": true})).unwrap();
|
||||
t.execute("file.open", &json!({"path": path})).unwrap();
|
||||
assert!(!autosave(&mut t));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_encoded_elsewhere_is_saved_as_of_its_snapshot() {
|
||||
use crate::cmd::file::FileSnapshot;
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-snapshot-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("snap.figcraft").to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.save", &json!({"path": path})).unwrap();
|
||||
rect(&mut s, 0.0);
|
||||
let file = FileSnapshot::of(s.ds().unwrap());
|
||||
let bytes = file.encode().unwrap();
|
||||
rect(&mut s, 50.0);
|
||||
assert!(file.save(&mut s, &bytes).unwrap());
|
||||
assert!(s.ds().unwrap().is_dirty(), "the edit since the snapshot is still unsaved");
|
||||
let mut t = Session::empty();
|
||||
t.execute("file.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 10.0, "the file is the snapshot");
|
||||
// Saved meanwhile: the older bytes never overwrite the newer file.
|
||||
let file = FileSnapshot::of(s.ds().unwrap());
|
||||
let bytes = file.encode().unwrap();
|
||||
rect(&mut s, 100.0);
|
||||
s.execute("file.save", &json!({})).unwrap();
|
||||
assert!(!file.save(&mut s, &bytes).unwrap());
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
let mut t = Session::empty();
|
||||
t.execute("file.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 110.0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn id(value: &serde_json::Value) -> crate::NodeId {
|
||||
crate::params::id_of(&value["id"]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_steps_target_layers_created_by_earlier_steps() {
|
||||
let mut s = Session::new();
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"label": "Card",
|
||||
"commands": [
|
||||
{"command": "node.create", "params": {"type": "frame", "x": 0, "y": 0, "width": 200, "height": 100}},
|
||||
{"command": "node.create", "params": {"type": "rectangle", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}},
|
||||
{"command": "node.set", "params": {"ids": [{"$step": 1}], "props": {"name": "Swatch"}}}
|
||||
]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let frame = id(&results[0]);
|
||||
let rect = id(&results[1]);
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.node(rect).unwrap().parent, Some(frame));
|
||||
assert_eq!(doc.node(rect).unwrap().name, "Swatch");
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), 1, "still one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_picks_another_part_of_the_result() {
|
||||
let mut s = Session::new();
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"commands": [
|
||||
{"command": "node.create", "params": {"type": "frame", "width": 50, "height": 50}},
|
||||
{"command": "node.create", "params": {"type": "text", "text": "hi", "parent": {"$step": 0, "field": "id"}}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(id(&results[1])).unwrap().parent, Some(id(&results[0])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_references_fail_and_roll_back() {
|
||||
let mut s = Session::new();
|
||||
let before = s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len();
|
||||
let frame = json!({"command": "node.create", "params": {"type": "frame", "width": 10, "height": 10}});
|
||||
for (bad, needle) in [
|
||||
(json!({"$step": 1}), "earlier step"),
|
||||
(json!({"$step": 5}), "earlier step"),
|
||||
(json!({"$step": -1}), "step index"),
|
||||
(json!({"$step": "0"}), "step index"),
|
||||
(json!({"$step": 0, "field": "nope"}), "no `nope`"),
|
||||
(json!({"$step": 0, "field": 3}), "`field` must be a string"),
|
||||
(json!({"$step": 0, "extra": true}), "not `extra`"),
|
||||
] {
|
||||
let r = s.execute("command.batch", &json!({"commands": [frame, {"command": "node.set", "params": {"ids": [bad], "props": {"name": "x"}}}]}));
|
||||
match r {
|
||||
Err(EngineError::BadParams { msg, .. }) => assert!(msg.contains(needle), "{msg} should mention {needle}"),
|
||||
other => panic!("expected BadParams for {bad}, got {other:?}"),
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), before, "rolled back");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_params_are_refused_not_overflowed() {
|
||||
let mut s = Session::new();
|
||||
let mut deep = json!(1);
|
||||
for _ in 0..200 {
|
||||
deep = json!([deep]);
|
||||
}
|
||||
let r = s.execute("command.batch", &json!({"commands": [{"command": "select.none", "params": {"x": deep}}]}));
|
||||
assert!(matches!(r, Err(EngineError::BadParams { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_that_looks_like_a_reference_is_left_alone() {
|
||||
let mut s = Session::new();
|
||||
let results = s
|
||||
.execute("command.batch", &json!({"commands": [{"command": "node.create", "params": {"type": "text", "text": "$step 0 {\"$step\": 0}"}}]}))
|
||||
.unwrap();
|
||||
let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone();
|
||||
assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == "$step 0 {\"$step\": 0}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_batch_refers_to_its_own_steps() {
|
||||
let mut s = Session::new();
|
||||
let frame = |name: &str| json!({"command": "node.create", "params": {"type": "frame", "name": name, "width": 100, "height": 100}});
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"commands": [
|
||||
frame("Outer"),
|
||||
{"command": "command.batch", "params": {"commands": [
|
||||
frame("Inner"),
|
||||
{"command": "node.create", "params": {"type": "rectangle", "parent": {"$step": 0}, "width": 10, "height": 10}}
|
||||
]}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let inner = &results[1];
|
||||
let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone();
|
||||
assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own first frame, not the outer one");
|
||||
}
|
||||
@@ -156,6 +156,22 @@ pub(crate) fn sync(s: &mut Session) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The branches of the file in tab `uid` as saving it now would write them: [`sync`] only puts a
|
||||
/// branch tab's latest edits in before the next command.
|
||||
pub fn current(s: &Session, uid: u64) -> Vec<Branch> {
|
||||
let Some(ds) = s.docs.iter().find(|d| d.uid == uid) else { return Vec::new() };
|
||||
let mut branches = ds.branches.clone();
|
||||
for d in &s.docs {
|
||||
let Some((_, id)) = d.branch_of.as_ref().filter(|(main, _)| *main == uid) else { continue };
|
||||
let Some(b) = branches.iter_mut().find(|b| b.id == *id) else { continue };
|
||||
b.head = d.document_itself().clone();
|
||||
if d.versions_changed {
|
||||
b.versions = d.versions.clone();
|
||||
}
|
||||
}
|
||||
branches
|
||||
}
|
||||
|
||||
/// 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)
|
||||
|
||||
@@ -988,6 +988,16 @@ fn batch_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"names": names.iter().map(|(i, n)| json!({"id": i, "name": n})).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Char index of the first case-insensitive occurrence of `needle` in `hay` at or after `from`.
|
||||
/// Works on char indices (what `replace_range` takes), so lowercasing never shifts offsets.
|
||||
fn find_ci(hay: &[char], needle: &[char], from: usize) -> Option<usize> {
|
||||
if needle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
(from..hay.len())
|
||||
.find(|&i| hay.get(i..i + needle.len()).is_some_and(|w| w.iter().zip(needle).all(|(a, b)| a == b || a.to_lowercase().eq(b.to_lowercase()))))
|
||||
}
|
||||
|
||||
fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let find = req_string("edit.findReplace", p, "find")?;
|
||||
if find.is_empty() {
|
||||
@@ -998,11 +1008,16 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let page = s.page()?;
|
||||
let doc = s.doc_mut()?;
|
||||
let mut hits = Vec::new();
|
||||
let lower = find.to_lowercase();
|
||||
let needle: Vec<char> = find.chars().collect();
|
||||
let r_chars: Vec<char> = replace.as_deref().map(|r| r.chars().collect()).unwrap_or_default();
|
||||
for i in doc.subtree(page) {
|
||||
let Some(n) = doc.node(i) else { continue };
|
||||
let text_hit = matches!(&n.kind, NodeKind::Text(t) if t.characters.to_lowercase().contains(&lower));
|
||||
let name_hit = names && n.name.to_lowercase().contains(&lower);
|
||||
let text: Option<Vec<char>> = match &n.kind {
|
||||
NodeKind::Text(t) => Some(t.characters.chars().collect()),
|
||||
_ => None,
|
||||
};
|
||||
let text_hit = text.as_deref().is_some_and(|c| find_ci(c, &needle, 0).is_some());
|
||||
let name_hit = names && find_ci(&n.name.chars().collect::<Vec<_>>(), &needle, 0).is_some();
|
||||
if !(text_hit || name_hit) || n.is_page() {
|
||||
continue;
|
||||
}
|
||||
@@ -1010,26 +1025,27 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(r) = &replace {
|
||||
let n = doc.get_mut(i)?;
|
||||
if names && name_hit {
|
||||
n.name = n.name.replace(find.as_str(), r);
|
||||
let chars: Vec<char> = n.name.chars().collect();
|
||||
let mut out = String::new();
|
||||
let mut at = 0;
|
||||
while let Some(pos) = find_ci(&chars, &needle, at) {
|
||||
out.extend(chars.get(at..pos).unwrap_or_default());
|
||||
out.push_str(r);
|
||||
at = pos + needle.len();
|
||||
}
|
||||
out.extend(chars.get(at..).unwrap_or_default());
|
||||
n.name = out;
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
// Replace occurrence by occurrence so styles shift correctly.
|
||||
// The search resumes after each inserted replacement, so a replacement that
|
||||
// contains `find` neither loops forever nor stops the other occurrences.
|
||||
let mut guard = 0;
|
||||
if let (NodeKind::Text(t), Some(mut chars)) = (&mut n.kind, text) {
|
||||
// Replace occurrence by occurrence so styles shift correctly. The search resumes
|
||||
// after each inserted replacement, so a replacement that matches `find` (in any
|
||||
// case) neither loops forever nor stops the other occurrences.
|
||||
let mut cursor = 0;
|
||||
let len = find.chars().count();
|
||||
let r_len = r.chars().count();
|
||||
loop {
|
||||
let from = t.characters.char_indices().nth(cursor).map_or(t.characters.len(), |(b, _)| b);
|
||||
let Some(pos) = t.characters.get(from..).and_then(|tail| tail.find(find.as_str())) else { break };
|
||||
let start = cursor + t.characters.get(from..from + pos).map_or(0, |x| x.chars().count());
|
||||
t.replace_range(start, start + len, r);
|
||||
cursor = start + r_len;
|
||||
guard += 1;
|
||||
if guard > 10_000 {
|
||||
break;
|
||||
}
|
||||
for _ in 0..10_000 {
|
||||
let Some(start) = find_ci(&chars, &needle, cursor) else { break };
|
||||
t.replace_range(start, start + needle.len(), r);
|
||||
chars.splice(start..start + needle.len(), r_chars.iter().copied());
|
||||
cursor = start + r_chars.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"version.autosave",
|
||||
"Autosave version",
|
||||
"{} save an automatic checkpoint when the document has unsaved changes since the last version (the app does this every 30 minutes); keeps the newest 20 autosaves → {saved, version?}",
|
||||
"{} save an automatic checkpoint when the document has unsaved changes since the last version (with `pref.autoSave` on: changes made since it was opened); the app does this every 30 minutes; keeps the newest 20 autosaves → {saved, version?}",
|
||||
false,
|
||||
autosave
|
||||
),
|
||||
@@ -257,8 +257,11 @@ pub const MAX_AUTOSAVES: usize = 20;
|
||||
|
||||
/// `version.autosave`: a checkpoint of a document with unsaved changes since the last version.
|
||||
fn autosave(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let auto_save = s.prefs.auto_save;
|
||||
let ds = s.ds_mut()?;
|
||||
if ds.preview.is_some() || !ds.is_dirty() || !ds.changed_since_checkpoint() {
|
||||
// A file that saves itself is rarely unsaved: edits since it was opened count then.
|
||||
let edited = ds.is_dirty() || (auto_save && !ds.history.undo.is_empty());
|
||||
if ds.preview.is_some() || !edited || !ds.changed_since_checkpoint() {
|
||||
return Ok(json!({"saved": false}));
|
||||
}
|
||||
Ok(match add_version(ds, AUTOSAVE, "", true) {
|
||||
@@ -693,10 +696,71 @@ pub fn thumbnail(doc: &Document) -> Option<Vec<u8>> {
|
||||
|
||||
/// The `.figcraft` file for a document: content, thumbnail and version history.
|
||||
pub fn file_bytes(ds: &DocState) -> Result<Vec<u8>> {
|
||||
let mut doc = (*ds.doc).clone();
|
||||
doc.current_page = Some(ds.page);
|
||||
let thumb = thumbnail(&doc);
|
||||
figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &ds.versions, &ds.branches).map_err(|e| EngineError::Other(e.to_string()))
|
||||
FileSnapshot::of(ds).encode()
|
||||
}
|
||||
|
||||
/// A document's file as it is at one moment, so the slow part of saving it (the thumbnail and the
|
||||
/// encoding) can run on another thread while editing goes on: [`encode`](Self::encode) there, then
|
||||
/// [`save`](Self::save) the bytes back on the session's thread.
|
||||
pub struct FileSnapshot {
|
||||
uid: u64,
|
||||
path: Option<String>,
|
||||
revision: u64,
|
||||
doc: Arc<Document>,
|
||||
/// The document as last saved when the snapshot was taken: another save since makes it stale.
|
||||
saved: Arc<Document>,
|
||||
page: NodeId,
|
||||
versions: Vec<Version>,
|
||||
branches: Vec<figcraft_format::Branch>,
|
||||
}
|
||||
|
||||
impl FileSnapshot {
|
||||
pub fn of(ds: &DocState) -> Self {
|
||||
Self {
|
||||
uid: ds.uid,
|
||||
path: ds.path.clone(),
|
||||
revision: ds.revision,
|
||||
doc: ds.doc.clone(),
|
||||
saved: ds.saved_doc.clone(),
|
||||
page: ds.page,
|
||||
versions: ds.versions.clone(),
|
||||
branches: ds.branches.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file's bytes: content, thumbnail and version history.
|
||||
pub fn encode(&self) -> Result<Vec<u8>> {
|
||||
let mut doc = (*self.doc).clone();
|
||||
doc.current_page = Some(self.page);
|
||||
let thumb = thumbnail(&doc);
|
||||
figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &self.versions, &self.branches)
|
||||
.map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// Write `bytes` (this snapshot's [`encode`](Self::encode)) to the document's file: it is saved
|
||||
/// as of the snapshot, and edits since stay unsaved. False (nothing written) when the document
|
||||
/// was closed, saved, reverted or given another path since: the bytes are out of date.
|
||||
pub fn save(self, s: &mut Session, bytes: &[u8]) -> Result<bool> {
|
||||
let Some(ds) = s.docs.iter_mut().find(|d| d.uid == self.uid && d.path == self.path && Arc::ptr_eq(&d.saved_doc, &self.saved)) else {
|
||||
return Ok(false);
|
||||
};
|
||||
write(self.path.as_deref().ok_or(EngineError::NoDocument)?, bytes)?;
|
||||
// The history or branches may have changed since: then they still count as unsaved.
|
||||
if ds.revision == self.revision {
|
||||
ds.versions_changed = false;
|
||||
}
|
||||
ds.saved_doc = self.doc;
|
||||
ds.revision += 1;
|
||||
branch_tabs_saved(s, self.uid);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// The branch tabs of the file in tab `uid` were saved with it.
|
||||
fn branch_tabs_saved(s: &mut Session, uid: u64) {
|
||||
for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) {
|
||||
tab.mark_saved();
|
||||
}
|
||||
}
|
||||
|
||||
fn color_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -721,9 +785,7 @@ fn save_to(s: &mut Session, path: String) -> Result<Value> {
|
||||
ds.mark_saved();
|
||||
ds.revision += 1;
|
||||
let uid = ds.uid;
|
||||
for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) {
|
||||
tab.mark_saved();
|
||||
}
|
||||
branch_tabs_saved(s, uid);
|
||||
remember(s, &path);
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
@@ -107,6 +107,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let id = id(p, "id").unwrap_or(s.page()?);
|
||||
let k = req_num("page.move", p, "index")?.max(0.0) as usize;
|
||||
let doc = s.doc_mut()?;
|
||||
if !doc.pages.contains(&id) {
|
||||
return Err(bad("page.move", format!("{id} is not a page")));
|
||||
}
|
||||
doc.pages.retain(|x| *x != id);
|
||||
let k = k.min(doc.pages.len());
|
||||
doc.pages.insert(k, id);
|
||||
|
||||
@@ -334,6 +334,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)),
|
||||
spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)),
|
||||
spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| {
|
||||
s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0);
|
||||
s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0);
|
||||
@@ -369,18 +370,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"url": s.prefs.publish_server, "default": crate::live::DEFAULT_SERVER}))
|
||||
}
|
||||
),
|
||||
spec!("pref.language", "Interface language", "{language: system|en|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}", false, |s, p| {
|
||||
let lang = req_string("pref.language", p, "language")?;
|
||||
let lang = lang.trim();
|
||||
// A BCP 47-style tag ("es", "pt-BR", "zh_Hans") or "system".
|
||||
let tag = lang.len() <= 35
|
||||
&& lang.split(['-', '_']).all(|part| !part.is_empty() && part.len() <= 8 && part.chars().all(|c| c.is_ascii_alphanumeric()));
|
||||
if !tag {
|
||||
return Err(bad("pref.language", format!("`{lang}` is not a language code")));
|
||||
spec!(
|
||||
"pref.language",
|
||||
"Interface language",
|
||||
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}",
|
||||
false,
|
||||
|s, p| {
|
||||
let lang = req_string("pref.language", p, "language")?;
|
||||
let lang = lang.trim();
|
||||
// A BCP 47-style tag ("es", "pt-BR", "zh_Hans") or "system".
|
||||
let tag = lang.len() <= 35
|
||||
&& lang.split(['-', '_']).all(|part| !part.is_empty() && part.len() <= 8 && part.chars().all(|c| c.is_ascii_alphanumeric()));
|
||||
if !tag {
|
||||
return Err(bad("pref.language", format!("`{lang}` is not a language code")));
|
||||
}
|
||||
s.prefs.language = lang.to_string();
|
||||
Ok(json!({"language": s.prefs.language}))
|
||||
}
|
||||
s.prefs.language = lang.to_string();
|
||||
Ok(json!({"language": s.prefs.language}))
|
||||
}),
|
||||
),
|
||||
spec!("app.preferences", "Preferences", "{}", false, |s, _| ui(s, "dialog.preferences")),
|
||||
spec!("app.actions", "Actions", "{}", false, |s, _| ui(s, "palette.actions")),
|
||||
spec!("help.shortcuts", "Keyboard shortcuts", "{}", false, |s, _| ui(s, "panel.shortcuts")),
|
||||
|
||||
@@ -77,6 +77,26 @@ fn double_clicks_reach_and_edit_text_in_a_group_wherever_they_land() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_text_keeps_other_document_changes_since_open() {
|
||||
let mut s = Session::new();
|
||||
let first = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 80, "height": 40}))["id"]).unwrap();
|
||||
let deleted = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 120, "y": 20, "width": 80, "height": 40}))["id"]).unwrap();
|
||||
let text = id_of(&ex(&mut s, "text.create", json!({"text": "A", "x": 20, "y": 100}))["id"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"ids": [first], "props": {"x": 650}}));
|
||||
ex(&mut s, "edit.delete", json!({"ids": [deleted]}));
|
||||
|
||||
let bounds = s.doc().unwrap().world_bounds(text).unwrap();
|
||||
double_click(&mut s, bounds.center().x, bounds.center().y, 0.0, 1.0);
|
||||
assert_eq!(editing(&s), Some(text));
|
||||
crate::textedit::insert(&mut s, "B").unwrap();
|
||||
crate::textedit::end(&mut s).unwrap();
|
||||
|
||||
assert_eq!(s.doc().unwrap().world_bounds(first).unwrap().x0, 650.0);
|
||||
assert!(!s.doc().unwrap().contains(deleted));
|
||||
assert_eq!(s.doc().unwrap().node(text).unwrap().kind.text().unwrap().characters, "B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_drags_only_past_the_threshold_in_screen_pixels() {
|
||||
for (zoom, by, moves) in [(1.0, 2.0, false), (1.0, 4.0, true), (4.0, 0.5, false), (4.0, 1.0, true), (0.25, 8.0, false), (0.25, 16.0, true)] {
|
||||
|
||||
@@ -107,6 +107,47 @@ pub fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.to_string(), msg: msg.into() }
|
||||
}
|
||||
|
||||
/// Deepest params nesting `resolve_step_refs` walks before giving up.
|
||||
const STEP_REF_MAX_DEPTH: usize = 64;
|
||||
|
||||
/// Replace every `{"$step": n}` / `{"$step": n, "field": "…"}` in `v` with that field (default
|
||||
/// `id`) of `results[n]`, the result of an earlier batch step.
|
||||
fn resolve_step_refs(v: &Value, results: &[Value], step: usize, depth: usize) -> Result<Value> {
|
||||
if depth > STEP_REF_MAX_DEPTH {
|
||||
return Err(bad("command.batch", format!("step {step}: params nest deeper than {STEP_REF_MAX_DEPTH} levels")));
|
||||
}
|
||||
match v {
|
||||
Value::Object(o) if o.contains_key("$step") => {
|
||||
if let Some(k) = o.keys().find(|k| *k != "$step" && *k != "field") {
|
||||
return Err(bad("command.batch", format!("step {step}: a `$step` reference takes only `field`, not `{k}`")));
|
||||
}
|
||||
let n = o
|
||||
.get("$step")
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| bad("command.batch", format!("step {step}: `$step` must be a step index (0, 1, …)")))?;
|
||||
let field = match o.get("field") {
|
||||
None => "id",
|
||||
Some(Value::String(f)) => f.as_str(),
|
||||
Some(_) => return Err(bad("command.batch", format!("step {step}: `field` must be a string"))),
|
||||
};
|
||||
let Some(result) = usize::try_from(n).ok().and_then(|n| results.get(n)) else {
|
||||
let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) };
|
||||
return Err(bad("command.batch", format!("step {step}: `$step` {n} must be an earlier step ({range})")));
|
||||
};
|
||||
result.get(field).cloned().ok_or_else(|| bad("command.batch", format!("step {step}: step {n}'s result has no `{field}`")))
|
||||
}
|
||||
Value::Object(o) => {
|
||||
let mut out = serde_json::Map::with_capacity(o.len());
|
||||
for (k, x) in o {
|
||||
out.insert(k.clone(), resolve_step_refs(x, results, step, depth + 1)?);
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
Value::Array(a) => a.iter().map(|x| resolve_step_refs(x, results, step, depth + 1)).collect::<Result<Vec<_>>>().map(Value::Array),
|
||||
_ => Ok(v.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// One undo step.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HistoryEntry {
|
||||
@@ -573,6 +614,9 @@ pub struct Prefs {
|
||||
/// Reduce motion: the interface's own animations are off (the system's setting turns them off
|
||||
/// too). Prototype transitions still play.
|
||||
pub reduce_motion: bool,
|
||||
/// Files that have a path are saved by themselves a few seconds after they change (the app's
|
||||
/// timer; off: only File ▸ Save writes them).
|
||||
pub auto_save: bool,
|
||||
/// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed
|
||||
/// builds, off for development builds.
|
||||
pub invite_links: Option<bool>,
|
||||
@@ -625,6 +669,7 @@ impl Default for Prefs {
|
||||
language: "system".into(),
|
||||
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
|
||||
reduce_motion: false,
|
||||
auto_save: false,
|
||||
invite_links: None,
|
||||
plugins: Vec::new(),
|
||||
plugin_settings: BTreeMap::new(),
|
||||
@@ -1081,9 +1126,20 @@ impl Session {
|
||||
self.ui_requests.push(UiRequest { kind: kind.to_string(), data });
|
||||
}
|
||||
|
||||
/// Run several commands as one undo step.
|
||||
/// Run several commands as one undo step. A param value `{"$step": n}` is replaced by the `id`
|
||||
/// in the result of the earlier step `n` (`{"$step": n, "field": "…"}` picks another field),
|
||||
/// so a step can target a layer that an earlier step of the same batch created. Any object
|
||||
/// with a `$step` key in a step's params is such a reference, except inside a nested
|
||||
/// `command.batch` step: its references are to its own steps.
|
||||
pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result<Vec<Value>> {
|
||||
self.grouped(label, |s| cmds.iter().map(|(id, p)| s.execute(id, p)).collect())
|
||||
self.grouped(label, |s| {
|
||||
let mut results: Vec<Value> = Vec::with_capacity(cmds.len());
|
||||
for (step, (id, p)) in cmds.iter().enumerate() {
|
||||
let p = if id == "command.batch" { p.clone() } else { resolve_step_refs(p, &results, step, 0)? };
|
||||
results.push(s.execute(id, &p)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
}
|
||||
|
||||
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
|
||||
@@ -1406,6 +1462,8 @@ mod autosave_tests;
|
||||
#[cfg(test)]
|
||||
mod batch_rollback_tests;
|
||||
#[cfg(test)]
|
||||
mod batch_step_ref_tests;
|
||||
#[cfg(test)]
|
||||
mod binding_lists_tests;
|
||||
#[cfg(test)]
|
||||
mod booleans_tests;
|
||||
|
||||
@@ -517,6 +517,11 @@ pub trait LiveLink: Send {
|
||||
fn background(&mut self, _job: u64, _req: NetRequest) -> bool {
|
||||
false
|
||||
}
|
||||
/// Remove client `client` from session `session`, which this link hosts: they are
|
||||
/// disconnected and can't join again from their address while it runs. Returns at once.
|
||||
fn remove(&mut self, _session: SessionId, _client: u32) -> std::result::Result<(), String> {
|
||||
Err("removing people needs the desktop app or the MCP server".into())
|
||||
}
|
||||
/// End session `session` (stop hosting or leave); the others go on. Nothing for a session
|
||||
/// that is over already.
|
||||
fn leave(&mut self, session: SessionId);
|
||||
@@ -1889,6 +1894,59 @@ fn follow(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"follow": s.live().follow}))
|
||||
}
|
||||
|
||||
/// Remove someone from the open file's session: in a session this app hosts, the hub drops them;
|
||||
/// in a file of yours on a server, the server does (with the file's owner token). They are told
|
||||
/// the session is over for them, keep their copy, and can't join again from their address while
|
||||
/// the session (or the server) runs.
|
||||
fn remove_person(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.removePerson";
|
||||
let client =
|
||||
get(p, "client").and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).ok_or_else(|| bad(CMD, "`client` must be a client id"))?;
|
||||
let live = s.live();
|
||||
if live.status != Status::Live {
|
||||
return Err(bad(CMD, "the open file isn't in a live session"));
|
||||
}
|
||||
let name =
|
||||
live.peers.get(&client).map(|p| p.name.clone()).ok_or_else(|| bad(CMD, format!("nobody with client id {client} is in the session")))?;
|
||||
let (id, role, server_file) = (live.id, live.role(), live.server_file());
|
||||
match (role, server_file) {
|
||||
(Some(Role::Host), _) => {
|
||||
let link = s.live.link.as_deref_mut().ok_or_else(|| unavailable(CMD))?;
|
||||
link.remove(id, client).map_err(EngineError::Other)?;
|
||||
}
|
||||
(Some(Role::Guest), Some((server, file))) => {
|
||||
let owner =
|
||||
s.live().published.as_ref().and_then(|p| p.owner.clone()).or_else(|| s.online_owner(&server, &file)).filter(|o| !o.is_empty());
|
||||
let owner = owner.ok_or_else(|| bad(CMD, "only the file's owner can remove people from it"))?;
|
||||
let req = Some(Admin::RemovePeer { file: file.clone(), client });
|
||||
match hosting_request(s, CMD, &server, &file, &owner, req) {
|
||||
Ok((_, Some(AdminReply::Done))) => {}
|
||||
Ok(_) => return Err(EngineError::Other(format!("couldn't remove {name}: the server answered something else"))),
|
||||
// A server older than this request can't read it.
|
||||
Err(e) => return Err(EngineError::Other(format!("couldn't remove {name}: {e} (the server may be older than removing people)"))),
|
||||
}
|
||||
}
|
||||
_ => return Err(bad(CMD, "only the person hosting the session, or the file's owner, can remove people")),
|
||||
}
|
||||
s.message = Some(format!("{name} was removed from the session"));
|
||||
Ok(json!({"removed": client, "name": name}))
|
||||
}
|
||||
|
||||
/// You may remove others from the open file's session: you host it, or it is your file on a
|
||||
/// server.
|
||||
pub fn can_remove_people(s: &Session) -> bool {
|
||||
let live = s.live();
|
||||
live.status == Status::Live
|
||||
&& match live.role() {
|
||||
Some(Role::Host) => true,
|
||||
Some(Role::Guest) => live.server_file().is_some_and(|(server, file)| {
|
||||
live.published.as_ref().and_then(|p| p.owner.as_deref()).is_some_and(|o| !o.is_empty())
|
||||
|| s.online_owner(&server, &file).is_some_and(|o| !o.is_empty())
|
||||
}),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let at = match (num(p, "x"), num(p, "y")) {
|
||||
(Some(x), Some(y)) => Some((x, y)),
|
||||
@@ -1997,6 +2055,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, _| Ok(s.live_summary())
|
||||
),
|
||||
spec!("collab.follow", "Follow", "{client? (a peer's id; omit to stop following)} → {follow}", false, follow),
|
||||
spec!(
|
||||
"collab.removePerson",
|
||||
"Remove from session",
|
||||
"{client (a peer's id)} → {removed, name}: they are disconnected (they keep their copy) and can't join again from their address while the session runs; only the person hosting, or the owner of a file on a server, can do it",
|
||||
false,
|
||||
remove_person
|
||||
),
|
||||
spec!(
|
||||
"collab.cursor",
|
||||
"Show cursor at",
|
||||
|
||||
@@ -28,6 +28,8 @@ pub(crate) struct Script {
|
||||
/// The session each presence in `sent` and each message in `msgs` was for.
|
||||
pub(crate) sent_to: Vec<u64>,
|
||||
pub(crate) msgs_to: Vec<u64>,
|
||||
/// The people removed: `(session, client)`.
|
||||
pub(crate) removed: Vec<(u64, u32)>,
|
||||
}
|
||||
|
||||
pub(crate) struct Mock(pub(crate) Arc<Mutex<Script>>);
|
||||
@@ -62,6 +64,10 @@ impl LiveLink for Mock {
|
||||
s.published.push(opts);
|
||||
Ok(())
|
||||
}
|
||||
fn remove(&mut self, session: u64, client: u32) -> Result<(), String> {
|
||||
self.0.lock().unwrap().removed.push((session, client));
|
||||
Ok(())
|
||||
}
|
||||
fn leave(&mut self, session: u64) {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.left += 1;
|
||||
@@ -314,6 +320,43 @@ fn the_host_ending_resets_everything() {
|
||||
assert_eq!(s.message.as_deref(), Some("The host ended the session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_removes_people_and_guests_cant() {
|
||||
let (mut s, script) = session();
|
||||
let remove = |s: &mut Session, client: serde_json::Value| s.execute("collab.removePerson", &json!({ "client": client }));
|
||||
// Not in a session: nobody to remove.
|
||||
assert!(matches!(remove(&mut s, json!(2)), Err(EngineError::BadParams { .. })));
|
||||
assert!(!crate::live::can_remove_people(&s));
|
||||
s.execute("collab.host", &json!({})).unwrap();
|
||||
let id = s.live().id;
|
||||
push(&script, vec![Event::PeerJoined(peer(2, "Ada")), Event::PeerJoined(peer(3, "Bo"))]);
|
||||
s.live_poll();
|
||||
assert!(crate::live::can_remove_people(&s));
|
||||
// Only someone who is here, by a client id.
|
||||
for bad in [json!(9), json!(1), json!(-2), json!("2"), json!(4_294_967_296u64)] {
|
||||
assert!(matches!(remove(&mut s, bad.clone()), Err(EngineError::BadParams { .. })), "{bad}");
|
||||
}
|
||||
let r = remove(&mut s, json!(2)).unwrap();
|
||||
assert_eq!((r["removed"].as_u64(), r["name"].as_str()), (Some(2), Some("Ada")));
|
||||
assert_eq!(script.lock().unwrap().removed, [(id, 2)]);
|
||||
assert_eq!(s.message.as_deref(), Some("Ada was removed from the session"));
|
||||
// The host's own session tells them she left.
|
||||
push(&script, vec![Event::PeerLeft(2)]);
|
||||
s.live_poll();
|
||||
assert_eq!(s.live().peers.keys().copied().collect::<Vec<_>>(), [3]);
|
||||
s.execute("collab.stop", &json!({})).unwrap();
|
||||
|
||||
// A guest in someone else's session can't remove anyone.
|
||||
let (mut s, script) = session();
|
||||
s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap();
|
||||
push(&script, vec![Event::Welcome { info: info(Role::Guest, 2), peers: vec![peer(1, "Hana"), peer(3, "Bo")] }]);
|
||||
s.live_poll();
|
||||
assert!(!crate::live::can_remove_people(&s));
|
||||
let e = remove(&mut s, json!(3)).unwrap_err().to_string();
|
||||
assert!(e.contains("only the person hosting"), "{e}");
|
||||
assert!(script.lock().unwrap().removed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collab_commands_survive_junk() {
|
||||
let junk = [
|
||||
|
||||
@@ -337,6 +337,17 @@ fn pages_by_the_documented_rules() {
|
||||
assert_ne!(s.page().unwrap(), div, "deleting the open page opens a page, never a divider");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_move_refuses_what_is_not_a_page() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, "r", 30.0, 40.0, None);
|
||||
let before = pages(&mut s);
|
||||
assert!(s.execute("page.move", &json!({"id": r, "index": 0})).is_err(), "a layer id is not a page");
|
||||
assert!(s.execute("page.move", &json!({"id": 999999, "index": 0})).is_err(), "a page that isn't there is not a page");
|
||||
assert_eq!(pages(&mut s), before, "the page list is untouched");
|
||||
assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_select_and_doc_switch_refuse_negative_or_fractional_indexes() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -597,3 +597,27 @@ fn guides_add_move_remove() {
|
||||
s.execute("guide.clear", &json!({})).unwrap();
|
||||
assert!(guides(&s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_replace_matches_and_replaces_case_insensitively() {
|
||||
let mut s = sess();
|
||||
let t = id(&s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0})).unwrap());
|
||||
let u = id(&s.execute("text.create", &json!({"text": "İ HELLO hello", "x": 0, "y": 40})).unwrap());
|
||||
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 80, "width": 50, "height": 50, "name": "HELLO box"})).unwrap());
|
||||
let chars = |s: &Session, i| s.doc().unwrap().node(i).unwrap().kind.text().unwrap().characters.clone();
|
||||
let r = s.execute("edit.findReplace", &json!({"find": "hello", "replace": "bye", "layerNames": true})).unwrap();
|
||||
assert_eq!(r["matches"], json!([t, u, f]));
|
||||
assert_eq!(chars(&s, t), "bye");
|
||||
assert_eq!(chars(&s, u), "İ bye bye");
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().name, "bye box");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_replace_changing_only_the_case_replaces_every_occurrence() {
|
||||
let mut s = sess();
|
||||
let t = id(&s.execute("text.create", &json!({"text": "figcraft and FIGCRAFT, figcraft", "x": 0, "y": 0})).unwrap());
|
||||
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 80, "width": 50, "height": 50, "name": "figcraft / figcraft"})).unwrap());
|
||||
s.execute("edit.findReplace", &json!({"find": "figcraft", "replace": "FigCraft", "layerNames": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft");
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft");
|
||||
}
|
||||
|
||||
@@ -192,3 +192,40 @@ fn paragraph_indent_only_moves_left_aligned_text() {
|
||||
let (x, w2) = first_x(&s);
|
||||
assert!((w2 - (w - 30.0)).abs() < 1e-6 && x.abs() < 1e-6, "centered auto-width text: no indent, the box shrinks by it");
|
||||
}
|
||||
|
||||
/// A new text made with the Text tool by a press at `from`, a drag to `to` and a release, holding
|
||||
/// `chars`: its box and resize mode.
|
||||
fn text_tool(from: (f64, f64), to: (f64, f64), chars: &str) -> (figcraft_geom::Rect, figcraft_doc::TextAutoResize) {
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Text);
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, from.0, from.1, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, to.0, to.1, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, to.0, to.1, 1.0)).unwrap();
|
||||
crate::textedit::insert(&mut s, chars).unwrap();
|
||||
let t = s.ds().unwrap().text_edit.as_ref().unwrap().id;
|
||||
let doc = s.doc().unwrap();
|
||||
let NodeKind::Text(td) = &doc.node(t).unwrap().kind else { panic!("not a text") };
|
||||
(doc.world_bounds(t).unwrap(), td.auto_resize)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_with_the_text_tool_makes_a_fixed_size_box() {
|
||||
// #360: the drag's size, its top-left where the drag started, and its text wraps in it; a
|
||||
// click, or a drag within a few pixels, still makes auto-width text.
|
||||
use figcraft_doc::TextAutoResize;
|
||||
figcraft_text::scan_system_fonts();
|
||||
let long = "A sentence much longer than eighty pixels";
|
||||
let (line, auto) = text_tool((100.0, 100.0), (100.0, 100.0), long);
|
||||
assert_eq!(auto, TextAutoResize::WidthAndHeight);
|
||||
assert_eq!(text_tool((100.0, 100.0), (101.0, 101.0), long).1, TextAutoResize::WidthAndHeight, "a slip of the hand is a click");
|
||||
let (b, auto) = text_tool((100.0, 100.0), (180.0, 400.0), long);
|
||||
assert_eq!(auto, TextAutoResize::None);
|
||||
assert_eq!((b.x0, b.y0, b.width(), b.height()), (100.0, 100.0, 80.0, 300.0), "the text wraps in a box that keeps its size");
|
||||
// Dragged up and to the left, the box is the same.
|
||||
let (b2, _) = text_tool((180.0, 400.0), (100.0, 100.0), long);
|
||||
assert_eq!((b2.x0, b2.y0, b2.width(), b2.height()), (100.0, 100.0, 80.0, 300.0));
|
||||
// A flat drag still makes a box one line tall.
|
||||
let (b3, _) = text_tool((100.0, 100.0), (180.0, 101.0), "A");
|
||||
assert_eq!((b3.width(), b3.height()), (80.0, line.height()));
|
||||
}
|
||||
|
||||
@@ -376,6 +376,13 @@ pub(crate) enum Gesture {
|
||||
TextSelect {
|
||||
id: NodeId,
|
||||
},
|
||||
/// A new text from the Text tool, still pressed: a drag makes it a box the size of the drag
|
||||
/// (at least `line`, the height of its first line, tall).
|
||||
TextBox {
|
||||
id: NodeId,
|
||||
start: Point,
|
||||
line: f64,
|
||||
},
|
||||
PenDrag {
|
||||
anchor: Point,
|
||||
},
|
||||
@@ -1412,6 +1419,11 @@ impl Session {
|
||||
self.tool.gesture = g;
|
||||
return self.draw_drag(ev, start, id, parent, &targets);
|
||||
}
|
||||
Gesture::TextBox { id, start, line } => {
|
||||
let (id, start, line) = (*id, *start, *line);
|
||||
self.tool.gesture = g;
|
||||
return self.text_box_drag(ev, id, start, line);
|
||||
}
|
||||
Gesture::Arc { id, handle, arc } => {
|
||||
let (id, handle, arc) = (*id, *handle, *arc);
|
||||
self.tool.gesture = g;
|
||||
@@ -1830,7 +1842,7 @@ impl Session {
|
||||
self.tool.overlay.pen.clear();
|
||||
self.pencil_finish(&points, &pressure)?;
|
||||
}
|
||||
Gesture::TextSelect { .. } | Gesture::Marquee { .. } | Gesture::None => {}
|
||||
Gesture::TextSelect { .. } | Gesture::TextBox { .. } | Gesture::Marquee { .. } | Gesture::None => {}
|
||||
Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } | Gesture::Origin => {}
|
||||
Gesture::Vector(_) => self.commit_interaction()?,
|
||||
}
|
||||
@@ -1958,15 +1970,39 @@ impl Session {
|
||||
n.width = w.max(1.0);
|
||||
n.height = h;
|
||||
}
|
||||
let line = n.height;
|
||||
d.insert(n, parent, None)?;
|
||||
ds.selection = vec![id];
|
||||
textedit::begin(self, id, Some(0), true)?;
|
||||
self.tool.gesture = Gesture::TextBox { id, start: ev.p(), line };
|
||||
if !self.prefs.keep_tool {
|
||||
self.tool.tool = ToolId::Move;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drag out a new text box: past the drag slop it has the drag's size (at least one line
|
||||
/// tall) and its top-left, and that size is fixed.
|
||||
fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point, line: f64) -> Result<()> {
|
||||
if within_slop(ev, start) {
|
||||
return Ok(());
|
||||
}
|
||||
let r = Rect::from_points(start, ev.p());
|
||||
let d = self.ds_mut()?.doc_mut();
|
||||
let pw = d.node(id).and_then(|n| n.parent).map(|p| d.world_transform(p)).unwrap_or_default();
|
||||
let n = d.get_mut(id)?;
|
||||
n.transform = pw.inverse() * Affine::translate((r.x0.round(), r.y0.round()));
|
||||
n.width = r.width().round().max(1.0);
|
||||
n.height = r.height().round().max(line);
|
||||
n.child_layout.sizing_h = Sizing::Fixed;
|
||||
n.child_layout.sizing_v = Sizing::Fixed;
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.auto_resize = TextAutoResize::None;
|
||||
}
|
||||
self.ds_mut()?.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn eyedrop(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
let ds = self.ds()?;
|
||||
let doc = ds.doc.clone();
|
||||
|
||||
+203
-26
@@ -25,6 +25,8 @@ const MAX_DEPTH: usize = 256;
|
||||
const MAX_COMMANDS: usize = 200_000;
|
||||
/// Id of "no style".
|
||||
const NO_STYLE: &str = "4294967295:4294967295";
|
||||
/// Longest asset `key` or `version` kept for resolving references given as assets.
|
||||
const MAX_ASSET_PART: usize = 256;
|
||||
|
||||
/// The REST API file JSON for message `msg`, with the warnings about what was skipped.
|
||||
pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec<String>) {
|
||||
@@ -86,6 +88,10 @@ fn vec2(v: &Kv) -> Option<Value> {
|
||||
Some(json!({"x": num(v, "x")?, "y": num(v, "y")?}))
|
||||
}
|
||||
|
||||
fn size(v: &Kv) -> Option<(f64, f64)> {
|
||||
Some((num(v, "x")?, num(v, "y")?))
|
||||
}
|
||||
|
||||
fn matrix(m: &Kv) -> Option<Affine> {
|
||||
let c = |k: &str| num(m, k);
|
||||
let a = Affine::new([c("m00")?, c("m10")?, c("m01")?, c("m11")?, c("m02")?, c("m12")?]);
|
||||
@@ -113,11 +119,21 @@ fn copy_str(o: &mut Obj, n: &View, from: &str, to: &str) {
|
||||
#[derive(Clone)]
|
||||
struct View<'a> {
|
||||
layers: Vec<&'a Kv<'a>>,
|
||||
/// Size and transform worked out from the constraints, over the layers' own.
|
||||
fitted: Option<((f64, f64), Affine)>,
|
||||
}
|
||||
|
||||
impl<'a> View<'a> {
|
||||
fn of(n: &'a Kv<'a>) -> Self {
|
||||
Self { layers: vec![n] }
|
||||
Self { layers: vec![n], fitted: None }
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<(f64, f64)> {
|
||||
self.fitted.map(|f| f.0).or_else(|| self.get("size").and_then(size))
|
||||
}
|
||||
|
||||
fn transform(&self) -> Option<Affine> {
|
||||
self.fitted.map(|f| f.1).or_else(|| self.get("transform").and_then(matrix))
|
||||
}
|
||||
|
||||
fn with(&self, top: impl IntoIterator<Item = &'a Kv<'a>>) -> Self {
|
||||
@@ -147,13 +163,83 @@ impl<'a> View<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How the layers of a resized container follow it when the file gives no derived layout for
|
||||
/// them: a frame's by their constraints, from its main's size to its own; a group's scale with it.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Fit {
|
||||
Frame { from: (f64, f64), to: (f64, f64) },
|
||||
Scale(f64, f64),
|
||||
}
|
||||
|
||||
impl Fit {
|
||||
fn frame(from: Option<(f64, f64)>, to: Option<(f64, f64)>) -> Option<Self> {
|
||||
let (from, to) = (from?, to?);
|
||||
((from.0 - to.0).abs() > 1e-9 || (from.1 - to.1).abs() > 1e-9).then_some(Fit::Frame { from, to })
|
||||
}
|
||||
|
||||
fn scale(from: Option<(f64, f64)>, to: Option<(f64, f64)>) -> Option<Self> {
|
||||
let (from, to) = (from?, to?);
|
||||
let k = |a: f64, b: f64| if a > 1e-9 { b / a } else { 1.0 };
|
||||
let (kx, ky) = (k(from.0, to.0), k(from.1, to.1));
|
||||
((kx - 1.0).abs() > 1e-9 || (ky - 1.0).abs() > 1e-9).then_some(Fit::Scale(kx, ky))
|
||||
}
|
||||
|
||||
/// The size and transform (in the parent's coordinates) of a `w` × `h` layer at `t` with
|
||||
/// constraints `h_con`, `v_con`. Like the editor's constraint solver, a layer keeps its
|
||||
/// rotation and flip and stretches along its own axes.
|
||||
fn apply(self, w: f64, h: f64, t: Affine, h_con: Option<&str>, v_con: Option<&str>) -> ((f64, f64), Affine) {
|
||||
let [a, b, c, d, _, _] = t.as_coeffs();
|
||||
let lin = Affine::new([a, b, c, d, 0.0, 0.0]);
|
||||
let bb = figcraft_doc::geom::transform_rect_bbox(t, figcraft_doc::geom::Rect::new(0.0, 0.0, w, h));
|
||||
// Where the new box goes: its top-left (constraints) or its centre (a group's scale).
|
||||
let (kx, ky, x0, y0, centred) = match self {
|
||||
Fit::Frame { from, to } => {
|
||||
let axis = |con: Option<&str>, lo: f64, hi: f64, old: f64, new: f64| match con {
|
||||
Some("MAX" | "FIXED_MAX") => (lo + new - old, hi + new - old),
|
||||
Some("STRETCH") => (lo, hi + new - old),
|
||||
Some("CENTER") => (lo + (new - old) / 2.0, hi + (new - old) / 2.0),
|
||||
Some("SCALE") if old > 1e-9 => (lo * new / old, hi * new / old),
|
||||
_ => (lo, hi),
|
||||
};
|
||||
let (x0, x1) = axis(h_con, bb.x0, bb.x1, from.0, to.0);
|
||||
let (y0, y1) = axis(v_con, bb.y0, bb.y1, from.1, to.1);
|
||||
let k = |new: f64, old: f64| if old > 1e-9 { (new / old).max(0.0) } else { 1.0 };
|
||||
(k(x1 - x0, bb.width()), k(y1 - y0, bb.height()), x0, y0, false)
|
||||
}
|
||||
Fit::Scale(kx, ky) => (kx, ky, bb.center().x * kx, bb.center().y * ky, true),
|
||||
};
|
||||
let along = |x: f64, y: f64, fallback: f64| {
|
||||
let len = x.hypot(y);
|
||||
let k = (kx * x).hypot(ky * y) / len;
|
||||
if len > 1e-12 && k.is_finite() { k } else { fallback }
|
||||
};
|
||||
let size = (w * along(a, b, kx), h * along(c, d, ky));
|
||||
let nb = figcraft_doc::geom::transform_rect_bbox(lin, figcraft_doc::geom::Rect::new(0.0, 0.0, size.0, size.1));
|
||||
let at = if centred { nb.center() } else { figcraft_doc::geom::Point::new(nb.x0, nb.y0) };
|
||||
(size, Affine::translate((x0 - at.x, y0 - at.y)) * lin)
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides and derived layout by id path.
|
||||
type Overrides<'a> = BTreeMap<Vec<String>, Vec<(Entry, &'a Kv<'a>)>>;
|
||||
|
||||
/// Where an override entry comes from.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Entry {
|
||||
Override,
|
||||
Derived,
|
||||
/// Derived layout of a nested instance for a size it no longer has: its size and place no
|
||||
/// longer hold, the rest (resolved colours, text) still does.
|
||||
Stale,
|
||||
}
|
||||
|
||||
/// Where an instance's expanded layers are: the overrides and derived layout of the outermost
|
||||
/// instance (by id path), the path of the instance being expanded, and the property values.
|
||||
#[derive(Clone)]
|
||||
struct Inst<'a> {
|
||||
root: String,
|
||||
path: Vec<String>,
|
||||
overrides: Rc<BTreeMap<Vec<String>, Vec<&'a Kv<'a>>>>,
|
||||
overrides: Rc<Overrides<'a>>,
|
||||
props: Rc<BTreeMap<String, &'a Kv<'a>>>,
|
||||
/// Mains being expanded (an instance inside its own main stops there).
|
||||
symbols: Vec<String>,
|
||||
@@ -165,6 +251,10 @@ struct Mapper<'a> {
|
||||
blobs: Vec<&'a [u8]>,
|
||||
/// Component property definitions: id → (REST name "Name#id", type).
|
||||
prop_defs: BTreeMap<String, (String, &'a str)>,
|
||||
/// File nodes by asset `key` and `version`: references can name a collection, variable or
|
||||
/// style as the asset instead of the guid, and the file carries the resources to resolve
|
||||
/// them (the collection node itself says the same `key`).
|
||||
assets: BTreeMap<String, BTreeMap<String, String>>,
|
||||
styles: Obj,
|
||||
/// Descriptions of components and component sets (REST `components` / `componentSets`).
|
||||
components: Obj,
|
||||
@@ -203,7 +293,37 @@ impl<'a> Mapper<'a> {
|
||||
}
|
||||
let blobs =
|
||||
msg.get("blobs").map(Kv::list).unwrap_or_default().iter().map(|b| b.get("bytes").and_then(Kv::bytes).unwrap_or_default()).collect();
|
||||
Self { nodes, kids, blobs, prop_defs, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 }
|
||||
let mut assets: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
|
||||
for n in msg.get("nodeChanges").map(Kv::list).unwrap_or_default() {
|
||||
let (Some(key), Some(id)) = (n.get("key").and_then(Kv::str), n.get("guid").and_then(guid)) else { continue };
|
||||
if key.is_empty() || key.len() > MAX_ASSET_PART {
|
||||
continue;
|
||||
}
|
||||
let version = n.get("version").and_then(Kv::str).filter(|v| !v.is_empty() && v.len() <= MAX_ASSET_PART).unwrap_or("");
|
||||
assets.entry(key.to_string()).or_default().entry(version.to_string()).or_insert(id);
|
||||
}
|
||||
Self { nodes, kids, blobs, prop_defs, assets, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 }
|
||||
}
|
||||
|
||||
/// The id of the resource a `VariableSetID` (or `VariableID`, `StyleId`) names: its `guid`,
|
||||
/// else the file node its asset `key` and `version` names (a collection from a library is
|
||||
/// referenced as the asset, and the file's own node for it says the same `key`). `None` when
|
||||
/// the reference has neither or the asset names nothing (or ambiguously) in the file.
|
||||
/// Original code for this importer: the field names are those of the schema each file embeds.
|
||||
fn resource_id(&self, v: &Kv) -> Option<String> {
|
||||
if let Some(g) = v.get("guid").and_then(guid).filter(|g| *g != NO_STYLE) {
|
||||
return Some(g);
|
||||
}
|
||||
let asset = v.get("assetRef")?;
|
||||
let key = asset.get("key").and_then(Kv::str)?;
|
||||
let versions = self.assets.get(key)?;
|
||||
let version = asset.get("version").and_then(Kv::str);
|
||||
match version {
|
||||
Some(v) => versions.get(v),
|
||||
// Without a version: an unversioned record, else the file's only one.
|
||||
None => versions.get("").or_else(|| (versions.len() == 1).then(|| versions.values().next()).flatten()),
|
||||
}
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn warn(&mut self, w: impl Into<String>) {
|
||||
@@ -263,7 +383,7 @@ impl<'a> Mapper<'a> {
|
||||
let name = sp.get("name").and_then(Kv::str).filter(|s| !s.is_empty()).unwrap_or("Flow");
|
||||
flows.push((sp.get("position").and_then(Kv::str).unwrap_or(""), json!({"nodeId": c, "name": name})));
|
||||
}
|
||||
kids.extend(self.layer(&c, None, Affine::IDENTITY, None, 0));
|
||||
kids.extend(self.layer(&c, None, Affine::IDENTITY, None, None, 0));
|
||||
}
|
||||
flows.sort_by(|a, b| a.0.cmp(b.0));
|
||||
if !flows.is_empty() {
|
||||
@@ -276,7 +396,15 @@ impl<'a> Mapper<'a> {
|
||||
/// Layer `id` (inside instance `inst` when expanding one) and its subtree. `space`: from the
|
||||
/// parent's coordinates to the enclosing frame's (groups and booleans place their children in
|
||||
/// the frame's space, like the REST JSON). `parent`: the parent's view (grid tracks).
|
||||
fn layer(&mut self, id: &str, inst: Option<&Inst<'a>>, space: Affine, parent: Option<&View<'a>>, depth: usize) -> Option<Value> {
|
||||
fn layer(
|
||||
&mut self,
|
||||
id: &str,
|
||||
inst: Option<&Inst<'a>>,
|
||||
space: Affine,
|
||||
parent: Option<&View<'a>>,
|
||||
fit: Option<Fit>,
|
||||
depth: usize,
|
||||
) -> Option<Value> {
|
||||
if depth > MAX_DEPTH {
|
||||
self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped"));
|
||||
return None;
|
||||
@@ -287,10 +415,28 @@ impl<'a> Mapper<'a> {
|
||||
}
|
||||
let base = *self.nodes.get(id)?;
|
||||
let path = inst.map(|i| [i.path.as_slice(), &[id.to_string()]].concat());
|
||||
let view = match (inst, &path) {
|
||||
(Some(i), Some(p)) => View::of(base).with(i.overrides.get(p).into_iter().flatten().copied()),
|
||||
_ => View::of(base),
|
||||
let entries = match (inst, &path) {
|
||||
(Some(i), Some(p)) => i.overrides.get(p).map(Vec::as_slice).unwrap_or_default(),
|
||||
_ => &[],
|
||||
};
|
||||
let mut view = View::of(base).with(entries.iter().map(|e| e.1));
|
||||
// A layer of a resized instance the file gives no derived layout for follows its
|
||||
// constraints from the main's geometry, then takes the size and place overridden on it
|
||||
// (auto layout places the others, as in the editor).
|
||||
let derived = entries.iter().any(|(d, l)| *d == Entry::Derived && (l.get("size").is_some() || l.get("transform").is_some()));
|
||||
let stacked = parent.and_then(|p| p.str("stackMode")).is_some_and(|m| matches!(m, "HORIZONTAL" | "VERTICAL" | "GRID"))
|
||||
&& view.str("stackPositioning") != Some("ABSOLUTE");
|
||||
let main = View::of(base);
|
||||
let set = |k: &str| entries.iter().rev().filter(|e| e.0 != Entry::Stale).find_map(|(_, l)| l.get(k));
|
||||
if let (Some(f), false, false, Some((w, h))) = (fit, derived, stacked, main.size()) {
|
||||
let t = main.transform().unwrap_or_default();
|
||||
let (fw, ft) = f.apply(w, h, t, view.str("horizontalConstraint"), view.str("verticalConstraint"));
|
||||
view.fitted = Some((set("size").and_then(size).unwrap_or(fw), set("transform").and_then(matrix).unwrap_or(ft)));
|
||||
} else if entries.iter().any(|(d, l)| *d == Entry::Stale && (l.get("size").is_some() || l.get("transform").is_some()))
|
||||
&& let Some(s) = set("size").and_then(size).or_else(|| main.size())
|
||||
{
|
||||
view.fitted = Some((s, set("transform").and_then(matrix).or_else(|| main.transform()).unwrap_or_default()));
|
||||
}
|
||||
let rest_id = match (inst, &path) {
|
||||
(Some(i), Some(p)) => format!("I{};{}", i.root, p.join(";")),
|
||||
_ => id.to_string(),
|
||||
@@ -338,21 +484,26 @@ impl<'a> Mapper<'a> {
|
||||
self.component(&mut o, &view, &rest_id);
|
||||
}
|
||||
self.prop_refs(&mut o, &view, inst);
|
||||
let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") {
|
||||
space * view.get("transform").and_then(matrix).unwrap_or_default()
|
||||
} else {
|
||||
Affine::IDENTITY
|
||||
};
|
||||
let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY };
|
||||
let mut kids = Vec::new();
|
||||
if ty == "INSTANCE" {
|
||||
if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) {
|
||||
let main = self.nodes.get(&sym).map(|m| View::of(m));
|
||||
let fit = Fit::frame(main.and_then(|m| m.size()), view.size());
|
||||
for c in self.children(&sym) {
|
||||
kids.extend(self.layer(&c, Some(&sub), child_space, Some(&view), depth + 1));
|
||||
kids.extend(self.layer(&c, Some(&sub), child_space, Some(&view), fit, depth + 1));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Inside an instance a layer's children are its main's: they follow it from the main's
|
||||
// size to its own.
|
||||
let fit = match (inst, ty) {
|
||||
(None, _) => None,
|
||||
(Some(_), "GROUP" | "BOOLEAN_OPERATION") => Fit::scale(View::of(base).size(), view.size()),
|
||||
(Some(_), _) => Fit::frame(View::of(base).size(), view.size()),
|
||||
};
|
||||
for c in self.children(id) {
|
||||
kids.extend(self.layer(&c, inst, child_space, Some(&view), depth + 1));
|
||||
kids.extend(self.layer(&c, inst, child_space, Some(&view), fit, depth + 1));
|
||||
}
|
||||
}
|
||||
if stand.is_some_and(|s| s.0 == "FRAME") {
|
||||
@@ -487,10 +638,10 @@ impl<'a> Mapper<'a> {
|
||||
if let Some(b) = v.str("blendMode").filter(|b| *b != "UNSET") {
|
||||
o.insert("blendMode".into(), json!(b));
|
||||
}
|
||||
if let Some(s) = v.get("size").and_then(vec2) {
|
||||
o.insert("size".into(), s);
|
||||
if let Some((x, y)) = v.size() {
|
||||
o.insert("size".into(), json!({"x": x, "y": y}));
|
||||
}
|
||||
let t = v.get("transform").and_then(matrix).unwrap_or_default();
|
||||
let t = v.transform().unwrap_or_default();
|
||||
o.insert("relativeTransform".into(), rows(space * t));
|
||||
// Paints, from the shared style when one is linked (the layer's own copy can be stale).
|
||||
let mut styles = Obj::new();
|
||||
@@ -739,7 +890,8 @@ impl<'a> Mapper<'a> {
|
||||
vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect();
|
||||
let style = |id: u32| styles.get(&u64::from(id)).copied().filter(|_| id != 0);
|
||||
let scale = |k: &str| {
|
||||
let (size, norm) = (v.get("size").and_then(|s| num(s, k)), vd.get("normalizedSize").and_then(|s| num(s, k)));
|
||||
let size = v.size().map(|s| if k == "x" { s.0 } else { s.1 });
|
||||
let norm = vd.get("normalizedSize").and_then(|s| num(s, k));
|
||||
size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n)
|
||||
};
|
||||
let (sx, sy) = (scale("x"), scale("y"));
|
||||
@@ -908,7 +1060,15 @@ impl<'a> Mapper<'a> {
|
||||
}
|
||||
// Overrides by id path below this instance (the instance itself is addressed by its main's id).
|
||||
let own_path: Vec<String> = outer.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()).unwrap_or_default();
|
||||
let mut overrides: BTreeMap<Vec<String>, Vec<&'a Kv<'a>>> = outer.map(|i| (*i.overrides).clone()).unwrap_or_default();
|
||||
let mut overrides: Overrides<'a> = outer.map(|i| (*i.overrides).clone()).unwrap_or_default();
|
||||
// A nested instance's derived layout is for the size it has in its main: resized here, the
|
||||
// sizes and places in it no longer hold and its layers follow their constraints instead.
|
||||
let differs = |a: f64, b: f64| (a - b).abs() > 1e-6 * a.abs().max(b.abs()).max(1.0);
|
||||
let resized = outer.is_some()
|
||||
&& match (self.nodes.get(id).and_then(|b| View::of(b).size()), v.size()) {
|
||||
(Some(a), Some(b)) => differs(a.0, b.0) || differs(a.1, b.1),
|
||||
(a, b) => a.is_some() != b.is_some(),
|
||||
};
|
||||
let lists = [sd.and_then(|s| s.get("symbolOverrides")).map(Kv::list).unwrap_or_default(), v.list("derivedSymbolData")];
|
||||
for (layer, list) in lists.into_iter().enumerate() {
|
||||
for ov in list {
|
||||
@@ -920,9 +1080,16 @@ impl<'a> Mapper<'a> {
|
||||
let e = overrides.entry([own_path.as_slice(), &rel].concat()).or_default();
|
||||
// The outer instance's overrides win over this one's own; derived layout comes last.
|
||||
if layer == 0 && outer.is_some() {
|
||||
e.insert(0, ov);
|
||||
e.insert(0, (Entry::Override, ov));
|
||||
} else {
|
||||
e.push(ov);
|
||||
e.push((
|
||||
match (layer, resized) {
|
||||
(0, _) => Entry::Override,
|
||||
(_, false) => Entry::Derived,
|
||||
(_, true) => Entry::Stale,
|
||||
},
|
||||
ov,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1105,11 +1272,21 @@ impl<'a> Mapper<'a> {
|
||||
o.insert("style".into(), json!({"boundVariables": text_bound}));
|
||||
}
|
||||
let mut modes = Obj::new();
|
||||
let mut lost = false;
|
||||
for e in v.get("variableModeBySetMap").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() {
|
||||
if let (Some(set), Some(mode)) = (e.get("variableSetID").and_then(ref_guid), e.get("variableModeID").and_then(guid)) {
|
||||
modes.insert(set, json!(mode));
|
||||
let (Some(set), Some(mode)) = (e.get("variableSetID"), e.get("variableModeID").and_then(guid)) else { continue };
|
||||
match self.resource_id(set) {
|
||||
Some(id) => {
|
||||
modes.insert(id, json!(mode));
|
||||
}
|
||||
// A mode of a collection neither named by guid nor resolvable in the file.
|
||||
None if set.get("guid").is_some() || set.get("assetRef").is_some() => lost = true,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if lost {
|
||||
self.warn("variable modes of collections that aren't in the file were skipped");
|
||||
}
|
||||
if !modes.is_empty() {
|
||||
o.insert("explicitVariableModes".into(), Value::Object(modes));
|
||||
}
|
||||
@@ -1128,7 +1305,7 @@ impl<'a> Mapper<'a> {
|
||||
found.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1)));
|
||||
let mut out = Vec::new();
|
||||
for (_, id, n) in found {
|
||||
let Some(node) = self.layer(&id, None, Affine::IDENTITY, None, 0) else { break };
|
||||
let Some(node) = self.layer(&id, None, Affine::IDENTITY, None, None, 0) else { break };
|
||||
let name = n.get("name").and_then(Kv::str).unwrap_or("Style");
|
||||
let desc = n.get("styleDescription").or_else(|| n.get("description")).and_then(Kv::str).unwrap_or("");
|
||||
let ty = n.get("styleType").and_then(Kv::str).unwrap_or("");
|
||||
@@ -1174,7 +1351,7 @@ impl<'a> Mapper<'a> {
|
||||
let mut variable_order = Vec::new();
|
||||
let mut variables = Obj::new();
|
||||
for (id, n) in of_type("VARIABLE", "sortPosition") {
|
||||
let Some(set) = n.get("variableSetID").and_then(ref_guid).filter(|s| collections.contains_key(s)) else { continue };
|
||||
let Some(set) = n.get("variableSetID").and_then(|s| self.resource_id(s)).filter(|s| collections.contains_key(s)) else { continue };
|
||||
let ty = n.get("variableResolvedType").and_then(Kv::str).unwrap_or("");
|
||||
if !matches!(ty, "BOOLEAN" | "FLOAT" | "STRING" | "COLOR") {
|
||||
continue;
|
||||
|
||||
@@ -32,18 +32,20 @@ enum ExpressionFunction ADDITION SUBTRACTION RESOLVE_VARIANT MULTIPLY DIVIDE EQU
|
||||
enum NavigationType NAVIGATE OVERLAY
|
||||
enum TransitionType INSTANT_TRANSITION DISSOLVE SMART_ANIMATE
|
||||
enum EasingType IN_CUBIC OUT_CUBIC
|
||||
enum ConstraintType MIN CENTER MAX STRETCH SCALE
|
||||
struct GUID uint sessionID, uint localID
|
||||
struct Color float r, float g, float b, float a
|
||||
struct Vector float x, float y
|
||||
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
|
||||
struct ColorStop Color color, float position
|
||||
message Image byte[] hash, string name
|
||||
message VariableID GUID guid
|
||||
message AssetRef string key, string version
|
||||
message VariableID GUID guid, AssetRef assetRef
|
||||
message PropRefValue GUID defId
|
||||
message AnyValue bool boolValue, float floatValue, VariableID alias, Color colorValue, PropRefValue propRefValue, TextData textDataValue, string textValue, Expression expressionValue
|
||||
message Expression ExpressionFunction expressionFunction, VariableData[] expressionArguments
|
||||
message VariableTarget VariableID id
|
||||
message VariableSetID GUID guid
|
||||
message VariableSetID GUID guid, AssetRef assetRef
|
||||
message ConditionalActions Action[] actions, VariableData condition
|
||||
message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable
|
||||
message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition
|
||||
@@ -70,7 +72,7 @@ message Event InteractionType interactionType
|
||||
message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType, VariableTarget targetVariable, VariableData targetVariableData, VariableSetID targetVariableSetID, GUID targetVariableModeID, ConditionalActions[] conditionalActions
|
||||
message Interaction Event event, Action[] actions
|
||||
message StartingPoint string name, string position
|
||||
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle
|
||||
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version
|
||||
message Blob byte[] bytes
|
||||
message Message NodeChange[] nodeChanges, Blob[] blobs
|
||||
";
|
||||
@@ -665,6 +667,209 @@ fn instances_inside_their_own_main_stop() {
|
||||
assert!(doc.nodes().any(|n| n.name == "Self"));
|
||||
}
|
||||
|
||||
/// A collection from a library is named by its asset key, not a guid — also in the explicit mode
|
||||
/// map of the frames, components and instances set it on, and in the variables' collection link.
|
||||
#[test]
|
||||
fn modes_of_collections_named_by_asset_import() {
|
||||
let modes = arr(vec![
|
||||
o(vec![("id", g(90)), ("name", s("Dark")), ("sortPosition", s("!"))]),
|
||||
o(vec![("id", g(91)), ("name", s("Light")), ("sortPosition", s("\""))]),
|
||||
]);
|
||||
let color_value = |c| o(vec![("value", o(vec![("colorValue", c)])), ("dataType", e("COLOR"))]);
|
||||
let values = o(vec![(
|
||||
"entries",
|
||||
arr(vec![
|
||||
o(vec![("modeID", g(90)), ("variableData", color_value(rgb(0.0, 0.0, 0.0)))]),
|
||||
o(vec![("modeID", g(91)), ("variableData", color_value(rgb(1.0, 1.0, 1.0)))]),
|
||||
]),
|
||||
)]);
|
||||
let by_asset = |key: &'static str, version: Option<&'static str>| {
|
||||
let mut r = vec![("key", s(key))];
|
||||
r.extend(version.map(|v| ("version", s(v))));
|
||||
o(vec![("assetRef", o(r))])
|
||||
};
|
||||
let mode_map = |set: Kv<'static>| o(vec![("entries", arr(vec![o(vec![("variableSetID", set), ("variableModeID", g(91))])]))]);
|
||||
let nodes = vec![
|
||||
node(0, None, "DOCUMENT", "Document", vec![]),
|
||||
node(1, Some((0, "!")), "CANVAS", "Page A", vec![]),
|
||||
node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]),
|
||||
// The collection itself says the asset key its references name.
|
||||
node(4, Some((2, "!")), "VARIABLE_SET", "Schema", vec![("variableSetModes", modes), ("key", s("schema-collection")), ("version", s("7"))]),
|
||||
node(
|
||||
5,
|
||||
Some((2, "\"")),
|
||||
"VARIABLE",
|
||||
"surface",
|
||||
vec![("variableSetID", by_asset("schema-collection", Some("7"))), ("variableResolvedType", e("COLOR")), ("variableDataValues", values)],
|
||||
),
|
||||
node(
|
||||
10,
|
||||
Some((1, "!")),
|
||||
"FRAME",
|
||||
"Light section",
|
||||
vec![("size", xy(400.0, 300.0)), ("variableModeBySetMap", mode_map(by_asset("schema-collection", Some("7"))))],
|
||||
),
|
||||
node(
|
||||
20,
|
||||
Some((1, "\"")),
|
||||
"SYMBOL",
|
||||
"Button",
|
||||
vec![("size", xy(100.0, 40.0)), ("variableModeBySetMap", mode_map(by_asset("schema-collection", None)))],
|
||||
),
|
||||
node(
|
||||
21,
|
||||
Some((1, "#")),
|
||||
"INSTANCE",
|
||||
"Button copy",
|
||||
vec![
|
||||
("size", xy(100.0, 40.0)),
|
||||
("symbolData", o(vec![("symbolID", g(20))])),
|
||||
("variableModeBySetMap", mode_map(by_asset("schema-collection", Some("7")))),
|
||||
],
|
||||
),
|
||||
// One names a collection that isn't in the file: its mode is skipped, with a warning.
|
||||
node(11, Some((1, "$")), "FRAME", "Gone", vec![("size", xy(400.0, 300.0)), ("variableModeBySetMap", mode_map(by_asset("gone", None)))]),
|
||||
];
|
||||
let (doc, warnings) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
|
||||
assert_eq!(doc.variables.collections.len(), 1);
|
||||
assert_eq!(doc.variables.variables.len(), 1);
|
||||
assert_eq!(doc.variables.variables[0].collection, "1:4");
|
||||
for name in ["Light section", "Button", "Button copy"] {
|
||||
let n = named(&doc, name);
|
||||
assert_eq!(n.var_modes.get("1:4").map(String::as_str), Some("1:91"), "{name}");
|
||||
}
|
||||
assert!(named(&doc, "Gone").var_modes.is_empty());
|
||||
assert!(warnings.iter().any(|w| w.contains("variable modes of collections that aren't in the file")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resized_instances_lay_their_layers_out_by_their_constraints() {
|
||||
let pin = |h: &'static str, v: &'static str| vec![("horizontalConstraint", e(h)), ("verticalConstraint", e(v))];
|
||||
let layer = |id, pos, ty, name, w, h, x, y, c: Vec<(&'static str, Kv<'static>)>| {
|
||||
node(id, Some((5, pos)), ty, name, [vec![("size", xy(w, h)), ("transform", at(x, y))], c].concat())
|
||||
};
|
||||
let nodes = vec![
|
||||
node(0, None, "DOCUMENT", "D", vec![]),
|
||||
node(1, Some((0, "!")), "CANVAS", "P", vec![]),
|
||||
node(5, Some((1, "!")), "SYMBOL", "Logo", vec![("size", xy(132.0, 22.0)), ("transform", at(0.0, 0.0))]),
|
||||
layer(6, "!", "ROUNDED_RECTANGLE", "Mark", 132.0, 22.0, 0.0, 0.0, pin("SCALE", "SCALE")),
|
||||
layer(7, "\"", "ROUNDED_RECTANGLE", "Badge", 32.0, 10.0, 100.0, 12.0, pin("MAX", "MAX")),
|
||||
layer(8, "#", "ROUNDED_RECTANGLE", "Bar", 112.0, 2.0, 10.0, 0.0, pin("STRETCH", "MIN")),
|
||||
// A group scales its layers with it.
|
||||
layer(11, "$", "FRAME", "Wordmark", 66.0, 22.0, 66.0, 0.0, [pin("SCALE", "SCALE"), vec![("resizeToFit", Kv::Bool(true))]].concat()),
|
||||
node(12, Some((11, "!")), "ROUNDED_RECTANGLE", "Letter", vec![("size", xy(66.0, 22.0)), ("transform", at(0.0, 0.0))]),
|
||||
node(
|
||||
9,
|
||||
Some((1, "\"")),
|
||||
"INSTANCE",
|
||||
"Small logo",
|
||||
vec![("size", xy(90.0, 15.0)), ("transform", at(0.0, 100.0)), ("symbolData", o(vec![("symbolID", g(5))]))],
|
||||
),
|
||||
// Derived layout the file gives wins over the constraints.
|
||||
node(
|
||||
10,
|
||||
Some((1, "#")),
|
||||
"INSTANCE",
|
||||
"Derived logo",
|
||||
vec![
|
||||
("size", xy(90.0, 15.0)),
|
||||
("transform", at(0.0, 200.0)),
|
||||
("symbolData", o(vec![("symbolID", g(5))])),
|
||||
("derivedSymbolData", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))])), ("size", xy(50.0, 5.0))])])),
|
||||
],
|
||||
),
|
||||
// A size the instance overrides: the place still follows the constraints.
|
||||
node(
|
||||
13,
|
||||
Some((1, "$")),
|
||||
"INSTANCE",
|
||||
"Resized badge",
|
||||
vec![
|
||||
("size", xy(90.0, 15.0)),
|
||||
("transform", at(0.0, 300.0)),
|
||||
(
|
||||
"symbolData",
|
||||
o(vec![
|
||||
("symbolID", g(5)),
|
||||
("symbolOverrides", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(7)]))])), ("size", xy(20.0, 10.0))])])),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
|
||||
let box_of = |inst: &str, name: &str| {
|
||||
let i = named(&doc, inst);
|
||||
let n = i.children.iter().map(|c| doc.node(*c).unwrap()).find(|n| n.name == name).unwrap();
|
||||
let t = n.transform.translation();
|
||||
[t.x, t.y, n.width, n.height].map(|x| (x * 1e3).round() / 1e3)
|
||||
};
|
||||
assert_eq!(box_of("Small logo", "Mark"), [0.0, 0.0, 90.0, 15.0]);
|
||||
assert_eq!(box_of("Small logo", "Badge"), [58.0, 5.0, 32.0, 10.0]);
|
||||
assert_eq!(box_of("Small logo", "Bar"), [10.0, 0.0, 70.0, 2.0]);
|
||||
// The main's own, then the instances'.
|
||||
let mut letters: Vec<[f64; 2]> =
|
||||
doc.nodes().filter(|n| n.name == "Letter").map(|n| [n.width, n.height].map(|x| (x * 1e3).round() / 1e3)).collect();
|
||||
letters.sort_by(|a, b| a[0].total_cmp(&b[0]));
|
||||
assert_eq!(letters, [[45.0, 15.0], [45.0, 15.0], [45.0, 15.0], [66.0, 22.0]]);
|
||||
assert_eq!(box_of("Derived logo", "Mark")[2..], [50.0, 5.0]);
|
||||
assert_eq!(box_of("Resized badge", "Badge"), [58.0, 5.0, 20.0, 10.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() {
|
||||
let stretch = || vec![("horizontalConstraint", e("STRETCH")), ("verticalConstraint", e("MIN"))];
|
||||
let nodes = vec![
|
||||
node(0, None, "DOCUMENT", "D", vec![]),
|
||||
node(1, Some((0, "!")), "CANVAS", "P", vec![]),
|
||||
node(20, Some((1, "!")), "SYMBOL", "Track", vec![("size", xy(100.0, 10.0)), ("transform", at(0.0, 0.0))]),
|
||||
node(21, Some((20, "!")), "ROUNDED_RECTANGLE", "Fill", [vec![("size", xy(100.0, 10.0)), ("transform", at(0.0, 0.0))], stretch()].concat()),
|
||||
node(30, Some((1, "\"")), "SYMBOL", "Slider", vec![("size", xy(200.0, 50.0)), ("transform", at(0.0, 100.0))]),
|
||||
// The nested instance is 150 wide in its main, with the layout derived for that.
|
||||
node(
|
||||
31,
|
||||
Some((30, "!")),
|
||||
"INSTANCE",
|
||||
"Inner",
|
||||
[
|
||||
vec![
|
||||
("size", xy(150.0, 10.0)),
|
||||
("transform", at(0.0, 0.0)),
|
||||
("symbolData", o(vec![("symbolID", g(20))])),
|
||||
(
|
||||
"derivedSymbolData",
|
||||
arr(vec![o(vec![
|
||||
("guidPath", o(vec![("guids", arr(vec![g(21)]))])),
|
||||
("size", xy(150.0, 10.0)),
|
||||
("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])),
|
||||
])]),
|
||||
),
|
||||
],
|
||||
stretch(),
|
||||
]
|
||||
.concat(),
|
||||
),
|
||||
node(
|
||||
40,
|
||||
Some((1, "#")),
|
||||
"INSTANCE",
|
||||
"Wide slider",
|
||||
vec![("size", xy(300.0, 50.0)), ("transform", at(0.0, 200.0)), ("symbolData", o(vec![("symbolID", g(30))]))],
|
||||
),
|
||||
];
|
||||
let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
|
||||
let width = |outer: &str| {
|
||||
let inner = doc.node(named(&doc, outer).children[0]).unwrap();
|
||||
(inner.width, doc.node(inner.children[0]).unwrap().width)
|
||||
};
|
||||
// In its main the derived layout holds; stretched to 250 it no longer does.
|
||||
assert_eq!(width("Slider"), (150.0, 150.0));
|
||||
assert_eq!(width("Wide slider"), (250.0, 250.0));
|
||||
// What else the derived data gives (the colour a variable resolves to, say) still holds.
|
||||
let fill = doc.node(doc.node(named(&doc, "Wide slider").children[0]).unwrap().children[0]).unwrap();
|
||||
assert!(matches!(fill.fills.first().map(|p| &p.kind), Some(figcraft_doc::color::PaintKind::Solid { color }) if color.r == 1.0 && color.g == 0.0));
|
||||
}
|
||||
|
||||
#[path = "tail_tests.rs"]
|
||||
mod tail;
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! The images of a native file within a budget (#189): however many members each within the
|
||||
//! per-member limit a file holds, the images read from it together stay within a byte and a count
|
||||
//! budget. The document's own images are read first, and the ones past the budget are reported.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use figcraft_doc::Document;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn png(k: u8) -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([k, 7, 0, 255]));
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// A saved document with `n` images, and their hashes and total size.
|
||||
fn file_with_images(n: u8) -> (Vec<u8>, Vec<String>, u64) {
|
||||
let mut d = Document::new("File");
|
||||
let mut hashes = Vec::new();
|
||||
let mut total = 0;
|
||||
for k in 0..n {
|
||||
let bytes = png(k);
|
||||
total += bytes.len() as u64;
|
||||
hashes.push(add_image(&mut d, bytes).unwrap().0);
|
||||
}
|
||||
(save_bytes(&d, None).unwrap(), hashes, total)
|
||||
}
|
||||
|
||||
/// `bytes` with one more member, `name` holding `data`.
|
||||
fn with_member(bytes: &[u8], name: &str, data: &[u8]) -> Vec<u8> {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let names: Vec<String> = z.file_names().map(str::to_string).collect();
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
let mut w = zip::ZipWriter::new(&mut buf);
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for n in names {
|
||||
let member = read_member(&mut z, &n, u64::MAX).unwrap();
|
||||
w.start_file(n, stored).unwrap();
|
||||
w.write_all(&member).unwrap();
|
||||
}
|
||||
w.start_file(name, stored).unwrap();
|
||||
w.write_all(data).unwrap();
|
||||
w.finish().unwrap();
|
||||
buf.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_exactly_within_the_budget_all_load() {
|
||||
let (bytes, hashes, total) = file_with_images(3);
|
||||
let loaded = load_within(&bytes, ImageBudget { images: 3, bytes: total }).unwrap();
|
||||
assert!(loaded.issues.is_empty(), "{:?}", loaded.issues);
|
||||
assert!(hashes.iter().all(|h| loaded.doc.images.contains_key(h)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_past_the_byte_budget_are_left_out_and_reported() {
|
||||
let (bytes, hashes, total) = file_with_images(3);
|
||||
let loaded = load_within(&bytes, ImageBudget { images: 100, bytes: total - 1 }).unwrap();
|
||||
let held: u64 = loaded.doc.images.values().map(|i| i.bytes.len() as u64).sum();
|
||||
assert!(held < total, "{held} bytes held");
|
||||
assert_eq!(loaded.doc.images.len(), 2);
|
||||
let lost: Vec<&String> = hashes.iter().filter(|h| !loaded.doc.images.contains_key(*h)).collect();
|
||||
assert_eq!(lost.len(), 1);
|
||||
assert!(loaded.issues.iter().any(|i| i.contains(lost[0].as_str()) && i.contains("together")), "{:?}", loaded.issues);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_past_the_count_budget_are_left_out_and_reported() {
|
||||
let (bytes, _, _) = file_with_images(4);
|
||||
let loaded = load_within(&bytes, ImageBudget { images: 3, bytes: u64::MAX }).unwrap();
|
||||
assert_eq!(loaded.doc.images.len(), 3);
|
||||
assert!(loaded.issues.iter().any(|i| i.contains("more than 3 images")), "{:?}", loaded.issues);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_documents_own_images_are_read_before_others() {
|
||||
let (bytes, hashes, total) = file_with_images(2);
|
||||
// An image the document doesn't list, named to come first in the archive's order.
|
||||
let extra = with_member(&bytes, "images/0000", &png(99));
|
||||
let loaded = load_within(&extra, ImageBudget { images: 2, bytes: total }).unwrap();
|
||||
assert!(hashes.iter().all(|h| loaded.doc.images.contains_key(h)), "{:?}", loaded.issues);
|
||||
assert!(!loaded.issues.iter().any(|i| hashes.iter().any(|h| i.contains(h.as_str()))), "{:?}", loaded.issues);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_within_the_shipped_budget_loads_as_before() {
|
||||
let (bytes, hashes, _) = file_with_images(3);
|
||||
let loaded = load_bytes_with_versions(&bytes).unwrap();
|
||||
assert!(loaded.issues.is_empty(), "{:?}", loaded.issues);
|
||||
assert!(hashes.iter().all(|h| loaded.doc.images.contains_key(h)));
|
||||
}
|
||||
+52
-12
@@ -47,6 +47,8 @@ pub const MAX_VERSION_DESCRIPTION: usize = 2000;
|
||||
/// Largest version index and total size of the version snapshots read from one file.
|
||||
const MAX_VERSION_INDEX: u64 = 16 * 1024 * 1024;
|
||||
const MAX_VERSION_BYTES: u64 = 1024 * 1024 * 1024;
|
||||
/// Most images, and image bytes together, read from one file (members past it are left out).
|
||||
const IMAGE_BUDGET: ImageBudget = ImageBudget { images: 20_000, bytes: 4 * 1024 * 1024 * 1024 };
|
||||
const BRANCH_INDEX: &str = "branches/index.json";
|
||||
/// Most branches a file keeps: making more is refused, extra entries in a file are skipped.
|
||||
pub const MAX_BRANCHES: usize = 100;
|
||||
@@ -453,8 +455,20 @@ pub(crate) fn read_member<R: Read + Seek>(z: &mut zip::ZipArchive<R>, name: &str
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// How many images, and how many bytes of them together, one file may give.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct ImageBudget {
|
||||
pub images: usize,
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
/// Read a `.figcraft` archive (or bare JSON) with its version history.
|
||||
pub fn load_bytes_with_versions(bytes: &[u8]) -> Result<Loaded, FormatError> {
|
||||
load_within(bytes, IMAGE_BUDGET)
|
||||
}
|
||||
|
||||
/// [`load_bytes_with_versions`] reading at most `budget` of images.
|
||||
pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result<Loaded, FormatError> {
|
||||
let trimmed = bytes.iter().position(|b| !b.is_ascii_whitespace()).and_then(|i| bytes.get(i));
|
||||
if trimmed == Some(&b'{') {
|
||||
let text = std::str::from_utf8(bytes).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||||
@@ -464,18 +478,7 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result<Loaded, FormatError> {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||||
let text = String::from_utf8(read_member(&mut z, "document.json", MAX_MEMBER)?).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||||
let (mut doc, mut issues, own_images) = parse_json(&text)?;
|
||||
let names: Vec<String> = z.file_names().filter(|n| n.starts_with("images/")).map(str::to_string).collect();
|
||||
let mut images = BTreeMap::new();
|
||||
let mut unread = Vec::new();
|
||||
for name in names {
|
||||
let hash = name.trim_start_matches("images/").to_string();
|
||||
match read_member(&mut z, &name, MAX_MEMBER) {
|
||||
Ok(data) => {
|
||||
images.insert(hash, image_data(data));
|
||||
}
|
||||
Err(e) => unread.push((hash, e)),
|
||||
}
|
||||
}
|
||||
let (images, unread) = read_images(&mut z, own_images.as_ref(), budget);
|
||||
report_lost_images(&mut issues, &unread, own_images.as_ref(), &images);
|
||||
match library::read(&mut z, &images, u64::MAX) {
|
||||
Ok(lib) => doc.published = lib.map(Arc::new),
|
||||
@@ -497,6 +500,41 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result<Loaded, FormatError> {
|
||||
Ok(Loaded { doc, issues, versions, branches })
|
||||
}
|
||||
|
||||
/// The archive's `images/` members by hash, within `budget`, and the ones that couldn't be read
|
||||
/// with why. The document's own images (`own`) are read first, so a file past the budget loses
|
||||
/// the images only its history uses before its own.
|
||||
fn read_images<R: Read + Seek>(
|
||||
z: &mut zip::ZipArchive<R>,
|
||||
own: Option<&BTreeSet<String>>,
|
||||
budget: ImageBudget,
|
||||
) -> (BTreeMap<String, ImageData>, Vec<(String, FormatError)>) {
|
||||
let mut hashes: Vec<String> = z.file_names().filter_map(|n| n.strip_prefix("images/")).map(str::to_string).collect();
|
||||
hashes.sort_by_key(|h| !own.is_some_and(|o| o.contains(h)));
|
||||
let mut images = BTreeMap::new();
|
||||
let mut unread = Vec::new();
|
||||
let mut left = budget.bytes;
|
||||
for (i, hash) in hashes.into_iter().enumerate() {
|
||||
let name = format!("images/{hash}");
|
||||
if i >= budget.images {
|
||||
unread.push((hash, FormatError::Corrupt(format!("the file has more than {} images", budget.images))));
|
||||
continue;
|
||||
}
|
||||
if z.by_name(&name).is_ok_and(|f| f.size() > left) {
|
||||
let mb = budget.bytes / (1024 * 1024);
|
||||
unread.push((hash, FormatError::Corrupt(format!("the file's images together pass {mb} MB"))));
|
||||
continue;
|
||||
}
|
||||
match read_member(z, &name, left.min(MAX_MEMBER)) {
|
||||
Ok(data) => {
|
||||
left = left.saturating_sub(data.len() as u64);
|
||||
images.insert(hash, image_data(data));
|
||||
}
|
||||
Err(e) => unread.push((hash, e)),
|
||||
}
|
||||
}
|
||||
(images, unread)
|
||||
}
|
||||
|
||||
/// Most lost images reported one by one (the rest as a count).
|
||||
const MAX_IMAGE_ISSUES: usize = 20;
|
||||
|
||||
@@ -712,6 +750,8 @@ pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec<String>), Form
|
||||
#[cfg(test)]
|
||||
mod branch_tests;
|
||||
#[cfg(test)]
|
||||
mod image_budget_tests;
|
||||
#[cfg(test)]
|
||||
mod image_tests;
|
||||
#[cfg(test)]
|
||||
mod large_tests;
|
||||
|
||||
@@ -128,7 +128,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"batch",
|
||||
"Run commands as one step",
|
||||
"Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. Note: later commands cannot reference ids created by earlier ones in the same batch (ids are only known afterwards), so create containers first, then batch the edits.",
|
||||
"Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. A later step can use a layer an earlier step created: anywhere in its params, {\"$step\": n} stands for the `id` in step n's result (zero-based; {\"$step\": n, \"field\": \"…\"} picks another field). Example: [{\"command\":\"node.create\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$step\":0}}}] builds a frame and a text inside it in one call.",
|
||||
obj(
|
||||
json!({
|
||||
"commands": {"type": "array", "minItems": 1, "description": "Steps to run", "items": obj(json!({"command": string("Command id"), "params": {"type": "object"}}), &["command"])},
|
||||
|
||||
@@ -307,3 +307,40 @@ fn task_vector_edit_keys() {
|
||||
assert_eq!(c.page_children().len(), 1);
|
||||
assert!(c.call("run_command", json!({"command": "vector.edit.state", "params": {}}))["isError"] == true, "Enter left edit mode");
|
||||
}
|
||||
|
||||
/// A whole card in one `batch` call: later steps reach the frames earlier steps created through
|
||||
/// `{"$step": n}`, so the tree is built in one round-trip and one undo step.
|
||||
#[test]
|
||||
fn task_card_in_one_batch() {
|
||||
let mut c = Client::new();
|
||||
let out = c.ok(
|
||||
"batch",
|
||||
json!({"label": "Card", "commands": [
|
||||
{"command": "node.create", "params": {"type": "frame", "name": "Card", "width": 320, "height": 50,
|
||||
"props": {"layout": "vertical", "padding": 24, "gap": 12, "sizingV": "hug"}}},
|
||||
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}, "text": "Ada Lovelace"}},
|
||||
{"command": "node.create", "params": {"type": "frame", "parent": {"$step": 0}, "name": "Button", "height": 44,
|
||||
"props": {"layout": "horizontal", "sizingH": "fill"}}},
|
||||
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 2}, "text": "Follow"}},
|
||||
{"command": "node.set", "params": {"ids": [{"$step": 2}], "props": {"fill": "#3B5BDB"}}},
|
||||
]}),
|
||||
);
|
||||
let results = ids(&out);
|
||||
assert_eq!(results.len(), 5);
|
||||
let (card, button) = (results[0]["id"].clone(), results[2]["id"].clone());
|
||||
let n = c.node(&card);
|
||||
assert_eq!(ids(&n["children"]).len(), 2, "title and button are inside the card");
|
||||
let b = c.node(&button);
|
||||
assert_eq!(ids(&b["children"]).len(), 1, "the label is inside the button");
|
||||
assert_eq!(b["fills"][0]["color"].as_str().unwrap().to_ascii_uppercase(), "#3B5BDB");
|
||||
assert_eq!(c.doc()["history"]["undo"][0], "Card", "one undo step");
|
||||
// A forward reference fails the whole batch and names the step.
|
||||
let e = c.err(
|
||||
"batch",
|
||||
json!({"commands": [
|
||||
{"command": "node.create", "params": {"type": "frame", "parent": {"$step": 1}}},
|
||||
{"command": "node.create", "params": {"type": "frame"}},
|
||||
]}),
|
||||
);
|
||||
assert!(e.contains("step 0") && e.contains("earlier step"), "{e}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! A drop shadow under a layer with a background blur (#255): the shadow is cast by what the layer
|
||||
//! paints (a translucent fill casts a faint one), not by the blurred backdrop drawn through its
|
||||
//! shape, and the blur reads what lies under the layer with its own shadow there, so a shadow
|
||||
//! that shows behind transparent areas is seen, blurred, through the layer.
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{Blur, Document, Effect, EffectKind, NodeId, NodeKind, Shadow};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A white 240 × 200 frame with blue and green stripes and, on top, a pane at (60, 40) 120 × 80
|
||||
/// filled 20% black, with a hard black drop shadow 40 down (below it and under its lower half)
|
||||
/// and, when `blur`, a background blur.
|
||||
fn scene(show_behind: bool, blur: bool) -> (Document, NodeId) {
|
||||
let mut d = Document::new("shadow behind blur");
|
||||
let p = d.first_page().unwrap();
|
||||
let f = d.create(p, NodeKind::Frame, "Frame", 0.0, 0.0, 240.0, 200.0).unwrap();
|
||||
d.get_mut(f).unwrap().fills = vec![Paint::solid(Color::from_hex("FFFFFF").unwrap())];
|
||||
for (i, x) in [20.0, 80.0, 140.0, 200.0].into_iter().enumerate() {
|
||||
let s = d.create(f, NodeKind::Rectangle, "Stripe", x, 0.0, 20.0, 200.0).unwrap();
|
||||
d.get_mut(s).unwrap().fills = vec![Paint::solid(Color::from_hex(if i % 2 == 0 { "2040E0" } else { "20C060" }).unwrap())];
|
||||
}
|
||||
let pane = d.create(f, NodeKind::Rectangle, "Pane", 60.0, 40.0, 120.0, 80.0).unwrap();
|
||||
let n = d.get_mut(pane).unwrap();
|
||||
n.fills = vec![Paint::solid(Color::new(0.0, 0.0, 0.0, 0.2))];
|
||||
let shadow = Shadow { color: Color::new(0.0, 0.0, 0.0, 1.0), offset: (0.0, 40.0), radius: 0.0, show_behind, ..Shadow::default() };
|
||||
n.effects = vec![Effect { kind: EffectKind::DropShadow(shadow), visible: true }];
|
||||
if blur {
|
||||
n.effects.push(Effect { kind: EffectKind::BackgroundBlur(Blur::uniform(8.0)), visible: true });
|
||||
}
|
||||
(d, f)
|
||||
}
|
||||
|
||||
fn px(p: &Pixmap, x: u32, y: u32) -> [u8; 4] {
|
||||
let i = ((y * p.width() + x) * 4) as usize;
|
||||
[p.data()[i], p.data()[i + 1], p.data()[i + 2], p.data()[i + 3]]
|
||||
}
|
||||
|
||||
/// The largest channel difference between `a` and `b` over the shadow below the pane
|
||||
/// (60..180 × 122..160).
|
||||
fn max_diff_below_pane(a: &Pixmap, b: &Pixmap) -> u8 {
|
||||
let mut worst = 0;
|
||||
for y in 122..160 {
|
||||
for x in 60..180 {
|
||||
worst = worst.max(px(a, x, y).iter().zip(px(b, x, y)).map(|(c, d)| c.abs_diff(d)).max().unwrap());
|
||||
}
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_background_blur_doesnt_change_the_shadow_its_layer_casts() {
|
||||
for show_behind in [false, true] {
|
||||
let (with_blur, f) = scene(show_behind, true);
|
||||
let (without, _) = scene(show_behind, false);
|
||||
let got = render_node(&with_blur, f, 1.0).unwrap();
|
||||
let want = render_node(&without, f, 1.0).unwrap();
|
||||
let diff = max_diff_below_pane(&got, &want);
|
||||
assert!(diff <= 2, "show behind {show_behind}: the shadow below the pane differs by {diff}");
|
||||
// A 20% fill casts a faint shadow: the white between the stripes stays light.
|
||||
let light = px(&got, 120, 140);
|
||||
assert!(light[0] > 180, "show behind {show_behind}: {light:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shadow_showing_behind_is_seen_through_the_background_blur() {
|
||||
let (d, f) = scene(true, true);
|
||||
let got = render_node(&d, f, 1.0).unwrap();
|
||||
// Between the stripes, the pane's top (no shadow under it) and its lower half (its own shadow
|
||||
// under it, from y = 80).
|
||||
let (top, low) = (px(&got, 120, 50), px(&got, 120, 110));
|
||||
assert!(top[0] > low[0] + 20, "the shadow under the pane doesn't show through: top {top:?}, lower half {low:?}");
|
||||
// Hidden behind the layer, it doesn't.
|
||||
let (d, f) = scene(false, true);
|
||||
let got = render_node(&d, f, 1.0).unwrap();
|
||||
let (top, low) = (px(&got, 120, 50), px(&got, 120, 110));
|
||||
assert!(top[0].abs_diff(low[0]) <= 2, "a hidden shadow shows: top {top:?}, lower half {low:?}");
|
||||
}
|
||||
+117
-30
@@ -423,16 +423,18 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
|
||||
let shape = node_outline(ctx.doc, n);
|
||||
// Background blur and glass: the whole backdrop under the layer (every surface below this
|
||||
// one, composited), through the shape, under the content.
|
||||
if backdrop::has_effects(n)
|
||||
&& let Some(sh) = &backdrop::outline(ctx.doc, n)
|
||||
{
|
||||
let pad = backdrop::pad(n, ctx.scale);
|
||||
let p = i32::try_from(pad).unwrap_or(0);
|
||||
if let Some(back) = backdrop::backdrop(pix, ctx.under, lx.saturating_sub(p), ly.saturating_sub(p), lw + pad * 2, lh + pad * 2)
|
||||
&& let Some(b) = backdrop::effects(n, sh, &back, pad, lw, lh, lxf, ctx.scale)
|
||||
{
|
||||
layer.draw_pixmap(0, 0, b.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
|
||||
}
|
||||
let back_shape = if backdrop::has_effects(n) { backdrop::outline(ctx.doc, n) } else { None };
|
||||
let read_backdrop = |pix: &Pixmap| back_shape.as_ref().and_then(|sh| backdrop_layer(ctx, pix, n, sh, (lx, ly), (lw, lh), lxf));
|
||||
// With drop shadows too, the layer casts them from what it paints, not from the backdrop, and
|
||||
// the backdrop is read again once they lie under it (a shadow showing behind is seen through
|
||||
// it). Until then, the layer's content sees the backdrop read first under it (#255).
|
||||
let casts = back_shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::DropShadow(_)));
|
||||
// Kept apart, the first read is only what backdrop effects inside the content see: without
|
||||
// any, the backdrop (blur or glass, the costly part) is computed once, after the shadows.
|
||||
let nested = || ctx.doc.subtree(id).iter().skip(1).any(|c| ctx.doc.node(*c).is_some_and(backdrop::has_effects));
|
||||
let first_back = if !casts || nested() { read_backdrop(pix) } else { None };
|
||||
if !casts && let Some(b) = &first_back {
|
||||
layer.draw_pixmap(0, 0, b.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
|
||||
}
|
||||
let op = n.opacity.clamp(0.0, 1.0);
|
||||
{
|
||||
@@ -448,9 +450,12 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
|
||||
}
|
||||
};
|
||||
let has_inner = shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::InnerShadow(_)));
|
||||
// Backdrops further down see this layer composited onto `pix`.
|
||||
// Backdrops further down see this layer composited onto `pix` (over its backdrop, when
|
||||
// that is kept apart for now).
|
||||
let under = Under { opacity: op, blend: n.blend, ..Under::new(pix, (lx, ly), ctx.under) };
|
||||
draw_content(&Ctx { under: Some(&under), ..*ctx }, &mut layer, n, lxf, depth, has_inner.then_some(&inner as &dyn Fn(&mut Pixmap)));
|
||||
let over_back = first_back.as_ref().filter(|_| casts).map(|b| Under::new(b, (0, 0), Some(&under)));
|
||||
let content_under = over_back.as_ref().unwrap_or(&under);
|
||||
draw_content(&Ctx { under: Some(content_under), ..*ctx }, &mut layer, n, lxf, depth, has_inner.then_some(&inner as &dyn Fn(&mut Pixmap)));
|
||||
}
|
||||
// A group is no outline of its own: its inner shadows are cast from around what its layers
|
||||
// draw together, over them.
|
||||
@@ -465,24 +470,60 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
|
||||
}
|
||||
}
|
||||
// Layer blur, noise and texture, in list order, over everything else.
|
||||
if !casts {
|
||||
layer_effects(ctx, &mut layer, n, id, lxf);
|
||||
}
|
||||
// Drop shadows go under the layer, in effect-list order.
|
||||
{
|
||||
// Kept apart, the backdrop isn't in the layer yet: the shadows are cast from the content
|
||||
// with the layer effects it will get (a copy only when it gets some).
|
||||
let layer_fx =
|
||||
n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::LayerBlur(_) | EffectKind::Noise(_) | EffectKind::Texture(_)));
|
||||
let content = (casts && layer_fx).then(|| {
|
||||
let mut c = layer.clone();
|
||||
layer_effects(ctx, &mut c, n, id, lxf);
|
||||
c
|
||||
});
|
||||
let caster = shadow::Caster::new(ctx.doc, n, content.as_ref().unwrap_or(&layer), lxf, ctx.scale);
|
||||
for e in n.effects.iter().filter(|e| e.visible) {
|
||||
if let EffectKind::DropShadow(s) = &e.kind
|
||||
&& let Some(sh) = shadow::drop_shadow(&caster, s)
|
||||
{
|
||||
composite(pix, (lx, ly), &sh, op, s.blend);
|
||||
}
|
||||
}
|
||||
}
|
||||
if casts {
|
||||
// The backdrop with the shadows under it, the content over it, then the layer effects.
|
||||
if let Some(mut b) = read_backdrop(pix) {
|
||||
b.draw_pixmap(0, 0, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
|
||||
layer = b;
|
||||
}
|
||||
layer_effects(ctx, &mut layer, n, id, lxf);
|
||||
}
|
||||
composite(pix, (lx, ly), &layer, op, n.blend);
|
||||
}
|
||||
|
||||
/// Node `n`'s backdrop effects through its backdrop outline `sh`, for its `size` layer at `at` on
|
||||
/// `pix` (local → layer pixels through `lxf`): what `pix` holds there now, over what lies under it.
|
||||
fn backdrop_layer(ctx: &Ctx, pix: &Pixmap, n: &Node, sh: &shadow::Outline, at: (i32, i32), size: (u32, u32), lxf: Affine) -> Option<Pixmap> {
|
||||
let ((lx, ly), (lw, lh)) = (at, size);
|
||||
let pad = backdrop::pad(n, ctx.scale);
|
||||
let p = i32::try_from(pad).unwrap_or(0);
|
||||
let back = backdrop::backdrop(pix, ctx.under, lx.saturating_sub(p), ly.saturating_sub(p), lw + pad * 2, lh + pad * 2)?;
|
||||
backdrop::effects(n, sh, &back, pad, lw, lh, lxf, ctx.scale)
|
||||
}
|
||||
|
||||
/// Node `n`'s layer blur, noise and texture on its `layer`, in list order.
|
||||
fn layer_effects(ctx: &Ctx, layer: &mut Pixmap, n: &Node, id: NodeId, lxf: Affine) {
|
||||
for e in n.effects.iter().filter(|e| e.visible) {
|
||||
match &e.kind {
|
||||
EffectKind::LayerBlur(b) => blur::apply(&mut layer, b, (n.width, n.height), lxf, ctx.scale),
|
||||
EffectKind::Noise(nz) => grain::noise(&mut layer, nz, lxf, id.0 as u32),
|
||||
EffectKind::Texture(t) => grain::texture(&mut layer, t, lxf, ctx.scale, id.0 as u32),
|
||||
EffectKind::LayerBlur(b) => blur::apply(layer, b, (n.width, n.height), lxf, ctx.scale),
|
||||
EffectKind::Noise(nz) => grain::noise(layer, nz, lxf, id.0 as u32),
|
||||
EffectKind::Texture(t) => grain::texture(layer, t, lxf, ctx.scale, id.0 as u32),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Drop shadows go under the layer, in effect-list order.
|
||||
let caster = shadow::Caster::new(ctx.doc, n, &layer, lxf, ctx.scale);
|
||||
for e in n.effects.iter().filter(|e| e.visible) {
|
||||
if let EffectKind::DropShadow(s) = &e.kind
|
||||
&& let Some(sh) = shadow::drop_shadow(&caster, s)
|
||||
{
|
||||
composite(pix, (lx, ly), &sh, op, s.blend);
|
||||
}
|
||||
}
|
||||
composite(pix, (lx, ly), &layer, op, n.blend);
|
||||
}
|
||||
|
||||
fn draw_outline(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) {
|
||||
@@ -531,21 +572,50 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize,
|
||||
NodeKind::Text(t) => {
|
||||
let Some((layout, dy)) = figcraft_text::layout_node(n) else { return };
|
||||
let gxf = xf * Affine::translate((0.0, dy));
|
||||
// Colour glyphs (emoji) carry their own ink, which draws instead of their outline: a
|
||||
// bitmap the font ships, or COLR layers with a colour each.
|
||||
let gs = gxf.determinant().abs().sqrt();
|
||||
let mut ink: Vec<(usize, Ink)> = Vec::new();
|
||||
// Group glyphs by style so runs with their own fills paint with them.
|
||||
let mut by_style: HashMap<usize, BezPath> = HashMap::new();
|
||||
for g in &layout.glyphs {
|
||||
by_style.entry(g.style).or_default().extend(g.path().elements().iter().copied());
|
||||
match g.color(gs) {
|
||||
Some(figcraft_text::ColorInk::Bitmap(bitmap, place)) => ink.push((g.style, Ink::Bitmap(bitmap, place))),
|
||||
Some(figcraft_text::ColorInk::Layers(layers)) => {
|
||||
for layer in layers.iter() {
|
||||
ink.push((g.style, Ink::Layer(g.layer_path(layer.gid), layer.rgba)));
|
||||
}
|
||||
}
|
||||
None => by_style.entry(g.style).or_default().extend(g.path().elements().iter().copied()),
|
||||
}
|
||||
}
|
||||
for d in &layout.decorations {
|
||||
by_style.entry(d.style).or_default().extend(d.path().elements().iter().copied());
|
||||
}
|
||||
let mut keys: Vec<usize> = by_style.keys().copied().collect();
|
||||
// A run can have ink and no outline at all (bitmap-only fonts).
|
||||
let mut keys: Vec<usize> = by_style.keys().copied().chain(ink.iter().map(|(style, _)| *style)).collect();
|
||||
keys.sort_unstable();
|
||||
// Every run's fills, then what paints over the fills, then the strokes on top.
|
||||
keys.dedup();
|
||||
// Every run's fills (its ink included), then what paints over the fills, then the
|
||||
// strokes on top.
|
||||
for k in &keys {
|
||||
let Some(tp) = by_style.get(k).and_then(ts_path) else { continue };
|
||||
let fills = layout.styles.get(*k).and_then(|s| s.fills.as_ref()).unwrap_or(&n.fills);
|
||||
fill_with(pix, &tp, fills, w, h, gxf, FillRule::Winding, ctx, None);
|
||||
if let Some(tp) = by_style.get(k).and_then(ts_path) {
|
||||
fill_with(pix, &tp, fills, w, h, gxf, FillRule::Winding, ctx, None);
|
||||
}
|
||||
for (_, item) in ink.iter().filter(|(style, _)| style == k) {
|
||||
match item {
|
||||
Ink::Bitmap(bitmap, place) => blit(pix, bitmap, ts_transform(gxf * *place)),
|
||||
Ink::Layer(path, rgba) => {
|
||||
if let Some(tp) = ts_path(path) {
|
||||
let mut paint = tiny_skia::Paint::default();
|
||||
paint.set_color_rgba8(rgba[0], rgba[1], rgba[2], rgba[3]);
|
||||
paint.anti_alias = true;
|
||||
pix.fill_path(&tp, &paint, FillRule::Winding, ts_transform(gxf), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(f) = over_fills {
|
||||
f(pix);
|
||||
@@ -687,6 +757,23 @@ fn draw_children_clipped(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, dept
|
||||
backdrop::replace(pix, (lx, ly), &layer, mask.as_ref().map(|m| (m, (0, 0))));
|
||||
}
|
||||
|
||||
/// Colour ink of a text run, in the order the run paints it. Strokes follow the outlines only: a
|
||||
/// bitmap-strike glyph has no outline to stroke.
|
||||
enum Ink {
|
||||
/// An embedded bitmap and the transform from its pixels into layout coordinates.
|
||||
Bitmap(Arc<figcraft_text::ColorBitmap>, Affine),
|
||||
/// One COLR layer: its outline in layout coordinates and the colour to fill it with.
|
||||
Layer(BezPath, [u8; 4]),
|
||||
}
|
||||
|
||||
/// Draw a colour bitmap (premultiplied RGBA) under a transform, sampled smoothly when the
|
||||
/// transform scales it.
|
||||
fn blit(pix: &mut Pixmap, bitmap: &figcraft_text::ColorBitmap, transform: Transform) {
|
||||
let Some(image) = tiny_skia::PixmapRef::from_bytes(&bitmap.rgba, bitmap.width, bitmap.height) else { return };
|
||||
let paint = PixmapPaint { quality: tiny_skia::FilterQuality::Bilinear, ..Default::default() };
|
||||
pix.draw_pixmap(0, 0, image, &paint, transform, None);
|
||||
}
|
||||
|
||||
fn is_axis_aligned(xf: Affine) -> bool {
|
||||
let c = xf.as_coeffs();
|
||||
c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && fract_ok(c[4]) && fract_ok(c[5])
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Decoded images within a memory budget (#203): an image too large is refused from its header,
|
||||
//! before its pixels are decoded, and the decoded-image caches hold a bounded number of bytes
|
||||
//! however many images, filters and colour profiles a document uses.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_color::ColorProfile;
|
||||
use tiny_skia::Pixmap;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(w, h, image::Rgba([200, 40, 40, 255]));
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// A PNG whose header claims `w` × `h` but which holds no pixel data at all.
|
||||
fn png_header_only(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut ihdr = Vec::new();
|
||||
ihdr.extend_from_slice(&w.to_be_bytes());
|
||||
ihdr.extend_from_slice(&h.to_be_bytes());
|
||||
ihdr.extend_from_slice(&[8, 6, 0, 0, 0]);
|
||||
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
out.extend_from_slice(&13u32.to_be_bytes());
|
||||
let chunk: Vec<u8> = b"IHDR".iter().copied().chain(ihdr).collect();
|
||||
out.extend_from_slice(&chunk);
|
||||
out.extend_from_slice(&crc32(&chunk).to_be_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn crc32(data: &[u8]) -> u32 {
|
||||
let mut crc = !0u32;
|
||||
for b in data {
|
||||
crc ^= u32::from(*b);
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 == 1 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
|
||||
}
|
||||
}
|
||||
!crc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_within_the_limits_decodes() {
|
||||
let (p, _) = images::decode_within(&png(30, 20), ColorProfile::Srgb, 30, 30 * 20 * 4).unwrap();
|
||||
assert_eq!((p.width(), p.height()), (30, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_wider_or_taller_than_the_limit_is_refused() {
|
||||
assert!(images::decode_within(&png(30, 20), ColorProfile::Srgb, 29, u64::MAX).is_none());
|
||||
assert!(images::decode_within(&png(20, 30), ColorProfile::Srgb, 29, u64::MAX).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_whose_pixels_pass_the_byte_budget_is_refused() {
|
||||
assert!(images::decode_within(&png(30, 20), ColorProfile::Srgb, 1000, 30 * 20 * 4 - 1).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_image_is_refused_from_its_header_alone() {
|
||||
// 16 000 × 16 000 is within the side limit; its pixels (1 GB) aren't within a 1 MB budget, and
|
||||
// the file has none to decode: only the header is read.
|
||||
assert!(images::decode_within(&png_header_only(16_000, 16_000), ColorProfile::Srgb, 16_384, 1 << 20).is_none());
|
||||
// Past the side limit, the shipped decode refuses it the same way.
|
||||
assert!(images::decode(&png_header_only(20_000, 10)).is_none());
|
||||
}
|
||||
|
||||
fn key(name: &str) -> images::Key {
|
||||
(name.to_string(), [0; 7], ColorProfile::Srgb)
|
||||
}
|
||||
|
||||
fn pixmap(side: u32) -> Arc<Pixmap> {
|
||||
Arc::new(Pixmap::new(side, side).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_image_cache_keeps_within_its_byte_budget() {
|
||||
// 10 × 10 pixmaps are 400 bytes; 1000 bytes hold two.
|
||||
let mut c = images::PixmapCache::default();
|
||||
for name in ["a", "b", "c", "d", "e"] {
|
||||
c.insert(key(name), pixmap(10), 1000);
|
||||
assert!(c.bytes() <= 1000, "{} bytes held", c.bytes());
|
||||
assert!(c.get(&key(name)).is_some(), "{name} was just added");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_image_cache_drops_the_least_recently_used_image_only() {
|
||||
// 1000 bytes hold two 400-byte pixmaps: the third drops the one used longest ago.
|
||||
let mut c = images::PixmapCache::default();
|
||||
c.insert(key("a"), pixmap(10), 1000);
|
||||
c.insert(key("b"), pixmap(10), 1000);
|
||||
assert!(c.get(&key("a")).is_some());
|
||||
c.insert(key("c"), pixmap(10), 1000);
|
||||
assert!(c.get(&key("a")).is_some(), "used since b: kept");
|
||||
assert!(c.get(&key("b")).is_none(), "used longest ago: dropped");
|
||||
assert!(c.get(&key("c")).is_some());
|
||||
assert_eq!(c.bytes(), 800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_larger_than_the_budget_is_held_alone() {
|
||||
let mut c = images::PixmapCache::default();
|
||||
c.insert(key("small"), pixmap(10), 1000);
|
||||
c.insert(key("big"), pixmap(20), 1000);
|
||||
assert!(c.get(&key("big")).is_some());
|
||||
assert!(c.get(&key("small")).is_none());
|
||||
assert_eq!(c.bytes(), 20 * 20 * 4);
|
||||
// The next image takes its place.
|
||||
c.insert(key("next"), pixmap(10), 1000);
|
||||
assert_eq!(c.bytes(), 400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_and_profile_variants_count_against_one_budget() {
|
||||
let mut c = images::PixmapCache::default();
|
||||
for (i, profile) in [ColorProfile::Srgb, ColorProfile::DisplayP3].into_iter().enumerate() {
|
||||
for f in 0..4 {
|
||||
c.insert(("img".to_string(), [f, 0, 0, 0, 0, 0, i as i32], profile), pixmap(10), 1000);
|
||||
assert!(c.bytes() <= 1000, "{} bytes held", c.bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
-22
@@ -13,11 +13,69 @@ use figcraft_color::{ColorProfile, ImageFilters, PaintKind};
|
||||
use figcraft_doc::{Document, ImageData, NodeId};
|
||||
use tiny_skia::Pixmap;
|
||||
|
||||
type Key = (String, [i32; 7], ColorProfile);
|
||||
pub(crate) type Key = (String, [i32; 7], ColorProfile);
|
||||
|
||||
fn cache() -> &'static Mutex<HashMap<Key, Arc<Pixmap>>> {
|
||||
static C: OnceLock<Mutex<HashMap<Key, Arc<Pixmap>>>> = OnceLock::new();
|
||||
C.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
/// The largest image side decoded.
|
||||
const MAX_SIDE: u32 = 16_384;
|
||||
/// The most bytes one image's decoding may allocate: the image crate's own default limit (a
|
||||
/// larger image is refused from its header).
|
||||
const MAX_DECODED_BYTES: u64 = 512 << 20;
|
||||
/// The most bytes of pixels each decoded-image cache holds (one image larger than this is held
|
||||
/// alone).
|
||||
const MAX_CACHED_BYTES: u64 = 512 << 20;
|
||||
/// The most entries each decoded-image cache holds.
|
||||
const MAX_CACHED_IMAGES: usize = 256;
|
||||
|
||||
/// Decoded pixmaps by image, filters and profile, holding at most a byte budget of pixels: adding
|
||||
/// one past the budget (or past [`MAX_CACHED_IMAGES`]) drops the least recently used first, so
|
||||
/// the images on screen stay while they fit.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct PixmapCache {
|
||||
/// Each pixmap and when it was last used.
|
||||
map: HashMap<Key, (Arc<Pixmap>, u64)>,
|
||||
bytes: u64,
|
||||
tick: u64,
|
||||
}
|
||||
|
||||
impl PixmapCache {
|
||||
pub(crate) fn get(&mut self, key: &Key) -> Option<Arc<Pixmap>> {
|
||||
self.tick += 1;
|
||||
let tick = self.tick;
|
||||
self.map.get_mut(key).map(|(p, used)| {
|
||||
*used = tick;
|
||||
p.clone()
|
||||
})
|
||||
}
|
||||
|
||||
/// Add `p` under `key`, keeping the cache within `max_bytes` (or holding `p` alone).
|
||||
pub(crate) fn insert(&mut self, key: Key, p: Arc<Pixmap>, max_bytes: u64) {
|
||||
let size = p.data().len() as u64;
|
||||
self.remove(&key);
|
||||
while self.bytes.saturating_add(size) > max_bytes || self.map.len() >= MAX_CACHED_IMAGES {
|
||||
let Some(oldest) = self.map.iter().min_by_key(|(_, (_, used))| *used).map(|(k, _)| k.clone()) else { break };
|
||||
self.remove(&oldest);
|
||||
}
|
||||
self.tick += 1;
|
||||
self.map.insert(key, (p, self.tick));
|
||||
self.bytes = self.bytes.saturating_add(size);
|
||||
}
|
||||
|
||||
fn remove(&mut self, key: &Key) {
|
||||
if let Some((p, _)) = self.map.remove(key) {
|
||||
self.bytes = self.bytes.saturating_sub(p.data().len() as u64);
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes of pixels held.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn bytes(&self) -> u64 {
|
||||
self.bytes
|
||||
}
|
||||
}
|
||||
|
||||
fn cache() -> &'static Mutex<PixmapCache> {
|
||||
static C: OnceLock<Mutex<PixmapCache>> = OnceLock::new();
|
||||
C.get_or_init(|| Mutex::new(PixmapCache::default()))
|
||||
}
|
||||
|
||||
fn fkey(f: &ImageFilters) -> [i32; 7] {
|
||||
@@ -34,7 +92,23 @@ pub fn decode(bytes: &[u8]) -> Option<Pixmap> {
|
||||
/// image's own profile into `target` (`false`), or as sRGB when it has none (`true`: the sRGB →
|
||||
/// `target` conversion is still to do).
|
||||
fn decode_in(bytes: &[u8], target: ColorProfile) -> Option<(Pixmap, bool)> {
|
||||
let mut dec = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?;
|
||||
decode_within(bytes, target, MAX_SIDE, MAX_DECODED_BYTES)
|
||||
}
|
||||
|
||||
/// [`decode_in`] for images at most `max_side` wide and tall whose decoded pixels take at most
|
||||
/// `max_bytes`; larger ones are refused from their header, before any pixels are decoded.
|
||||
pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, max_bytes: u64) -> Option<(Pixmap, bool)> {
|
||||
use image::ImageDecoder;
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(max_side);
|
||||
limits.max_image_height = Some(max_side);
|
||||
limits.max_alloc = Some(max_bytes);
|
||||
reader.limits(limits);
|
||||
let mut dec = reader.into_decoder().ok()?;
|
||||
if dec.total_bytes() > max_bytes {
|
||||
return None;
|
||||
}
|
||||
let source = crate::image_profile::Source::read(bytes, &mut dec);
|
||||
let img = image::DynamicImage::from_decoder(dec).ok()?;
|
||||
match source.convert(&img, target) {
|
||||
@@ -66,7 +140,7 @@ pub fn profile_name(hash: &str, data: Option<&ImageData>) -> Option<String> {
|
||||
/// Straight RGBA → a premultiplied pixmap (max 16k per side).
|
||||
fn premultiplied(rgba: &image::RgbaImage) -> Option<Pixmap> {
|
||||
let (w, h) = rgba.dimensions();
|
||||
if w == 0 || h == 0 || w > 16_384 || h > 16_384 {
|
||||
if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE {
|
||||
return None;
|
||||
}
|
||||
let mut p = Pixmap::new(w, h)?;
|
||||
@@ -87,17 +161,13 @@ fn premultiplied(rgba: &image::RgbaImage) -> Option<Pixmap> {
|
||||
pub fn get(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile) -> Option<Arc<Pixmap>> {
|
||||
let key = (hash.to_string(), fkey(filters), profile);
|
||||
if let Some(p) = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
|
||||
return Some(p.clone());
|
||||
return Some(p);
|
||||
}
|
||||
let (mut p, srgb) = decode_in(&data?.bytes, profile)?;
|
||||
// Images with their own profile are in `profile` already (sRGB → sRGB is the identity).
|
||||
finish(&mut p, filters, if srgb { profile } else { ColorProfile::Srgb });
|
||||
let p = Arc::new(p);
|
||||
let mut c = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.len() > 256 {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(key, p.clone());
|
||||
cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, p.clone(), MAX_CACHED_BYTES);
|
||||
Some(p)
|
||||
}
|
||||
|
||||
@@ -166,7 +236,7 @@ pub fn decode_frames(bytes: &[u8]) -> Option<Vec<(Pixmap, f64)>> {
|
||||
let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).ok()?;
|
||||
let (w, h) = dec.dimensions();
|
||||
let frame_bytes = u64::from(w) * u64::from(h) * 4;
|
||||
if frame_bytes == 0 || w > 16_384 || h > 16_384 {
|
||||
if frame_bytes == 0 || w > MAX_SIDE || h > MAX_SIDE {
|
||||
return None;
|
||||
}
|
||||
let most = (MAX_ANIMATION_BYTES / frame_bytes).min(MAX_FRAMES as u64) as usize;
|
||||
@@ -304,22 +374,18 @@ pub fn pad(pm: &Pixmap) -> Option<Pixmap> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn padded_cache() -> &'static Mutex<HashMap<Key, Arc<Pixmap>>> {
|
||||
static C: OnceLock<Mutex<HashMap<Key, Arc<Pixmap>>>> = OnceLock::new();
|
||||
C.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
fn padded_cache() -> &'static Mutex<PixmapCache> {
|
||||
static C: OnceLock<Mutex<PixmapCache>> = OnceLock::new();
|
||||
C.get_or_init(|| Mutex::new(PixmapCache::default()))
|
||||
}
|
||||
|
||||
/// `pm` with a transparent 1-pixel border (cached).
|
||||
pub fn padded(hash: &str, filters: &ImageFilters, profile: ColorProfile, pm: &Pixmap) -> Option<Arc<Pixmap>> {
|
||||
let key = (hash.to_string(), fkey(filters), profile);
|
||||
if let Some(p) = padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
|
||||
return Some(p.clone());
|
||||
return Some(p);
|
||||
}
|
||||
let out = Arc::new(pad(pm)?);
|
||||
let mut c = padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.len() > 256 {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(key, out.clone());
|
||||
padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, out.clone(), MAX_CACHED_BYTES);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
@@ -425,6 +425,8 @@ pub fn png_insert_chunk(png: Vec<u8>, kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod backdrop_shadow_tests;
|
||||
#[cfg(test)]
|
||||
mod backdrop_tests;
|
||||
#[cfg(test)]
|
||||
@@ -442,6 +444,8 @@ mod grain_tests;
|
||||
#[cfg(test)]
|
||||
mod group_effects_tests;
|
||||
#[cfg(test)]
|
||||
mod image_budget_tests;
|
||||
#[cfg(test)]
|
||||
mod image_profile_tests;
|
||||
#[cfg(test)]
|
||||
mod lod_tests;
|
||||
|
||||
@@ -217,6 +217,37 @@ fn text_renders_something() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The machine's colour emoji face, if it has one (this test skips without: no font ships here).
|
||||
fn emoji_face() -> Option<(String, String)> {
|
||||
figcraft_text::scan_system_fonts();
|
||||
let mut db = figcraft_text::db().write().unwrap();
|
||||
db.emoji_fallbacks().into_iter().find(|f| f.has_color()).map(|f| (f.family.clone(), f.style.clone()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_emoji_draw_their_pixels() {
|
||||
let Some((family, style)) = emoji_face() else { return };
|
||||
let mut d = figcraft_doc::Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let mut td = TextData::new("\u{1F600}");
|
||||
(td.style.font_family, td.style.font_style) = (family.clone(), style.clone());
|
||||
td.style.font_size = 48.0;
|
||||
d.create(p, NodeKind::Text(td), "T", 0.0, 0.0, 200.0, 64.0).unwrap();
|
||||
let pix = render_view(&d, p, &view(100, 80), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap();
|
||||
// The emoji is the only thing in this document that paints saturated colour.
|
||||
let coloured = pix
|
||||
.data()
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
let (r, g, b) = (i16::from(c[0]), i16::from(c[1]), i16::from(c[2]));
|
||||
c[3] == 255 && (r.max(g).max(b) - r.min(g).min(b)) > 60
|
||||
})
|
||||
.count();
|
||||
assert!(coloured > 200, "{family} drew {coloured} coloured pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_png_roundtrip() {
|
||||
let (d, _, r) = doc_with_rect(Color::from_hex("00FF00").unwrap());
|
||||
|
||||
@@ -13,6 +13,8 @@ figcraft-geom = { workspace = true }
|
||||
figcraft-doc = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
harfrust = { workspace = true }
|
||||
# Colour emoji: sbix/CBDT strikes are PNG images the text layer decodes for the renderer.
|
||||
png = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Colour glyphs: embedded bitmap strikes (sbix, CBDT/EBDT) and COLR v0 layers.
|
||||
//!
|
||||
//! Colour emoji fonts don't draw like other glyphs: the picture itself lives in a bitmap strike
|
||||
//! (sbix, CBDT/EBDT) or in a stack of coloured layers, not in the glyph's outline — in Apple Color
|
||||
//! Emoji most emoji are `sbix` bitmaps while the flags are drawn from outlines, and Noto Color
|
||||
//! Emoji has no outlines at all. This module gives the renderer either a
|
||||
//! premultiplied RGBA raster at the size it asked for, or the layer glyphs and the palette colours
|
||||
//! to fill with its own outlines. Anything else (COLR v1's paint graphs, alpha-only strikes) is
|
||||
//! left to the outline pipeline, which draws whatever outlines the face has, as before.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::PoisonError;
|
||||
|
||||
use figcraft_geom::Affine;
|
||||
use skrifa::bitmap::{BitmapData, BitmapGlyph, BitmapStrikes, Origin};
|
||||
use skrifa::color::ColorPalettes;
|
||||
use skrifa::instance::Size;
|
||||
use skrifa::raw::TableProvider;
|
||||
use skrifa::{FontRef, GlyphId};
|
||||
|
||||
use crate::fontdb::{Coords, Face};
|
||||
|
||||
/// Colour rasters kept per face before the cache starts over, in bytes: they are pixels, and a
|
||||
/// canvas zoomed far in makes each one large.
|
||||
const MAX_RASTER_BYTES: usize = 32 * 1024 * 1024;
|
||||
/// Rasters kept per face (misses count too: a face whose strikes cover a few glyphs builds one
|
||||
/// per glyph and size, and the zoom changes the size).
|
||||
const MAX_RASTER_ENTRIES: usize = 8192;
|
||||
/// COLR layer lists kept per face (24 bytes a layer, so a count is enough).
|
||||
const MAX_LAYERS: usize = 4096;
|
||||
/// Widest and tallest PNG strike decoded (strikes are at most a few hundred pixels).
|
||||
const MAX_PNG_SIDE: u32 = 4096;
|
||||
/// Largest colour raster per em (device pixels). Past this the renderer scales one up instead.
|
||||
pub const MAX_PX_PER_EM: f64 = 512.0;
|
||||
|
||||
/// A colour bitmap glyph: premultiplied RGBA at `size` px per em, and the offset of its top-left
|
||||
/// corner from the glyph's pen, in px at `size` (y down).
|
||||
#[derive(Debug)]
|
||||
pub struct ColorBitmap {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub left: f64,
|
||||
pub top: f64,
|
||||
pub rgba: Arc<[u8]>,
|
||||
}
|
||||
|
||||
/// One layer of a COLR v0 glyph: the glyph that draws the layer and the palette colour it fills
|
||||
/// with (straight RGBA: the renderer paints it).
|
||||
#[derive(Debug)]
|
||||
pub struct ColorLayer {
|
||||
pub gid: u32,
|
||||
pub rgba: [u8; 4],
|
||||
}
|
||||
|
||||
/// A glyph's colour ink: what draws instead of its outline, placed in coordinates the caller
|
||||
/// supplies (a bitmap pixel maps through `place`; COLR layers are drawn like the glyph's outline).
|
||||
pub enum ColorInk {
|
||||
Bitmap(Arc<ColorBitmap>, Affine),
|
||||
Layers(Arc<Vec<ColorLayer>>),
|
||||
}
|
||||
|
||||
/// Colour rasters cached per face, keyed by glyph, variations and size.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Rasters {
|
||||
entries: HashMap<(u32, Coords, u64), Option<Arc<ColorBitmap>>>,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl Rasters {
|
||||
fn get(&self, key: &(u32, Coords, u64)) -> Option<Arc<ColorBitmap>> {
|
||||
self.entries.get(key).and_then(Option::clone)
|
||||
}
|
||||
|
||||
fn known(&self, key: &(u32, Coords, u64)) -> bool {
|
||||
self.entries.contains_key(key)
|
||||
}
|
||||
|
||||
fn insert(&mut self, key: (u32, Coords, u64), bitmap: Option<Arc<ColorBitmap>>) {
|
||||
if let Some(b) = &bitmap {
|
||||
self.bytes += b.rgba.len();
|
||||
}
|
||||
if self.bytes > MAX_RASTER_BYTES || self.entries.len() > MAX_RASTER_ENTRIES {
|
||||
self.entries.clear();
|
||||
self.bytes = bitmap.as_ref().map(|b| b.rgba.len()).unwrap_or(0);
|
||||
}
|
||||
self.entries.insert(key, bitmap);
|
||||
}
|
||||
}
|
||||
|
||||
impl Face {
|
||||
/// Whether the face has colour glyphs at all: bitmaps (sbix, CBDT/EBDT) or COLR layers.
|
||||
pub fn has_color(&self) -> bool {
|
||||
self.has_bitmaps() || self.has_colr()
|
||||
}
|
||||
|
||||
/// The face has bitmap strikes (`sbix`/`CBDT`/`EBDT`)
|
||||
pub fn has_bitmaps(&self) -> bool {
|
||||
self.bitmap_strikes
|
||||
}
|
||||
|
||||
/// The face has a `COLR` table.
|
||||
pub fn has_colr(&self) -> bool {
|
||||
self.colr
|
||||
}
|
||||
|
||||
/// The glyph's embedded bitmap, scaled to `size` px per em (premultiplied RGBA), with the offset
|
||||
/// of its top-left corner from the pen in px at `size` (y down). `None` when the face has no
|
||||
/// bitmap for the glyph.
|
||||
pub fn color_bitmap(&self, gid: u32, coords: &Coords, size: f64) -> Option<Arc<ColorBitmap>> {
|
||||
if !self.bitmap_strikes || !size.is_finite() || size <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let size = size.clamp(1.0, MAX_PX_PER_EM);
|
||||
let key = (gid, coords.clone(), size.to_bits());
|
||||
let mut cache = self.rasters.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if cache.known(&key) {
|
||||
return cache.get(&key);
|
||||
}
|
||||
let made = self.read_bitmap(gid, size).map(Arc::new);
|
||||
cache.insert(key, made.clone());
|
||||
made
|
||||
}
|
||||
|
||||
/// The glyph's COLR v0 layers (empty when the glyph has none: COLR v1 glyphs, or no COLR data).
|
||||
pub fn color_layers(&self, gid: u32) -> Option<Arc<Vec<ColorLayer>>> {
|
||||
if !self.colr {
|
||||
return None;
|
||||
}
|
||||
let mut cache = self.layers.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(hit) = cache.get(&gid) {
|
||||
return hit.clone();
|
||||
}
|
||||
let made = self.read_layers(gid).map(Arc::new);
|
||||
if cache.len() >= MAX_LAYERS {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(gid, made.clone());
|
||||
made
|
||||
}
|
||||
|
||||
/// Read a strike bitmap and place it the way Skia's fontations port does
|
||||
/// (`src/ports/fontations/src/bitmap.rs`): the outer bearings are in font units and scale with
|
||||
/// the em, the inner ones are pixels at the strike's ppem, and an sbix image is measured from
|
||||
/// its lower-left corner (the spec places the graphic's origin at the glyph box's xMin, yMin).
|
||||
fn read_bitmap(&self, gid: u32, size: f64) -> Option<ColorBitmap> {
|
||||
let gid = GlyphId::new(gid);
|
||||
let font = FontRef::from_index(self.bytes.data(), self.index).ok()?;
|
||||
let strike = BitmapStrikes::new(&font).glyph_for_size(Size::new(size as f32), gid)?;
|
||||
let (rgba, width, height) = decode(&strike)?;
|
||||
if width == 0 || height == 0 || rgba.len() != width as usize * height as usize * 4 {
|
||||
return None;
|
||||
}
|
||||
let (left, top) = placement(&strike, size, self.upem);
|
||||
// Scale the strike's own pixels to the requested size (the caller blits them 1:1).
|
||||
let scale = size / f64::from(strike.ppem_y.max(1.0));
|
||||
let w = ((f64::from(width) * scale).round() as i64).clamp(1, MAX_PX_PER_EM as i64) as u32;
|
||||
let h = ((f64::from(height) * scale).round() as i64).clamp(1, MAX_PX_PER_EM as i64) as u32;
|
||||
let rgba = if (w, h) == (width, height) { rgba } else { scaled(&rgba, width, height, w, h)? };
|
||||
Some(ColorBitmap { width: w, height: h, left, top, rgba: rgba.into() })
|
||||
}
|
||||
|
||||
/// Read the COLR v0 layers of a glyph (`None` for a glyph the table doesn't cover, which is
|
||||
/// every glyph of a COLR v1-only font).
|
||||
fn read_layers(&self, gid: u32) -> Option<Vec<ColorLayer>> {
|
||||
let font = FontRef::from_index(self.bytes.data(), self.index).ok()?;
|
||||
let colr = font.colr().ok()?;
|
||||
let range = colr.v0_base_glyph(GlyphId::new(gid))?;
|
||||
let palettes = ColorPalettes::new(&font);
|
||||
let palette = palettes.get(0);
|
||||
let colors = palette.as_ref().map(|p| p.colors()).unwrap_or(&[]);
|
||||
let mut out = Vec::new();
|
||||
for index in range {
|
||||
let (layer, color_index) = colr.v0_layer(index)?;
|
||||
let rgba = colors.get(usize::from(color_index)).map(|c| [c.red(), c.green(), c.blue(), c.alpha()]).unwrap_or([0, 0, 0, 255]);
|
||||
out.push(ColorLayer { gid: layer.to_u32(), rgba });
|
||||
}
|
||||
(!out.is_empty()).then_some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a strike's image sits from the pen: the image's top-left corner in px at `size` (y down).
|
||||
fn placement(glyph: &BitmapGlyph, size: f64, upem: f64) -> (f64, f64) {
|
||||
let units = size / upem.max(1.0);
|
||||
let pixels = size / f64::from(glyph.ppem_y.max(1.0));
|
||||
let left = f64::from(glyph.inner_bearing_x) * pixels + f64::from(glyph.bearing_x) * units;
|
||||
let above = match glyph.placement_origin {
|
||||
// sbix: the origin is the image's lower-left corner, so the whole image hangs above it.
|
||||
Origin::BottomLeft => f64::from(glyph.inner_bearing_y) + f64::from(glyph.height),
|
||||
Origin::TopLeft => f64::from(glyph.inner_bearing_y),
|
||||
};
|
||||
(left, -above * pixels - f64::from(glyph.bearing_y) * units)
|
||||
}
|
||||
|
||||
/// A strike's pixels as premultiplied RGBA (what the renderer's pixmaps hold), with their size.
|
||||
fn decode(glyph: &BitmapGlyph<'_>) -> Option<(Vec<u8>, u32, u32)> {
|
||||
match &glyph.data {
|
||||
// The OpenType spec's PNG strikes are plain PNGs (straight alpha), so they are
|
||||
// premultiplied here for the renderer.
|
||||
BitmapData::Png(png) => {
|
||||
let (rgba, width, height) = decode_png(png)?;
|
||||
Some((premultiplied(rgba), width, height))
|
||||
}
|
||||
// Bit depth 32 without PNG is BGRA with premultiplied channels (the spec says so).
|
||||
BitmapData::Bgra(bgra) => {
|
||||
let (width, height) = (glyph.width, glyph.height);
|
||||
let mut rgba = bgra.get(..width as usize * height as usize * 4)?.to_vec();
|
||||
for px in rgba.chunks_exact_mut(4) {
|
||||
px.swap(0, 2);
|
||||
}
|
||||
Some((rgba, width, height))
|
||||
}
|
||||
// Alpha-only strikes would be masks, not colour: the outline pipeline draws those glyphs.
|
||||
BitmapData::Mask(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "color_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// A PNG strike as straight 8-bit RGBA.
|
||||
fn decode_png(png: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(png));
|
||||
// Expand palettes, `tRNS` and sub-byte samples to 8-bit RGBA.
|
||||
decoder.set_transformations(png::Transformations::normalize_to_color8() | png::Transformations::ALPHA);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
// The header's size is checked before anything is allocated for it: a font can claim any size.
|
||||
let (w, h) = reader.info().size();
|
||||
if w == 0 || h == 0 || w > MAX_PNG_SIDE || h > MAX_PNG_SIDE {
|
||||
return None;
|
||||
}
|
||||
let mut buf = vec![0u8; reader.output_buffer_size()?];
|
||||
let info = reader.next_frame(&mut buf).ok()?;
|
||||
let (width, height) = (info.width, info.height);
|
||||
let data = buf.get(..info.buffer_size())?;
|
||||
let rgba = match info.color_type {
|
||||
png::ColorType::Rgba => data.to_vec(),
|
||||
png::ColorType::Rgb => data.as_chunks::<3>().0.iter().flat_map(|p| [p[0], p[1], p[2], 255]).collect(),
|
||||
png::ColorType::Grayscale => data.iter().flat_map(|g| [*g, *g, *g, 255]).collect(),
|
||||
png::ColorType::GrayscaleAlpha => data.as_chunks::<2>().0.iter().flat_map(|p| [p[0], p[0], p[0], p[1]]).collect(),
|
||||
png::ColorType::Indexed => return None,
|
||||
};
|
||||
Some((rgba, width, height))
|
||||
}
|
||||
|
||||
/// Straight RGBA → premultiplied RGBA (in place).
|
||||
fn premultiplied(mut rgba: Vec<u8>) -> Vec<u8> {
|
||||
for px in rgba.chunks_exact_mut(4) {
|
||||
let a = u32::from(px[3]);
|
||||
for c in &mut px[..3] {
|
||||
*c = ((u32::from(*c) * a + 127) / 255) as u8;
|
||||
}
|
||||
}
|
||||
rgba
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA scaled to `dw`×`dh`: a box average when shrinking, bilinear when growing.
|
||||
fn scaled(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Option<Vec<u8>> {
|
||||
if sw == 0 || sh == 0 || dw == 0 || dh == 0 {
|
||||
return None;
|
||||
}
|
||||
let sample = |x: u32, y: u32| -> [u8; 4] {
|
||||
let i = ((y.min(sh - 1) * sw + x.min(sw - 1)) * 4) as usize;
|
||||
match src.get(i..i + 4) {
|
||||
Some(px) => [px[0], px[1], px[2], px[3]],
|
||||
None => [0, 0, 0, 0],
|
||||
}
|
||||
};
|
||||
let mut out = vec![0u8; dw as usize * dh as usize * 4];
|
||||
if dw >= sw && dh >= sh {
|
||||
let at = |v: f64, n: u32| ((v + 0.5) * f64::from(n) - 0.5).clamp(0.0, f64::from(n - 1));
|
||||
for y in 0..dh {
|
||||
let sy = at(f64::from(y) / f64::from(dh), sh);
|
||||
let (y0, fy) = (sy.floor() as u32, sy.fract());
|
||||
for x in 0..dw {
|
||||
let sx = at(f64::from(x) / f64::from(dw), sw);
|
||||
let (x0, fx) = (sx.floor() as u32, sx.fract());
|
||||
let (p00, p10, p01, p11) = (sample(x0, y0), sample(x0 + 1, y0), sample(x0, y0 + 1), sample(x0 + 1, y0 + 1));
|
||||
let o = ((y * dw + x) * 4) as usize;
|
||||
for c in 0..4 {
|
||||
let top = f64::from(p00[c]) * (1.0 - fx) + f64::from(p10[c]) * fx;
|
||||
let bottom = f64::from(p01[c]) * (1.0 - fx) + f64::from(p11[c]) * fx;
|
||||
out[o + c] = (top * (1.0 - fy) + bottom * fy).round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Some(out);
|
||||
}
|
||||
let (fx, fy) = (f64::from(sw) / f64::from(dw), f64::from(sh) / f64::from(dh));
|
||||
for y in 0..dh {
|
||||
let (y0, y1) = ((f64::from(y) * fy).floor() as u32, ((f64::from(y) + 1.0) * fy).ceil() as u32);
|
||||
for x in 0..dw {
|
||||
let (x0, x1) = ((f64::from(x) * fx).floor() as u32, ((f64::from(x) + 1.0) * fx).ceil() as u32);
|
||||
let mut sum = [0.0f64; 4];
|
||||
let mut n = 0.0f64;
|
||||
for sy in y0..y1.clamp(y0 + 1, sh) {
|
||||
for sx in x0..x1.clamp(x0 + 1, sw) {
|
||||
let px = sample(sx, sy);
|
||||
for c in 0..4 {
|
||||
sum[c] += f64::from(px[c]);
|
||||
}
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
let o = ((y * dw + x) * 4) as usize;
|
||||
for c in 0..4 {
|
||||
out[o + c] = (sum[c] / n.max(1.0)).round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Colour glyph behaviour (colour emoji): bitmap strikes, COLR layers, and where they sit.
|
||||
//!
|
||||
//! The placement cases are pure numbers, so they run everywhere. The rest reads the machine's
|
||||
//! colour emoji face and skips (like the other suites) on a machine without one; which format it
|
||||
//! uses depends on the platform (Apple Color Emoji is sbix, Noto Color Emoji is CBDT, Segoe UI
|
||||
//! Emoji is COLR), so a machine exercises the paths its own font has.
|
||||
|
||||
use super::*;
|
||||
use crate::fontdb::{db, no_coords, scan_system_fonts};
|
||||
use crate::layout::layout;
|
||||
use figcraft_doc::TextData;
|
||||
use skrifa::bitmap::{BitmapData, BitmapGlyph, MaskData, Origin};
|
||||
|
||||
/// A strike glyph described by hand, for the placement cases.
|
||||
fn glyph(width: u32, height: u32, origin: Origin, bearing: (f32, f32), inner: (f32, f32), ppem: f32) -> BitmapGlyph<'static> {
|
||||
BitmapGlyph {
|
||||
data: BitmapData::Png(&[]),
|
||||
bearing_x: bearing.0,
|
||||
bearing_y: bearing.1,
|
||||
inner_bearing_x: inner.0,
|
||||
inner_bearing_y: inner.1,
|
||||
ppem_x: ppem,
|
||||
ppem_y: ppem,
|
||||
advance: None,
|
||||
width,
|
||||
height,
|
||||
placement_origin: origin,
|
||||
}
|
||||
}
|
||||
|
||||
/// A strike glyph with data, for the decoding cases (the fields they don't read stay neutral).
|
||||
fn strike(data: BitmapData<'_>, width: u32, height: u32) -> BitmapGlyph<'_> {
|
||||
BitmapGlyph {
|
||||
data,
|
||||
bearing_x: 0.0,
|
||||
bearing_y: 0.0,
|
||||
inner_bearing_x: 0.0,
|
||||
inner_bearing_y: 0.0,
|
||||
ppem_x: 10.0,
|
||||
ppem_y: 10.0,
|
||||
advance: None,
|
||||
width,
|
||||
height,
|
||||
placement_origin: Origin::TopLeft,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_placement_follows_the_strike() {
|
||||
// CBDT: bearings are pixels from the origin to the bitmap's left and top edges, read at the
|
||||
// strike's ppem and scaled to the size asked for: 2 px at ppem 10 in a 20 px run → 4 px.
|
||||
let cbdt = glyph(100, 50, Origin::TopLeft, (0.0, 0.0), (2.0, 8.0), 10.0);
|
||||
let (left, top) = placement(&cbdt, 20.0, 1000.0);
|
||||
assert_eq!((left, top), (4.0, -16.0), "the left edge sits right of the pen, the top above it");
|
||||
|
||||
// sbix: bearings and origin offsets are font units, and the image is placed by its lower-left
|
||||
// corner, so its top is the whole image above the origin.
|
||||
let sbix = glyph(10, 10, Origin::BottomLeft, (-1.0, -3.0), (1.0, 2.0), 10.0);
|
||||
let (left, top) = placement(&sbix, 20.0, 1000.0);
|
||||
assert!((left - (2.0 - 0.02)).abs() < 1e-9, "{left}");
|
||||
assert!((top - (-24.0 + 0.06)).abs() < 1e-9, "{top}");
|
||||
|
||||
// Doubling the size doubles the offset with it.
|
||||
let scaled = glyph(10, 10, Origin::TopLeft, (0.0, 0.0), (1.0, 1.0), 10.0);
|
||||
assert_eq!(placement(&scaled, 40.0, 1000.0).1, -4.0);
|
||||
assert_eq!(placement(&scaled, 20.0, 1000.0).1, -2.0);
|
||||
}
|
||||
|
||||
/// A 2×2 PNG with straight alpha, the format strikes carry: row 0 is (255,0,0,128) then
|
||||
/// (0,255,0,255), row 1 is (0,0,255,0) then (10,20,30,51).
|
||||
const PNG_STRAIGHT: &[u8] = &[
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x72, 0xb6, 0x0d, 0x24, 0x00, 0x00, 0x00, 0x17, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xf8, 0xcf, 0xc0, 0xd0,
|
||||
0xc0, 0xf0, 0x1f, 0x08, 0x19, 0x80, 0x98, 0x4b, 0x44, 0xce, 0x18, 0x00, 0x34, 0xb8, 0x04, 0xec, 0xa7, 0x3b, 0x38, 0x04, 0x00, 0x00, 0x00, 0x00,
|
||||
0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn png_strikes_decode_to_premultiplied_rgba() {
|
||||
let (rgba, width, height) = decode(&strike(BitmapData::Png(PNG_STRAIGHT), 2, 2)).unwrap();
|
||||
assert_eq!((width, height), (2, 2));
|
||||
assert_eq!(
|
||||
rgba,
|
||||
vec![128, 0, 0, 128, 0, 255, 0, 255, 0, 0, 0, 0, 2, 4, 6, 51],
|
||||
"straight alpha times its own alpha, rounded: the renderer's surfaces are premultiplied"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_png_strike_claiming_a_huge_size_is_refused_before_anything_is_allocated() {
|
||||
// PNG_STRAIGHT with its header saying 1 048 576 × 1 048 576 pixels (4 TiB as RGBA).
|
||||
let mut png = PNG_STRAIGHT.to_vec();
|
||||
assert_eq!(crc32(&png[12..29]).to_be_bytes(), png[29..33], "the patched header passes its checksum");
|
||||
png[16..24].copy_from_slice(&[0, 0x10, 0, 0, 0, 0x10, 0, 0]);
|
||||
let crc = crc32(&png[12..29]);
|
||||
png[29..33].copy_from_slice(&crc.to_be_bytes());
|
||||
assert!(decode_png(&png).is_none());
|
||||
}
|
||||
|
||||
/// The CRC-32 a PNG chunk ends with.
|
||||
fn crc32(bytes: &[u8]) -> u32 {
|
||||
!bytes.iter().fold(!0u32, |c, b| (0..8).fold(c ^ u32::from(*b), |c, _| if c & 1 == 1 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 }))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_bgra_strikes_are_already_premultiplied() {
|
||||
// BitmapData::Bgra is BGRA with premultiplied channels (the spec says so), so decoding only
|
||||
// moves the red and blue channels.
|
||||
let (rgba, width, height) = decode(&strike(BitmapData::Bgra(&[17, 34, 51, 255, 4, 8, 12, 16]), 2, 1)).unwrap();
|
||||
assert_eq!((width, height), (2, 1));
|
||||
assert_eq!(rgba, vec![51, 34, 17, 255, 12, 8, 4, 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strikes_that_are_not_colour_decode_to_nothing() {
|
||||
let mask = MaskData { bpp: 8, is_packed: false, data: &[0; 16] };
|
||||
assert!(decode(&strike(BitmapData::Mask(mask), 2, 2)).is_none(), "alpha-only strikes are masks, not colour");
|
||||
assert!(decode(&strike(BitmapData::Png(b"not a png"), 2, 2)).is_none(), "a broken PNG is skipped, not drawn");
|
||||
assert!(decode(&strike(BitmapData::Bgra(&[1, 2, 3]), 2, 2)).is_none(), "a buffer shorter than the glyph is skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaling_keeps_the_pixels_premultiplied() {
|
||||
// A 4×4 face: one straight colour over a falling alpha, premultiplied by hand.
|
||||
let straight = [220u32, 180, 60];
|
||||
let ramp = [255u32, 190, 128, 64, 0];
|
||||
let mut src = Vec::new();
|
||||
for y in 0..4 {
|
||||
for x in 0..4 {
|
||||
let a = ramp[(x + y) % ramp.len()];
|
||||
src.extend(straight.iter().map(|c| ((c * a + 127) / 255) as u8));
|
||||
src.push(a as u8);
|
||||
}
|
||||
}
|
||||
for (dw, dh) in [(2, 2), (3, 4), (9, 7), (16, 16)] {
|
||||
let out = scaled(&src, 4, 4, dw, dh).unwrap();
|
||||
assert_eq!(out.len(), (dw * dh * 4) as usize);
|
||||
for px in out.chunks_exact(4) {
|
||||
assert!(px[0] <= px[3] && px[1] <= px[3] && px[2] <= px[3], "{dw}×{dh}: {px:?}");
|
||||
}
|
||||
}
|
||||
assert!(scaled(&src, 4, 4, 0, 4).is_none() && scaled(&src, 0, 0, 4, 4).is_none(), "an empty side scales to nothing");
|
||||
}
|
||||
|
||||
/// The machine's colour emoji face, if it has one (tests skip without: no font ships in the repo).
|
||||
fn emoji_face() -> Option<std::sync::Arc<Face>> {
|
||||
scan_system_fonts();
|
||||
let mut db = db().write().unwrap();
|
||||
db.emoji_fallbacks().into_iter().find(|f| f.has_color())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_emoji_carry_their_own_pixels() {
|
||||
let Some(face) = emoji_face() else { return };
|
||||
// A grinning face: a bitmap strike (Apple, Noto) or COLR layers (Segoe) must cover it.
|
||||
let gid = face.glyph_for('\u{1F600}');
|
||||
assert_ne!(gid, 0, "U+1F600 is not in {}", face.family);
|
||||
if let Some(layers) = face.color_layers(gid) {
|
||||
assert!(!layers.is_empty());
|
||||
assert!(layers.len() < 64, "{} layers", layers.len());
|
||||
assert!(layers.iter().all(|l| l.gid != 0), "every layer names a glyph: {layers:?}");
|
||||
return;
|
||||
}
|
||||
let Some(bitmap) = face.color_bitmap(gid, &no_coords(), 32.0) else {
|
||||
panic!("{} has colour tables but draws nothing for U+1F600", face.family);
|
||||
};
|
||||
// The image is a sane size for 32 px of text and its pixels are premultiplied (what the
|
||||
// renderer's surfaces hold), so no channel is brighter than its alpha.
|
||||
assert!(bitmap.width >= 8 && bitmap.width <= 128, "{} px wide", bitmap.width);
|
||||
assert!(bitmap.height >= 8 && bitmap.height <= 128, "{} px tall", bitmap.height);
|
||||
assert_eq!(bitmap.rgba.len(), bitmap.width as usize * bitmap.height as usize * 4);
|
||||
let mut opaque = 0;
|
||||
for px in bitmap.rgba.chunks_exact(4) {
|
||||
assert!(px[0] <= px[3] && px[1] <= px[3] && px[2] <= px[3], "premultiplied: {px:?}");
|
||||
opaque += usize::from(px[3] == 255);
|
||||
}
|
||||
assert!(opaque > 20, "the glyph draws something: {opaque} opaque pixels");
|
||||
// A bigger strike is scaled to a bigger image, and a smaller one to a smaller image.
|
||||
let small = face.color_bitmap(gid, &no_coords(), 16.0);
|
||||
let large = face.color_bitmap(gid, &no_coords(), 64.0);
|
||||
if let (Some(small), Some(large)) = (small, large) {
|
||||
assert!(small.width < bitmap.width && bitmap.width < large.width, "{} {} {}", small.width, bitmap.width, large.width);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emoji_in_plain_text_draw_from_a_colour_face() {
|
||||
// The style's own face (DejaVu Sans on Linux) or a symbol font among the fallbacks (Windows has
|
||||
// one) draws U+1F600 in one colour: the emoji face comes first for it.
|
||||
if emoji_face().is_none() {
|
||||
return;
|
||||
}
|
||||
let l = layout(&TextData::new("Hi \u{1F600}"), None, None);
|
||||
assert!(l.glyphs.iter().any(|g| g.color(1.0).is_some()), "the emoji has colour ink");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyphs_place_their_ink_at_a_scale() {
|
||||
let Some(face) = emoji_face() else { return };
|
||||
let mut t = TextData::new("\u{1F600}");
|
||||
(t.style.font_family, t.style.font_style) = (face.family.clone(), face.style.clone());
|
||||
t.style.font_size = 32.0;
|
||||
let l = layout(&t, None, None);
|
||||
let Some(g) = l.glyphs.first() else { return };
|
||||
// Zoomed 2:1 the glyph asks for a 64 px raster and places it in layout coordinates.
|
||||
let Some(ColorInk::Bitmap(bitmap, place)) = g.color(2.0) else {
|
||||
assert!(g.color(2.0).is_some(), "the emoji glyph has ink");
|
||||
return;
|
||||
};
|
||||
// Some emoji faces draw wider than the em (Noto Color Emoji's strikes are about 1.25 em).
|
||||
assert!(bitmap.width >= 32 && bitmap.width <= 96, "{} px at 2:1", bitmap.width);
|
||||
let corner = place * figcraft_geom::Point::new(0.0, 0.0);
|
||||
assert!(corner.x >= g.x - g.size && corner.x <= g.x + g.size, "x {} vs pen {}", corner.x, g.x);
|
||||
assert!(corner.y >= g.y - g.size && corner.y <= g.y + g.size, "y {} vs baseline {}", corner.y, g.y);
|
||||
// Past the raster cap the image stops growing (a canvas zoomed far in).
|
||||
let Some(ColorInk::Bitmap(huge, _)) = g.color(1000.0) else { return };
|
||||
assert!(f64::from(huge.width) <= MAX_PX_PER_EM + 1.0, "{} px", huge.width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_without_colour_ink_keeps_its_outlines() {
|
||||
// Faces without colour tables answer "no ink" without touching the font again, so plain text
|
||||
// (the whole canvas, most of the time) pays one flag per glyph.
|
||||
let Some(face) = crate::fontdb::find_face("Inter", "Regular") else { return };
|
||||
if face.has_color() {
|
||||
return;
|
||||
}
|
||||
let gid = face.glyph_for('A');
|
||||
assert!(gid != 0, "Inter draws A");
|
||||
assert!(face.color_layers(gid).is_none() && face.color_bitmap(gid, &no_coords(), 32.0).is_none());
|
||||
}
|
||||
@@ -11,6 +11,8 @@ use skrifa::raw::{FileRef, TableProvider};
|
||||
use skrifa::string::StringId;
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
|
||||
use crate::color::{ColorLayer, Rasters};
|
||||
|
||||
/// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`).
|
||||
pub struct CraftFont {
|
||||
pub family: &'static str,
|
||||
@@ -107,6 +109,11 @@ pub struct Face {
|
||||
charmap: HashMap<u32, u32>,
|
||||
outlines: Mutex<HashMap<(u32, Coords), Arc<BezPath>>>,
|
||||
advances: Mutex<HashMap<(u32, Coords), f64>>,
|
||||
/// Colour glyph tables (see [`crate::color`]): bitmap strikes and/or COLR layers.
|
||||
pub(crate) bitmap_strikes: bool,
|
||||
pub(crate) colr: bool,
|
||||
pub(crate) rasters: Mutex<Rasters>,
|
||||
pub(crate) layers: Mutex<HashMap<u32, Option<Arc<Vec<ColorLayer>>>>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Face {
|
||||
@@ -139,6 +146,9 @@ impl Face {
|
||||
gsub_features.dedup();
|
||||
let charmap: HashMap<u32, u32> = f.charmap().mappings().map(|(c, g)| (c, g.to_u32())).collect();
|
||||
let (axes, instances) = variation_info(&f);
|
||||
let has_table = |tag: &[u8; 4]| f.data_for_tag(skrifa::Tag::new(tag)).is_some();
|
||||
let bitmap_strikes = has_table(b"sbix") || has_table(b"CBDT") || has_table(b"EBDT");
|
||||
let colr = has_table(b"COLR");
|
||||
let under = m.underline.map(|d| (f64::from(d.offset), f64::from(d.thickness))).unwrap_or((-upem * 0.1, upem * 0.06));
|
||||
let strike = m.strikeout.map(|d| f64::from(d.offset)).unwrap_or(ascent * 0.3);
|
||||
let weight = {
|
||||
@@ -169,6 +179,10 @@ impl Face {
|
||||
bytes,
|
||||
outlines: Mutex::new(HashMap::new()),
|
||||
advances: Mutex::new(HashMap::new()),
|
||||
bitmap_strikes,
|
||||
colr,
|
||||
rasters: Mutex::new(Rasters::default()),
|
||||
layers: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -613,7 +627,6 @@ impl FontDb {
|
||||
"PingFang SC",
|
||||
"Noto Sans CJK SC",
|
||||
"Microsoft YaHei",
|
||||
"Apple Color Emoji",
|
||||
"Noto Sans Arabic",
|
||||
"Apple Symbols",
|
||||
"Segoe UI Symbol",
|
||||
@@ -625,6 +638,19 @@ impl FontDb {
|
||||
out
|
||||
}
|
||||
|
||||
/// Colour emoji faces, for characters no other fallback covers. Kept out of
|
||||
/// [`fallbacks`](Self::fallbacks) because the files are large (Apple Color Emoji is ~180 MB)
|
||||
/// and only texts that actually show emoji need them; each family reads one face.
|
||||
pub fn emoji_fallbacks(&mut self) -> Vec<Arc<Face>> {
|
||||
let mut out = Vec::new();
|
||||
for fam in ["Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"] {
|
||||
if let Some(f) = self.find_one(fam, "Regular") {
|
||||
out.push(f);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn face_count(&self) -> usize {
|
||||
self.faces.len()
|
||||
}
|
||||
@@ -670,14 +696,27 @@ fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
||||
{
|
||||
v.push("/usr/share/fonts".into());
|
||||
v.push("/usr/local/share/fonts".into());
|
||||
if let Some(h) = std::env::var_os("HOME") {
|
||||
v.push(std::path::PathBuf::from(&h).join(".fonts"));
|
||||
v.push(std::path::PathBuf::from(h).join(".local/share/fonts"));
|
||||
}
|
||||
v.extend(unix_user_font_dirs(std::env::var_os("HOME"), std::env::var_os("XDG_DATA_HOME")));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
// Fontconfig's default configuration uses $XDG_DATA_HOME/fonts for user fonts.
|
||||
// Ignore relative XDG paths, as required by the XDG Base Directory Specification.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn unix_user_font_dirs(home: Option<std::ffi::OsString>, data_home: Option<std::ffi::OsString>) -> Vec<std::path::PathBuf> {
|
||||
let home = home.map(std::path::PathBuf::from);
|
||||
let data = data_home.map(std::path::PathBuf::from).filter(|p| p.is_absolute()).or_else(|| home.as_ref().map(|h| h.join(".local/share")));
|
||||
let mut dirs = Vec::new();
|
||||
if let Some(home) = home {
|
||||
dirs.push(home.join(".fonts"));
|
||||
}
|
||||
if let Some(data) = data {
|
||||
dirs.push(data.join("fonts"));
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap<String, (String, Vec<Pending>)>) {
|
||||
if depth > 6 {
|
||||
return;
|
||||
@@ -699,8 +738,10 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap<String, (Str
|
||||
if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") {
|
||||
continue;
|
||||
}
|
||||
// Skip very large files (colour emoji); they are not outline fonts we can draw anyway.
|
||||
let Some(bytes) = usize::try_from(meta.len()).ok().filter(|l| *l <= 64 * 1024 * 1024).and_then(|l| index_bytes(&path, l)) else { continue };
|
||||
// Very large files are indexed all the same (Apple Color Emoji is ~180 MB and is where the
|
||||
// system's colour emoji live): `index_bytes` reads headers and name tables out of a zeroed
|
||||
// buffer, so only those parts are ever touched.
|
||||
let Some(bytes) = usize::try_from(meta.len()).ok().filter(|l| *l <= 256 * 1024 * 1024).and_then(|l| index_bytes(&path, l)) else { continue };
|
||||
for (index, family, style) in faces_in(&bytes) {
|
||||
out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { path: path.clone(), index, style });
|
||||
}
|
||||
@@ -743,7 +784,8 @@ fn index_bytes(path: &std::path::Path, len: usize) -> Option<Vec<u8>> {
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
/// (index, family, style) of each outline face (one we can draw) in a font file.
|
||||
/// (index, family, style) of each face in a font file: outline fonts and colour fonts (Apple Color
|
||||
/// Emoji draws most emoji from `sbix`, Noto Color Emoji has no outlines at all).
|
||||
fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> {
|
||||
let count = match FileRef::new(bytes) {
|
||||
Ok(FileRef::Collection(c)) => c.len(),
|
||||
@@ -753,7 +795,10 @@ fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> {
|
||||
(0..count.min(64))
|
||||
.filter_map(|i| {
|
||||
let f = skrifa::FontRef::from_index(bytes, i).ok()?;
|
||||
[b"glyf", b"CFF ", b"CFF2"].iter().any(|t| f.data_for_tag(skrifa::Tag::new(t)).is_some()).then_some(())?;
|
||||
[b"glyf", b"CFF ", b"CFF2", b"sbix", b"CBDT", b"EBDT", b"COLR"]
|
||||
.iter()
|
||||
.any(|t| f.data_for_tag(skrifa::Tag::new(t)).is_some())
|
||||
.then_some(())?;
|
||||
let (family, style) = names(&f);
|
||||
let family = family.filter(|f| !f.starts_with('.'))?;
|
||||
Some((i, family, style.unwrap_or_else(|| "Regular".into())))
|
||||
|
||||
@@ -67,3 +67,29 @@ fn the_scan_reads_names_like_a_whole_file_read() {
|
||||
assert_eq!(faces_in(&index_bytes(&p, whole.len()).unwrap()), faces_in(&whole), "{}", p.display());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn user_font_dirs_follow_xdg_data_home() {
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
let dirs = |home: Option<&str>, data: Option<&str>| unix_user_font_dirs(home.map(OsString::from), data.map(OsString::from));
|
||||
let paths = |items: &[&str]| items.iter().map(PathBuf::from).collect::<Vec<_>>();
|
||||
assert_eq!(dirs(Some("/home/user"), Some("/custom user data")), paths(&["/home/user/.fonts", "/custom user data/fonts"]));
|
||||
for data in [None, Some(""), Some("relative/data")] {
|
||||
assert_eq!(dirs(Some("/home/user"), data), paths(&["/home/user/.fonts", "/home/user/.local/share/fonts"]));
|
||||
}
|
||||
assert_eq!(dirs(None, Some("/custom-data")), paths(&["/custom-data/fonts"]));
|
||||
assert_eq!(dirs(None, None), paths(&[]));
|
||||
assert_eq!(dirs(None, Some("relative")), paths(&[]));
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn user_font_dirs_preserve_non_utf8_paths() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||
let dirs = unix_user_font_dirs(Some(OsString::from_vec(b"/home/\xff".to_vec())), Some(OsString::from_vec(b"/data/\xfe".to_vec())));
|
||||
assert_eq!(dirs[0].as_os_str().as_bytes(), b"/home/\xff/.fonts");
|
||||
assert_eq!(dirs[1].as_os_str().as_bytes(), b"/data/\xfe/fonts");
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use figcraft_doc::{
|
||||
use figcraft_geom::{Affine, BezPath, Rect};
|
||||
use harfrust::{Direction, Feature, ShapeOptions, ShaperInstance, Tag, UnicodeBuffer, Variation};
|
||||
|
||||
use crate::color::{ColorInk, MAX_PX_PER_EM};
|
||||
use crate::fontdb::{Coords, Face, db, no_coords};
|
||||
|
||||
/// A positioned glyph (pen position on the baseline, layout coordinates, y down).
|
||||
@@ -37,11 +38,34 @@ pub struct Glyph {
|
||||
impl Glyph {
|
||||
/// The glyph outline placed in layout coordinates.
|
||||
pub fn path(&self) -> BezPath {
|
||||
self.layer_path(self.gid)
|
||||
}
|
||||
|
||||
/// The outline of one of the face's glyphs (a COLR layer), placed like this glyph.
|
||||
pub fn layer_path(&self, gid: u32) -> BezPath {
|
||||
let s = self.size / self.face.upem;
|
||||
let mut p = (*self.face.outline_at(self.gid, &self.coords)).clone();
|
||||
let mut p = (*self.face.outline_at(gid, &self.coords)).clone();
|
||||
p.apply_affine(self.warp * Affine::new([s, 0.0, 0.0, s, self.x, self.y]));
|
||||
p
|
||||
}
|
||||
|
||||
/// The glyph's colour ink at `scale` device pixels per layout unit, placed in layout
|
||||
/// coordinates (a bitmap's pixels map through the affine; COLR layers are drawn like the
|
||||
/// outline). `None` — the usual case — means the outline is what draws.
|
||||
pub fn color(&self, scale: f64) -> Option<ColorInk> {
|
||||
if !scale.is_finite() || scale <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
if let Some(layers) = self.face.color_layers(self.gid) {
|
||||
return Some(ColorInk::Layers(layers));
|
||||
}
|
||||
// Whole device pixels per em: rasters stay on the pixel grid and the cache stays small.
|
||||
let px = (self.size * scale).round().clamp(1.0, MAX_PX_PER_EM);
|
||||
let bitmap = self.face.color_bitmap(self.gid, &self.coords, px)?;
|
||||
let s = px / self.size;
|
||||
let place = self.warp * Affine::translate((self.x + bitmap.left / s, self.y + bitmap.top / s)) * Affine::scale(1.0 / s);
|
||||
Some(ColorInk::Bitmap(bitmap, place))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -299,11 +323,16 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
span_style.push(idx);
|
||||
}
|
||||
let mut dbw = db().write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let fallbacks = dbw.fallbacks();
|
||||
let mut fallbacks = dbw.fallbacks();
|
||||
let mut faces: Vec<Option<Arc<Face>>> = Vec::with_capacity(styles.len());
|
||||
for s in &styles {
|
||||
faces.push(dbw.find(&s.font_family, &s.font_style).or_else(|| dbw.default_face()));
|
||||
}
|
||||
// Colour emoji are their own, large faces (Apple Color Emoji is ~180 MB): they are read only
|
||||
// for a text with emoji, or with characters nothing above covers.
|
||||
let covered = |c: char| c == '\n' || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c));
|
||||
let emoji = if chars.iter().any(|c| is_emoji(*c) || !covered(*c)) { dbw.emoji_fallbacks() } else { Vec::new() };
|
||||
fallbacks.extend(emoji.iter().cloned());
|
||||
drop(dbw);
|
||||
|
||||
// OpenType features the user toggled (global for the node).
|
||||
@@ -331,6 +360,14 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
let mut seg_start = *start;
|
||||
while seg_start < *end {
|
||||
let face_for = |c: char| -> Arc<Face> {
|
||||
// Emoji take a colour face before a face without colour glyphs (the style's own or a
|
||||
// symbol font), which would draw them in one colour.
|
||||
if is_emoji(c)
|
||||
&& !primary.has_color()
|
||||
&& let Some(f) = emoji.iter().find(|f| f.has_char(c))
|
||||
{
|
||||
return f.clone();
|
||||
}
|
||||
if c == '\n' || primary.has_char(c) {
|
||||
return primary.clone();
|
||||
}
|
||||
@@ -958,6 +995,13 @@ fn sanitize_size(s: f64) -> f64 {
|
||||
if s.is_finite() { s.clamp(0.5, 5000.0) } else { 12.0 }
|
||||
}
|
||||
|
||||
/// A character drawn as a colour emoji by default: flags and the pictograph blocks.
|
||||
/// shortcut: the older symbols (U+2600–U+27BF) keep their text look even before U+FE0F; read the
|
||||
/// emoji presentation data if they should turn colour too.
|
||||
fn is_emoji(c: char) -> bool {
|
||||
matches!(c, '\u{1F1E6}'..='\u{1F1FF}' | '\u{1F300}'..='\u{1FAFF}')
|
||||
}
|
||||
|
||||
/// One run of chars shaped with a single face and style.
|
||||
struct Segment<'a> {
|
||||
start: usize,
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod breaks;
|
||||
pub mod color;
|
||||
pub mod display;
|
||||
pub mod fontdb;
|
||||
mod layout;
|
||||
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,
|
||||
|
||||
@@ -25,6 +25,7 @@ log = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
image = { workspace = true }
|
||||
egui_extras = { workspace = true }
|
||||
web-time = "1.1.0"
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
+11
-3
@@ -71,7 +71,7 @@ fn locales() -> String {
|
||||
o
|
||||
}
|
||||
|
||||
/// The rows of one catalog table: `"key" = "text"` or `"key" = { one = "…", other = "…" }`.
|
||||
/// The rows of one catalog table: strings or plural forms (`other` required).
|
||||
fn table(o: &mut String, path: &std::path::Path, t: Option<&toml::Value>) {
|
||||
let Some(t) = t.and_then(toml::Value::as_table) else { return };
|
||||
// Sorted by key (byte order), as the lookup's binary search needs.
|
||||
@@ -81,8 +81,16 @@ fn table(o: &mut String, path: &std::path::Path, t: Option<&toml::Value>) {
|
||||
let s = |k: &str| v.get(k).and_then(toml::Value::as_str);
|
||||
match (v.as_str(), s("one"), s("other")) {
|
||||
(Some(text), ..) => _ = writeln!(o, " ({k:?}, Text::Plain({text:?})),"),
|
||||
(None, one, Some(other)) => _ = writeln!(o, " ({k:?}, Text::Plural({}, {other:?})),", name(one)),
|
||||
_ => println!("cargo::warning={}: `{k}` is neither a string nor {{ one, other }}; left out", path.display()),
|
||||
(None, one, Some(other)) => {
|
||||
_ = writeln!(
|
||||
o,
|
||||
" ({k:?}, Text::Plural {{ one: {}, few: {}, many: {}, other: {other:?} }}),",
|
||||
name(one),
|
||||
name(s("few")),
|
||||
name(s("many")),
|
||||
);
|
||||
}
|
||||
_ => println!("cargo::warning={}: `{k}` is neither a string nor plural forms with `other`; left out", path.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -302,7 +302,9 @@ status = "draft — needs native review"
|
||||
"Copy {value}" = "{value} kopieren"
|
||||
"Corner radius" = "Eckenradius"
|
||||
"Corner smoothing" = "Eckenglättung"
|
||||
"Couldn't download the variables" = "Die Variablen konnten nicht heruntergeladen werden"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat."
|
||||
"Couldn't save automatically: {error}" = "Automatisches Speichern fehlgeschlagen: {error}"
|
||||
"Count" = "Anzahl"
|
||||
"Create" = "Erstellen"
|
||||
"Create a variable collection first" = "Erstellen Sie zuerst eine Variablensammlung"
|
||||
@@ -459,6 +461,7 @@ status = "draft — needs native review"
|
||||
"Fade" = "Verblassen"
|
||||
"False" = "Falsch"
|
||||
"Fetching from the server…" = "Wird vom Server abgerufen…"
|
||||
"FigCraft couldn't open its window: {error}" = "FigCraft konnte sein Fenster nicht öffnen: {error}"
|
||||
"File" = "Datei"
|
||||
"File name" = "Dateiname"
|
||||
"File published" = "Datei veröffentlicht"
|
||||
@@ -570,6 +573,7 @@ status = "draft — needs native review"
|
||||
"Hue" = "Farbton"
|
||||
"Hug" = "Umschließen"
|
||||
"Hug contents" = "Inhalt umschließen"
|
||||
"If it keeps happening, please report it at {link} with this message." = "Wenn es weiterhin passiert, melden Sie es bitte mit dieser Meldung unter {link}."
|
||||
"If" = "Wenn"
|
||||
"If {condition}" = "Wenn {condition}"
|
||||
"Ignore auto layout (absolute position)" = "Auto-Layout ignorieren (absolute Position)"
|
||||
@@ -1001,6 +1005,7 @@ status = "draft — needs native review"
|
||||
"Remove the else and its actions" = "„Sonst“ und seine Aktionen entfernen"
|
||||
"Remove this condition and its actions" = "Diese Bedingung und ihre Aktionen entfernen"
|
||||
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Diese Datei vom Server entfernen? Sie kann von dort nicht mehr geteilt oder geöffnet werden. Ihre Kopie bleibt hier geöffnet."
|
||||
"Remove {name} from session" = "{name} aus der Sitzung entfernen"
|
||||
"Remove {name} from {server}?" = "{name} von {server} entfernen?"
|
||||
"Removed" = "Entfernt"
|
||||
"Removed from the server" = "Vom Server entfernt"
|
||||
@@ -1075,6 +1080,7 @@ status = "draft — needs native review"
|
||||
"Save" = "Speichern"
|
||||
"Save as" = "Speichern unter"
|
||||
"Save as preset…" = "Als Vorgabe speichern…"
|
||||
"Save files automatically" = "Dateien automatisch speichern"
|
||||
"Save version {key}" = "Version speichern {key}"
|
||||
"Saved" = "Gespeichert"
|
||||
"Saved {name}" = "{name} gespeichert"
|
||||
@@ -1329,6 +1335,8 @@ status = "draft — needs native review"
|
||||
"Update plugin" = "Plugin aktualisieren"
|
||||
"Updated {components} and {styles}" = "{components} und {styles} aktualisiert"
|
||||
"Updated {name}" = "{name} aktualisiert"
|
||||
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Oft hilft es, den Grafiktreiber zu aktualisieren. Um nur OpenGL zu verwenden, starten Sie FigCraft in der Eingabeaufforderung mit:"
|
||||
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Oft hilft es, den Grafiktreiber zu aktualisieren. Mit WGPU_BACKEND (vulkan, gl, metal) lässt sich eine andere Grafik-API ausprobieren."
|
||||
"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Lädt diese Datei auf einen FigCraft-Server hoch und hält diesen Tab mit ihm synchron. Sie erhalten einen Link zum Bearbeiten und einen zum Ansehen."
|
||||
"Uppercase" = "Großbuchstaben"
|
||||
"Use a fixed value" = "Festen Wert verwenden"
|
||||
@@ -1370,6 +1378,7 @@ status = "draft — needs native review"
|
||||
"Visibility" = "Sichtbarkeit"
|
||||
"Visible" = "Sichtbar"
|
||||
"Vivid" = "Lebhaft"
|
||||
"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND ist gesetzt und legt die Grafik-API fest; ohne diese Variable wählt FigCraft selbst."
|
||||
"Warm" = "Warm"
|
||||
"Watch" = "Smartwatch"
|
||||
"Wavy" = "Gewellt"
|
||||
@@ -1577,6 +1586,7 @@ status = "draft — needs native review"
|
||||
"collab.publish" = "Auf Server veröffentlichen"
|
||||
"collab.recoverFile" = "Online-Datei wiederherstellen"
|
||||
"collab.registerLinks" = "Einladungslinks in FigCraft öffnen"
|
||||
"collab.removePerson" = "Aus der Sitzung entfernen"
|
||||
"collab.report" = "Datei dem Serverbetreiber melden"
|
||||
"collab.serverInfo" = "Serverinformationen"
|
||||
"collab.share" = "Mit Beitrittscode teilen"
|
||||
@@ -1793,6 +1803,7 @@ status = "draft — needs native review"
|
||||
"plugin.ui" = "Plugin-Bereich"
|
||||
"plugins.manage" = "Plugins verwalten…"
|
||||
"plugins.runLast" = "Letztes Plugin ausführen"
|
||||
"pref.autoSave" = "Dateien automatisch speichern"
|
||||
"pref.highlightHover" = "Ebenen beim Überfahren hervorheben"
|
||||
"pref.keepTool" = "Werkzeug nach Verwendung ausgewählt lassen"
|
||||
"pref.language" = "Sprache der Benutzeroberfläche"
|
||||
|
||||
@@ -311,7 +311,9 @@ name = "English"
|
||||
"Copy {value}" = "Copy {value}"
|
||||
"Corner radius" = "Corner radius"
|
||||
"Corner smoothing" = "Corner smoothing"
|
||||
"Couldn't download the variables" = "Couldn't download the variables"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers."
|
||||
"Couldn't save automatically: {error}" = "Couldn't save automatically: {error}"
|
||||
"Count" = "Count"
|
||||
"Create" = "Create"
|
||||
"Create a variable collection first" = "Create a variable collection first"
|
||||
@@ -477,6 +479,7 @@ name = "English"
|
||||
"False" = "False"
|
||||
"Feb" = "Feb"
|
||||
"Fetching from the server…" = "Fetching from the server…"
|
||||
"FigCraft couldn't open its window: {error}" = "FigCraft couldn't open its window: {error}"
|
||||
"File" = "File"
|
||||
"File name" = "File name"
|
||||
"File not found" = "File not found"
|
||||
@@ -596,6 +599,7 @@ name = "English"
|
||||
"Hug contents" = "Hug contents"
|
||||
"Icon components on a 24 px grid with keylines and a size preview" = "Icon components on a 24 px grid with keylines and a size preview"
|
||||
"Icon set starter" = "Icon set starter"
|
||||
"If it keeps happening, please report it at {link} with this message." = "If it keeps happening, please report it at {link} with this message."
|
||||
"If" = "If"
|
||||
"If {condition}" = "If {condition}"
|
||||
"Ignore auto layout (absolute position)" = "Ignore auto layout (absolute position)"
|
||||
@@ -1049,6 +1053,7 @@ name = "English"
|
||||
"Remove the else and its actions" = "Remove the else and its actions"
|
||||
"Remove this condition and its actions" = "Remove this condition and its actions"
|
||||
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here."
|
||||
"Remove {name} from session" = "Remove {name} from session"
|
||||
"Remove {name} from {server}?" = "Remove {name} from {server}?"
|
||||
"Removed" = "Removed"
|
||||
"Removed from the server" = "Removed from the server"
|
||||
@@ -1117,6 +1122,7 @@ name = "English"
|
||||
"Rulers" = "Rulers"
|
||||
"Run" = "Run"
|
||||
"Run {label}" = "Run {label}"
|
||||
"Save files automatically" = "Save files automatically"
|
||||
"Search templates" = "Search templates"
|
||||
"See all templates" = "See all templates"
|
||||
"Select layers and they space themselves as their content changes" = "Select layers and they space themselves as their content changes"
|
||||
@@ -1395,6 +1401,8 @@ name = "English"
|
||||
"Update from selection" = "Update from selection"
|
||||
"Updated {components} and {styles}" = "Updated {components} and {styles}"
|
||||
"Updated {name}" = "Updated {name}"
|
||||
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:"
|
||||
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try."
|
||||
"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view."
|
||||
"Uppercase" = "Uppercase"
|
||||
"Use a fixed value" = "Use a fixed value"
|
||||
@@ -1437,6 +1445,7 @@ name = "English"
|
||||
"Visibility" = "Visibility"
|
||||
"Visible" = "Visible"
|
||||
"Vivid" = "Vivid"
|
||||
"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose."
|
||||
"Warm" = "Warm"
|
||||
"Watch" = "Watch"
|
||||
"Wavy" = "Wavy"
|
||||
|
||||
@@ -8,6 +8,7 @@ name = "Español"
|
||||
status = "draft — needs native review"
|
||||
|
||||
[ui]
|
||||
"Couldn't download the variables" = "No se pudieron descargar las variables"
|
||||
|
||||
"% of all commits" = "% del total de commits"
|
||||
"(no longer published)" = "(ya no publicado)"
|
||||
@@ -313,6 +314,7 @@ status = "draft — needs native review"
|
||||
"Corner radius" = "Radio de esquina"
|
||||
"Corner smoothing" = "Suavizado de esquinas"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "No se pudo contactar con {server}: se muestra lo que este ordenador recuerda."
|
||||
"Couldn't save automatically: {error}" = "No se pudo guardar automáticamente: {error}"
|
||||
"Count" = "Número"
|
||||
"Create" = "Crear"
|
||||
"Create a variable collection first" = "Crea primero una colección de variables"
|
||||
@@ -478,6 +480,7 @@ status = "draft — needs native review"
|
||||
"False" = "Falso"
|
||||
"Feb" = "feb"
|
||||
"Fetching from the server…" = "Obteniendo del servidor…"
|
||||
"FigCraft couldn't open its window: {error}" = "FigCraft no pudo abrir su ventana: {error}"
|
||||
"File" = "Archivo"
|
||||
"File name" = "Nombre del archivo"
|
||||
"File not found" = "No se encuentra el archivo"
|
||||
@@ -597,6 +600,7 @@ status = "draft — needs native review"
|
||||
"Hug contents" = "Ajustar al contenido"
|
||||
"Icon components on a 24 px grid with keylines and a size preview" = "Componentes de icono en una cuadrícula de 24 px con líneas guía y una vista de tamaños"
|
||||
"Icon set starter" = "Base de conjunto de iconos"
|
||||
"If it keeps happening, please report it at {link} with this message." = "Si sigue pasando, infórmalo en {link} con este mensaje."
|
||||
"If" = "Si"
|
||||
"If {condition}" = "Si {condition}"
|
||||
"Ignore auto layout (absolute position)" = "Ignorar diseño automático (posición absoluta)"
|
||||
@@ -1050,6 +1054,7 @@ status = "draft — needs native review"
|
||||
"Remove the else and its actions" = "Quitar el «si no» y sus acciones"
|
||||
"Remove this condition and its actions" = "Quitar esta condición y sus acciones"
|
||||
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "¿Quitar este archivo del servidor? No se podrá volver a compartir ni abrir desde allí. Tu copia sigue abierta aquí."
|
||||
"Remove {name} from session" = "Quitar a {name} de la sesión"
|
||||
"Remove {name} from {server}?" = "¿Quitar {name} de {server}?"
|
||||
"Removed" = "Eliminado"
|
||||
"Removed from the server" = "Quitado del servidor"
|
||||
@@ -1118,6 +1123,7 @@ status = "draft — needs native review"
|
||||
"Rulers" = "Reglas"
|
||||
"Run" = "Ejecutar"
|
||||
"Run {label}" = "Ejecutar {label}"
|
||||
"Save files automatically" = "Guardar archivos automáticamente"
|
||||
"Search templates" = "Buscar plantillas"
|
||||
"See all templates" = "Ver todas las plantillas"
|
||||
"Select layers and they space themselves as their content changes" = "Selecciona capas y se espaciarán solas cuando cambie su contenido"
|
||||
@@ -1396,6 +1402,8 @@ status = "draft — needs native review"
|
||||
"Update from selection" = "Actualizar desde la selección"
|
||||
"Updated {components} and {styles}" = "Actualizados {components} y {styles}"
|
||||
"Updated {name}" = "{name} actualizado"
|
||||
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Actualizar el controlador gráfico suele ayudar. Para usar solo OpenGL, inicia FigCraft desde el Símbolo del sistema con:"
|
||||
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Actualizar el controlador gráfico suele ayudar. WGPU_BACKEND (vulkan, gl, metal) indica otra API gráfica que probar."
|
||||
"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Sube este archivo a un servidor FigCraft y mantiene esta pestaña sincronizada con él. Obtienes un enlace para editar y otro para ver."
|
||||
"Uppercase" = "Mayúsculas"
|
||||
"Use a fixed value" = "Usar un valor fijo"
|
||||
@@ -1438,6 +1446,7 @@ status = "draft — needs native review"
|
||||
"Visibility" = "Visibilidad"
|
||||
"Visible" = "Visible"
|
||||
"Vivid" = "Vívido"
|
||||
"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND está definida e indica la API gráfica que se usa; si la quitas, FigCraft elige por sí mismo."
|
||||
"Warm" = "Cálido"
|
||||
"Watch" = "Reloj"
|
||||
"Wavy" = "Ondulado"
|
||||
@@ -1652,6 +1661,7 @@ status = "draft — needs native review"
|
||||
"collab.publish" = "Publicar en el servidor"
|
||||
"collab.recoverFile" = "Recuperar archivo en línea"
|
||||
"collab.registerLinks" = "Abrir enlaces de invitación en FigCraft"
|
||||
"collab.removePerson" = "Quitar de la sesión"
|
||||
"collab.report" = "Denunciar archivo al operador del servidor"
|
||||
"collab.serverInfo" = "Información del servidor"
|
||||
"collab.share" = "Compartir con un código para unirse"
|
||||
@@ -1874,6 +1884,7 @@ status = "draft — needs native review"
|
||||
"plugin.ui" = "Panel del plugin"
|
||||
"plugins.manage" = "Gestionar plugins…"
|
||||
"plugins.runLast" = "Ejecutar el último plugin"
|
||||
"pref.autoSave" = "Guardar archivos automáticamente"
|
||||
"pref.highlightHover" = "Resaltar capas al pasar el cursor"
|
||||
"pref.keepTool" = "Mantener la herramienta tras usarla"
|
||||
"pref.language" = "Idioma de la interfaz"
|
||||
|
||||
@@ -302,7 +302,9 @@ status = "draft — needs native review"
|
||||
"Copy {value}" = "Copier {value}"
|
||||
"Corner radius" = "Rayon d’angle"
|
||||
"Corner smoothing" = "Lissage des angles"
|
||||
"Couldn't download the variables" = "Impossible de télécharger les variables"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient."
|
||||
"Couldn't save automatically: {error}" = "Impossible d’enregistrer automatiquement : {error}"
|
||||
"Count" = "Nombre"
|
||||
"Create" = "Créer"
|
||||
"Create a variable collection first" = "Créez d’abord une collection de variables"
|
||||
@@ -459,6 +461,7 @@ status = "draft — needs native review"
|
||||
"Fade" = "Fondu"
|
||||
"False" = "Faux"
|
||||
"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}"
|
||||
"File" = "Fichier"
|
||||
"File name" = "Nom du fichier"
|
||||
"File published" = "Fichier publié"
|
||||
@@ -570,6 +573,7 @@ status = "draft — needs native review"
|
||||
"Hue" = "Teinte"
|
||||
"Hug" = "Ajuster"
|
||||
"Hug contents" = "Ajuster au contenu"
|
||||
"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}"
|
||||
"Ignore auto layout (absolute position)" = "Ignorer la mise en page auto (position absolue)"
|
||||
@@ -1001,6 +1005,7 @@ status = "draft — needs native review"
|
||||
"Remove the else and its actions" = "Retirer le « sinon » et ses actions"
|
||||
"Remove this condition and its actions" = "Retirer cette condition et ses actions"
|
||||
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Retirer ce fichier du serveur ? Il ne pourra plus être partagé ni ouvert depuis celui-ci. Votre copie reste ouverte ici."
|
||||
"Remove {name} from session" = "Retirer {name} de la session"
|
||||
"Remove {name} from {server}?" = "Retirer {name} de {server} ?"
|
||||
"Removed" = "Supprimé"
|
||||
"Removed from the server" = "Retiré du serveur"
|
||||
@@ -1075,6 +1080,7 @@ status = "draft — needs native review"
|
||||
"Save" = "Enregistrer"
|
||||
"Save as" = "Enregistrer sous"
|
||||
"Save as preset…" = "Enregistrer comme préréglage…"
|
||||
"Save files automatically" = "Enregistrer les fichiers automatiquement"
|
||||
"Save version {key}" = "Enregistrer une version {key}"
|
||||
"Saved" = "Enregistré"
|
||||
"Saved {name}" = "{name} enregistré"
|
||||
@@ -1329,6 +1335,8 @@ status = "draft — needs native review"
|
||||
"Update plugin" = "Mettre à jour le plugin"
|
||||
"Updated {components} and {styles}" = "{components} et {styles} mis à jour"
|
||||
"Updated {name}" = "{name} mis à jour"
|
||||
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Mettre à jour le pilote graphique aide souvent. Pour n’utiliser qu’OpenGL, lancez FigCraft depuis l’invite de commandes avec :"
|
||||
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Mettre à jour le pilote graphique aide souvent. WGPU_BACKEND (vulkan, gl, metal) désigne une autre API graphique à essayer."
|
||||
"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Envoie ce fichier sur un serveur FigCraft et garde cet onglet synchronisé avec lui. Vous obtenez un lien de modification et un lien de consultation."
|
||||
"Uppercase" = "Majuscules"
|
||||
"Use a fixed value" = "Utiliser une valeur fixe"
|
||||
@@ -1370,6 +1378,7 @@ status = "draft — needs native review"
|
||||
"Visibility" = "Visibilité"
|
||||
"Visible" = "Visible"
|
||||
"Vivid" = "Vif"
|
||||
"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND est définie et impose l’API graphique ; sans elle, FigCraft choisit lui-même."
|
||||
"Warm" = "Chaud"
|
||||
"Watch" = "Montre"
|
||||
"Wavy" = "Ondulé"
|
||||
@@ -1577,6 +1586,7 @@ status = "draft — needs native review"
|
||||
"collab.publish" = "Publier sur un serveur"
|
||||
"collab.recoverFile" = "Récupérer un fichier en ligne"
|
||||
"collab.registerLinks" = "Ouvrir les liens d’invitation dans FigCraft"
|
||||
"collab.removePerson" = "Retirer de la session"
|
||||
"collab.report" = "Signaler le fichier à l’opérateur du serveur"
|
||||
"collab.serverInfo" = "Informations sur le serveur"
|
||||
"collab.share" = "Partager avec un code d’accès"
|
||||
@@ -1793,6 +1803,7 @@ status = "draft — needs native review"
|
||||
"plugin.ui" = "Panneau du plugin"
|
||||
"plugins.manage" = "Gérer les plugins…"
|
||||
"plugins.runLast" = "Exécuter le dernier plugin"
|
||||
"pref.autoSave" = "Enregistrer les fichiers automatiquement"
|
||||
"pref.highlightHover" = "Mettre en évidence les calques au survol"
|
||||
"pref.keepTool" = "Garder l’outil sélectionné après usage"
|
||||
"pref.language" = "Langue de l’interface"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -315,6 +315,7 @@ status = "draft — needs native review"
|
||||
"Corner radius" = "角の半径"
|
||||
"Corner smoothing" = "角のスムージング"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "{server} に接続できない:このコンピューターが覚えている内容を表示中。"
|
||||
"Couldn't save automatically: {error}" = "自動保存できませんでした:{error}"
|
||||
"Count" = "数"
|
||||
"Create" = "作成"
|
||||
"Create a variable collection first" = "先に変数コレクションを作成"
|
||||
@@ -480,6 +481,7 @@ status = "draft — needs native review"
|
||||
"False" = "false"
|
||||
"Feb" = "2月"
|
||||
"Fetching from the server…" = "サーバーから取得中…"
|
||||
"FigCraft couldn't open its window: {error}" = "FigCraft のウィンドウを開けなかった:{error}"
|
||||
"File" = "ファイル"
|
||||
"File name" = "ファイル名"
|
||||
"File not found" = "ファイルが見つからない"
|
||||
@@ -599,6 +601,7 @@ status = "draft — needs native review"
|
||||
"Hug contents" = "内容に合わせる"
|
||||
"Icon components on a 24 px grid with keylines and a size preview" = "24 px グリッドのアイコンコンポーネント、キーライン、サイズのプレビュー"
|
||||
"Icon set starter" = "アイコンセットの雛形"
|
||||
"If it keeps happening, please report it at {link} with this message." = "繰り返し起きる場合は、このメッセージを添えて {link} に報告。"
|
||||
"If" = "条件"
|
||||
"If {condition}" = "条件:{condition}"
|
||||
"Ignore auto layout (absolute position)" = "オートレイアウトを無視(絶対位置)"
|
||||
@@ -1052,6 +1055,7 @@ status = "draft — needs native review"
|
||||
"Remove the else and its actions" = "「それ以外」とそのアクションを削除"
|
||||
"Remove this condition and its actions" = "この条件とそのアクションを削除"
|
||||
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "このファイルをサーバーから削除しますか? サーバーからの共有や再オープンはできなくなる。手元のコピーは開いたまま。"
|
||||
"Remove {name} from session" = "{name} をセッションから削除"
|
||||
"Remove {name} from {server}?" = "{name} を {server} から削除しますか?"
|
||||
"Removed" = "削除"
|
||||
"Removed from the server" = "サーバーから削除済み"
|
||||
@@ -1126,6 +1130,7 @@ status = "draft — needs native review"
|
||||
"Save" = "保存"
|
||||
"Save as" = "名前を付けて保存"
|
||||
"Save as preset…" = "プリセットとして保存…"
|
||||
"Save files automatically" = "ファイルを自動保存"
|
||||
"Save version {key}" = "バージョンを保存 {key}"
|
||||
"Saved" = "保存済み"
|
||||
"Saved {name}" = "{name} を保存済み"
|
||||
@@ -1398,6 +1403,8 @@ status = "draft — needs native review"
|
||||
"Update plugin" = "プラグインを更新"
|
||||
"Updated {components} and {styles}" = "{components}と{styles}を更新済み"
|
||||
"Updated {name}" = "{name} を更新済み"
|
||||
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "グラフィックスドライバーの更新で解決することが多い。OpenGL だけを使うには、コマンドプロンプトから次のように FigCraft を起動:"
|
||||
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "グラフィックスドライバーの更新で解決することが多い。WGPU_BACKEND(vulkan、gl、metal)で別のグラフィックス API を試せる。"
|
||||
"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "このファイルを FigCraft サーバーにアップロードし、このタブを同期した状態に保つ。編集用リンクと閲覧用リンクが発行される。"
|
||||
"Uppercase" = "大文字"
|
||||
"Use a fixed value" = "固定値を使用"
|
||||
@@ -1440,6 +1447,7 @@ status = "draft — needs native review"
|
||||
"Visibility" = "表示"
|
||||
"Visible" = "表示"
|
||||
"Vivid" = "ビビッド"
|
||||
"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND が設定されていて、使うグラフィックス API を指定している。解除すると FigCraft が選ぶ。"
|
||||
"Warm" = "ウォーム"
|
||||
"Watch" = "ウォッチ"
|
||||
"Wavy" = "波線"
|
||||
@@ -1654,6 +1662,7 @@ status = "draft — needs native review"
|
||||
"collab.publish" = "サーバーに公開"
|
||||
"collab.recoverFile" = "オンラインファイルを復元"
|
||||
"collab.registerLinks" = "招待リンクを FigCraft で開く"
|
||||
"collab.removePerson" = "セッションから削除"
|
||||
"collab.report" = "ファイルをサーバーの運営者に報告"
|
||||
"collab.serverInfo" = "サーバー情報"
|
||||
"collab.share" = "参加コードで共有"
|
||||
@@ -1876,6 +1885,7 @@ status = "draft — needs native review"
|
||||
"plugin.ui" = "プラグインパネル"
|
||||
"plugins.manage" = "プラグインを管理…"
|
||||
"plugins.runLast" = "最後のプラグインを実行"
|
||||
"pref.autoSave" = "ファイルを自動保存"
|
||||
"pref.highlightHover" = "ホバー時にレイヤーをハイライト"
|
||||
"pref.keepTool" = "使用後もツールを選択したままにする"
|
||||
"pref.language" = "インターフェースの言語"
|
||||
|
||||
@@ -304,6 +304,7 @@ status = "draft — needs native review"
|
||||
"Corner radius" = "모서리 반경"
|
||||
"Corner smoothing" = "모서리 스무딩"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "{server}에 연결할 수 없음: 이 컴퓨터가 기억하는 내용을 표시함."
|
||||
"Couldn't save automatically: {error}" = "자동으로 저장할 수 없음: {error}"
|
||||
"Count" = "개수"
|
||||
"Create" = "만들기"
|
||||
"Create a variable collection first" = "먼저 변수 컬렉션을 만드세요"
|
||||
@@ -460,6 +461,7 @@ status = "draft — needs native review"
|
||||
"Fade" = "페이드"
|
||||
"False" = "거짓"
|
||||
"Fetching from the server…" = "서버에서 가져오는 중…"
|
||||
"FigCraft couldn't open its window: {error}" = "FigCraft 창을 열 수 없음: {error}"
|
||||
"File" = "파일"
|
||||
"File name" = "파일 이름"
|
||||
"File published" = "파일 게시됨"
|
||||
@@ -571,6 +573,7 @@ status = "draft — needs native review"
|
||||
"Hue" = "색조"
|
||||
"Hug" = "맞춤"
|
||||
"Hug contents" = "내용에 맞춤"
|
||||
"If it keeps happening, please report it at {link} with this message." = "계속 발생하면 이 메시지와 함께 {link}에 알려 주세요."
|
||||
"If" = "조건"
|
||||
"If {condition}" = "조건: {condition}"
|
||||
"Ignore auto layout (absolute position)" = "자동 레이아웃 무시(절대 위치)"
|
||||
@@ -1002,6 +1005,7 @@ status = "draft — needs native review"
|
||||
"Remove the else and its actions" = "\"그 외\"와 해당 액션 제거"
|
||||
"Remove this condition and its actions" = "이 조건과 해당 액션 제거"
|
||||
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "이 파일을 서버에서 제거할까요? 서버에서 다시 공유하거나 열 수 없게 됨. 내 사본은 여기 열린 채로 유지됨."
|
||||
"Remove {name} from session" = "{name} 님을 세션에서 제거"
|
||||
"Remove {name} from {server}?" = "{name}을(를) {server}에서 제거할까요?"
|
||||
"Removed" = "제거됨"
|
||||
"Removed from the server" = "서버에서 제거됨"
|
||||
@@ -1076,6 +1080,7 @@ status = "draft — needs native review"
|
||||
"Save" = "저장"
|
||||
"Save as" = "다른 이름으로 저장"
|
||||
"Save as preset…" = "프리셋으로 저장…"
|
||||
"Save files automatically" = "파일 자동 저장"
|
||||
"Save version {key}" = "버전 저장 {key}"
|
||||
"Saved" = "저장됨"
|
||||
"Saved {name}" = "{name} 저장됨"
|
||||
@@ -1330,6 +1335,8 @@ status = "draft — needs native review"
|
||||
"Update plugin" = "플러그인 업데이트"
|
||||
"Updated {components} and {styles}" = "{components} 및 {styles} 업데이트됨"
|
||||
"Updated {name}" = "{name} 업데이트됨"
|
||||
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "그래픽 드라이버를 업데이트하면 해결되는 경우가 많습니다. OpenGL만 사용하려면 명령 프롬프트에서 다음과 같이 FigCraft를 시작하세요:"
|
||||
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "그래픽 드라이버를 업데이트하면 해결되는 경우가 많습니다. WGPU_BACKEND(vulkan, gl, metal)로 다른 그래픽 API를 시도할 수 있습니다."
|
||||
"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "이 파일을 FigCraft 서버에 업로드하고 이 탭을 동기화된 상태로 유지함. 편집 링크와 보기 링크가 생성됨."
|
||||
"Uppercase" = "대문자"
|
||||
"Use a fixed value" = "고정 값 사용"
|
||||
@@ -1371,6 +1378,7 @@ status = "draft — needs native review"
|
||||
"Visibility" = "표시 여부"
|
||||
"Visible" = "표시됨"
|
||||
"Vivid" = "선명하게"
|
||||
"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND가 설정되어 사용할 그래픽 API를 지정하고 있습니다. 설정을 해제하면 FigCraft가 선택합니다."
|
||||
"Warm" = "따뜻하게"
|
||||
"Watch" = "시계"
|
||||
"Wavy" = "물결선"
|
||||
@@ -1578,6 +1586,7 @@ status = "draft — needs native review"
|
||||
"collab.publish" = "서버에 게시"
|
||||
"collab.recoverFile" = "온라인 파일 복구"
|
||||
"collab.registerLinks" = "초대 링크를 FigCraft에서 열기"
|
||||
"collab.removePerson" = "세션에서 제거"
|
||||
"collab.report" = "서버 운영자에게 파일 신고"
|
||||
"collab.serverInfo" = "서버 정보"
|
||||
"collab.share" = "참여 코드로 공유"
|
||||
@@ -1794,6 +1803,7 @@ status = "draft — needs native review"
|
||||
"plugin.ui" = "플러그인 패널"
|
||||
"plugins.manage" = "플러그인 관리…"
|
||||
"plugins.runLast" = "마지막 플러그인 실행"
|
||||
"pref.autoSave" = "파일 자동 저장"
|
||||
"pref.highlightHover" = "마우스를 올리면 레이어 강조"
|
||||
"pref.keepTool" = "사용 후에도 도구 선택 유지"
|
||||
"pref.language" = "인터페이스 언어"
|
||||
|
||||
@@ -301,7 +301,9 @@ status = "draft — needs native review"
|
||||
"Copy {value}" = "Copiar {value}"
|
||||
"Corner radius" = "Raio do canto"
|
||||
"Corner smoothing" = "Suavização de cantos"
|
||||
"Couldn't download the variables" = "Não foi possível baixar as variáveis"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Não foi possível alcançar {server}: mostrando o que este computador lembra."
|
||||
"Couldn't save automatically: {error}" = "Não foi possível salvar automaticamente: {error}"
|
||||
"Count" = "Quantidade"
|
||||
"Create" = "Criar"
|
||||
"Create a variable collection first" = "Crie primeiro uma coleção de variáveis"
|
||||
@@ -458,6 +460,7 @@ status = "draft — needs native review"
|
||||
"Fade" = "Esmaecer"
|
||||
"False" = "Falso"
|
||||
"Fetching from the server…" = "Buscando no servidor…"
|
||||
"FigCraft couldn't open its window: {error}" = "O FigCraft não conseguiu abrir a janela: {error}"
|
||||
"File" = "Arquivo"
|
||||
"File name" = "Nome do arquivo"
|
||||
"File published" = "Arquivo publicado"
|
||||
@@ -569,6 +572,7 @@ status = "draft — needs native review"
|
||||
"Hue" = "Matiz"
|
||||
"Hug" = "Ajustar"
|
||||
"Hug contents" = "Ajustar ao conteúdo"
|
||||
"If it keeps happening, please report it at {link} with this message." = "Se continuar acontecendo, informe em {link} com esta mensagem."
|
||||
"If" = "Se"
|
||||
"If {condition}" = "Se {condition}"
|
||||
"Ignore auto layout (absolute position)" = "Ignorar layout automático (posição absoluta)"
|
||||
@@ -1000,6 +1004,7 @@ status = "draft — needs native review"
|
||||
"Remove the else and its actions" = "Remover o \"senão\" e suas ações"
|
||||
"Remove this condition and its actions" = "Remover esta condição e suas ações"
|
||||
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Remover este arquivo do servidor? Ele não poderá mais ser compartilhado nem aberto a partir de lá. Sua cópia continua aberta aqui."
|
||||
"Remove {name} from session" = "Remover {name} da sessão"
|
||||
"Remove {name} from {server}?" = "Remover {name} de {server}?"
|
||||
"Removed" = "Removido"
|
||||
"Removed from the server" = "Removido do servidor"
|
||||
@@ -1074,6 +1079,7 @@ status = "draft — needs native review"
|
||||
"Save" = "Salvar"
|
||||
"Save as" = "Salvar como"
|
||||
"Save as preset…" = "Salvar como predefinição…"
|
||||
"Save files automatically" = "Salvar arquivos automaticamente"
|
||||
"Save version {key}" = "Salvar versão {key}"
|
||||
"Saved" = "Salvo"
|
||||
"Saved {name}" = "{name} salvo"
|
||||
@@ -1328,6 +1334,8 @@ status = "draft — needs native review"
|
||||
"Update plugin" = "Atualizar plugin"
|
||||
"Updated {components} and {styles}" = "{components} e {styles} atualizados"
|
||||
"Updated {name}" = "{name} atualizado"
|
||||
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Atualizar o driver de vídeo costuma ajudar. Para usar só o OpenGL, abra o FigCraft pelo Prompt de Comando com:"
|
||||
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Atualizar o driver de vídeo costuma ajudar. WGPU_BACKEND (vulkan, gl, metal) indica outra API gráfica para tentar."
|
||||
"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Envia este arquivo para um servidor FigCraft e mantém esta aba sincronizada com ele. Você recebe um link para editar e outro para visualizar."
|
||||
"Uppercase" = "Maiúsculas"
|
||||
"Use a fixed value" = "Usar um valor fixo"
|
||||
@@ -1369,6 +1377,7 @@ status = "draft — needs native review"
|
||||
"Visibility" = "Visibilidade"
|
||||
"Visible" = "Visível"
|
||||
"Vivid" = "Vívido"
|
||||
"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND está definida e indica a API gráfica a usar; sem ela, o FigCraft escolhe sozinho."
|
||||
"Warm" = "Quente"
|
||||
"Watch" = "Relógio"
|
||||
"Wavy" = "Ondulado"
|
||||
@@ -1576,6 +1585,7 @@ status = "draft — needs native review"
|
||||
"collab.publish" = "Publicar no servidor"
|
||||
"collab.recoverFile" = "Recuperar arquivo online"
|
||||
"collab.registerLinks" = "Abrir links de convite no FigCraft"
|
||||
"collab.removePerson" = "Remover da sessão"
|
||||
"collab.report" = "Denunciar arquivo ao operador do servidor"
|
||||
"collab.serverInfo" = "Informações do servidor"
|
||||
"collab.share" = "Compartilhar com um código de acesso"
|
||||
@@ -1792,6 +1802,7 @@ status = "draft — needs native review"
|
||||
"plugin.ui" = "Painel do plugin"
|
||||
"plugins.manage" = "Gerenciar plugins…"
|
||||
"plugins.runLast" = "Executar o último plugin"
|
||||
"pref.autoSave" = "Salvar arquivos automaticamente"
|
||||
"pref.highlightHover" = "Destacar camadas ao passar o cursor"
|
||||
"pref.keepTool" = "Manter a ferramenta selecionada após o uso"
|
||||
"pref.language" = "Idioma da interface"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -305,6 +305,7 @@ status = "draft — needs native review"
|
||||
"Corner radius" = "圆角半径"
|
||||
"Corner smoothing" = "圆角平滑"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "无法连接 {server}:显示的是这台电脑记录的内容。"
|
||||
"Couldn't save automatically: {error}" = "无法自动保存:{error}"
|
||||
"Count" = "数量"
|
||||
"Create" = "创建"
|
||||
"Create a variable collection first" = "请先创建变量集合"
|
||||
@@ -461,6 +462,7 @@ status = "draft — needs native review"
|
||||
"Fade" = "褪色"
|
||||
"False" = "假"
|
||||
"Fetching from the server…" = "正在从服务器获取…"
|
||||
"FigCraft couldn't open its window: {error}" = "FigCraft 无法打开窗口:{error}"
|
||||
"File" = "文件"
|
||||
"File name" = "文件名"
|
||||
"File published" = "文件已发布"
|
||||
@@ -572,6 +574,7 @@ status = "draft — needs native review"
|
||||
"Hue" = "色相"
|
||||
"Hug" = "适应"
|
||||
"Hug contents" = "适应内容"
|
||||
"If it keeps happening, please report it at {link} with this message." = "如果问题持续出现,请附上此消息到 {link} 报告。"
|
||||
"If" = "如果"
|
||||
"If {condition}" = "如果 {condition}"
|
||||
"Ignore auto layout (absolute position)" = "忽略自动布局(绝对定位)"
|
||||
@@ -1003,6 +1006,7 @@ status = "draft — needs native review"
|
||||
"Remove the else and its actions" = "移除“否则”及其操作"
|
||||
"Remove this condition and its actions" = "移除此条件及其操作"
|
||||
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "要从服务器移除此文件吗?之后将无法再从服务器分享或打开它。你的副本仍在此处打开。"
|
||||
"Remove {name} from session" = "将 {name} 移出会话"
|
||||
"Remove {name} from {server}?" = "要从 {server} 移除 {name} 吗?"
|
||||
"Removed" = "已删除"
|
||||
"Removed from the server" = "已从服务器移除"
|
||||
@@ -1077,6 +1081,7 @@ status = "draft — needs native review"
|
||||
"Save" = "保存"
|
||||
"Save as" = "另存为"
|
||||
"Save as preset…" = "存储为预设…"
|
||||
"Save files automatically" = "自动保存文件"
|
||||
"Save version {key}" = "保存版本 {key}"
|
||||
"Saved" = "已保存"
|
||||
"Saved {name}" = "已保存 {name}"
|
||||
@@ -1331,6 +1336,8 @@ status = "draft — needs native review"
|
||||
"Update plugin" = "更新插件"
|
||||
"Updated {components} and {styles}" = "已更新 {components} 和 {styles}"
|
||||
"Updated {name}" = "已更新 {name}"
|
||||
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "更新显卡驱动通常会有帮助。若只想使用 OpenGL,请在命令提示符中这样启动 FigCraft:"
|
||||
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "更新显卡驱动通常会有帮助。可用 WGPU_BACKEND(vulkan、gl、metal)指定另一个图形 API 来尝试。"
|
||||
"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "将此文件上传到 FigCraft 服务器,并使此标签页与之保持同步。你会获得一个编辑链接和一个查看链接。"
|
||||
"Uppercase" = "大写"
|
||||
"Use a fixed value" = "使用固定值"
|
||||
@@ -1372,6 +1379,7 @@ status = "draft — needs native review"
|
||||
"Visibility" = "可见性"
|
||||
"Visible" = "可见"
|
||||
"Vivid" = "鲜艳"
|
||||
"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "已设置 WGPU_BACKEND,它指定了要使用的图形 API;取消设置后由 FigCraft 自行选择。"
|
||||
"Warm" = "暖调"
|
||||
"Watch" = "手表"
|
||||
"Wavy" = "波浪线"
|
||||
@@ -1579,6 +1587,7 @@ status = "draft — needs native review"
|
||||
"collab.publish" = "发布到服务器"
|
||||
"collab.recoverFile" = "恢复在线文件"
|
||||
"collab.registerLinks" = "在 FigCraft 中打开邀请链接"
|
||||
"collab.removePerson" = "移出会话"
|
||||
"collab.report" = "向服务器运营者举报文件"
|
||||
"collab.serverInfo" = "服务器信息"
|
||||
"collab.share" = "用加入代码分享"
|
||||
@@ -1795,6 +1804,7 @@ status = "draft — needs native review"
|
||||
"plugin.ui" = "插件面板"
|
||||
"plugins.manage" = "管理插件…"
|
||||
"plugins.runLast" = "运行上一个插件"
|
||||
"pref.autoSave" = "自动保存文件"
|
||||
"pref.highlightHover" = "悬停时高亮图层"
|
||||
"pref.keepTool" = "使用后保持工具选中"
|
||||
"pref.language" = "界面语言"
|
||||
|
||||
@@ -1100,11 +1100,12 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
|
||||
trn("Swapped {n} assets; {missed} had no match and still use the old library", moved as usize, &[("missed", &missed)])
|
||||
});
|
||||
}
|
||||
if add
|
||||
&& let Some(p) = app.services.pick_open(tr("Add library"), &["figcraft"])
|
||||
&& let Some(r) = app.run(ADD, json!({"path": p.to_string_lossy()}))
|
||||
{
|
||||
added(app, &r);
|
||||
if add {
|
||||
app.pick_open(tr("Add library"), &["figcraft"], |app, p| {
|
||||
if let Some(r) = app.run(ADD, json!({"path": p.to_string_lossy()})) {
|
||||
added(app, &r);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use figcraft_engine::Session;
|
||||
|
||||
use crate::{DialogFuture, FigApp, Services};
|
||||
|
||||
struct OpenFile(PathBuf);
|
||||
|
||||
impl Services for OpenFile {
|
||||
fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture {
|
||||
Box::pin(std::future::ready(Some(self.0.clone())))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_dialog_finishes_after_the_async_picker_returns() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-async-open-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("opened.figcraft");
|
||||
let doc = figcraft_doc::Document::new("Opened");
|
||||
std::fs::write(&path, figcraft_format::save_bytes(&doc, None).unwrap()).unwrap();
|
||||
|
||||
let mut app = FigApp::new(Session::empty(), Box::new(OpenFile(path.clone())));
|
||||
app.ui.home = true;
|
||||
app.open_dialog();
|
||||
assert!(app.session.docs.is_empty(), "opening waits for the picker result");
|
||||
|
||||
app.poll_dialog(&egui::Context::default());
|
||||
assert_eq!(app.session.docs.len(), 1);
|
||||
assert_eq!(app.session.docs[0].path.as_deref(), Some(path.to_string_lossy().as_ref()));
|
||||
assert!(!app.ui.home);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Every picker answers `self.0`.
|
||||
struct Picks(PathBuf);
|
||||
|
||||
impl Services for Picks {
|
||||
fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture {
|
||||
Box::pin(std::future::ready(Some(self.0.clone())))
|
||||
}
|
||||
fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> DialogFuture {
|
||||
Box::pin(std::future::ready(Some(self.0.clone())))
|
||||
}
|
||||
}
|
||||
|
||||
/// Two files, A (the first) in front, and where the picker saves.
|
||||
fn two_files(tag: &str) -> (FigApp, PathBuf, PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-async-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("a.figcraft");
|
||||
let mut app = FigApp::new(Session::empty(), Box::new(Picks(path.clone())));
|
||||
for _ in 0..2 {
|
||||
app.session.execute("file.new", &serde_json::json!({})).unwrap();
|
||||
}
|
||||
app.session.active = 0;
|
||||
(app, dir, path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_saves_the_file_it_was_opened_for_after_another_tab_comes_to_the_front() {
|
||||
let (mut app, dir, path) = two_files("pin");
|
||||
app.save_as_dialog();
|
||||
// The user switches tabs while the dialog is open.
|
||||
app.session.active = 1;
|
||||
// Another window draws first: the result waits for the window holding the file.
|
||||
app.windows.current = 7;
|
||||
app.poll_dialog(&egui::Context::default());
|
||||
assert!(app.session.docs.iter().all(|d| d.path.is_none()));
|
||||
app.windows.current = 0;
|
||||
app.poll_dialog(&egui::Context::default());
|
||||
let p = path.to_string_lossy();
|
||||
assert_eq!(app.session.docs[0].path.as_deref(), Some(p.as_ref()), "A is saved where the user picked");
|
||||
assert_eq!(app.session.docs[1].path, None, "B is untouched");
|
||||
assert_eq!(app.session.active, 1, "the tab the user switched to stays in front");
|
||||
assert!(path.exists());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dialog_whose_file_closed_meanwhile_does_nothing() {
|
||||
let (mut app, dir, path) = two_files("closed");
|
||||
app.save_as_dialog();
|
||||
app.session.execute("file.close", &serde_json::json!({"index": 0, "force": true})).unwrap();
|
||||
app.poll_dialog(&egui::Context::default());
|
||||
assert!(app.session.docs.iter().all(|d| d.path.is_none()), "B isn't saved in A's place");
|
||||
assert!(!path.exists());
|
||||
assert!(app.pending_dialog.is_none());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -11,16 +11,27 @@ use serde_json::json;
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::i18n::{tr, trf, trn};
|
||||
use crate::render_worker::{Job, Worker};
|
||||
use crate::render_worker::{Done, Job, Worker};
|
||||
use crate::theme::{ACCENT, COMPONENT, Tokens, doc_c32, regular, semibold};
|
||||
|
||||
pub struct CanvasState {
|
||||
pub rect: Rect,
|
||||
texture: Option<egui::TextureHandle>,
|
||||
tex_view: Option<figcraft_render::View>,
|
||||
/// The file ([`figcraft_engine::DocState::uid`]) and page the texture shows: it is drawn only
|
||||
/// on them.
|
||||
tex_of: Option<(u64, NodeId)>,
|
||||
/// The texture is a coarse preview (see [`crate::render_worker`]).
|
||||
pub tex_coarse: bool,
|
||||
submitted: Option<JobKey>,
|
||||
/// The view of the last render asked for.
|
||||
want_view: Option<figcraft_render::View>,
|
||||
/// What the last canvas render had to do (shown by `ui.inspect`).
|
||||
pub render_work: Option<figcraft_render::Work>,
|
||||
/// From asking for a render to its preview, and its refined image, handed to egui as the
|
||||
/// canvas texture (ms; the texture is uploaded when that frame is painted).
|
||||
pub preview_latency_ms: Option<f64>,
|
||||
pub refine_latency_ms: Option<f64>,
|
||||
dragging: bool,
|
||||
space_pan: bool,
|
||||
middle_pan: Option<(Pos2, f64, f64)>,
|
||||
@@ -45,8 +56,13 @@ impl Default for CanvasState {
|
||||
rect: Rect::NOTHING,
|
||||
texture: None,
|
||||
tex_view: None,
|
||||
tex_of: None,
|
||||
tex_coarse: false,
|
||||
submitted: None,
|
||||
want_view: None,
|
||||
render_work: None,
|
||||
preview_latency_ms: None,
|
||||
refine_latency_ms: None,
|
||||
dragging: false,
|
||||
space_pan: false,
|
||||
middle_pan: None,
|
||||
@@ -172,7 +188,14 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
||||
request_render(app, worker, ppp);
|
||||
// Every finished render is shown, even when a newer one is already on its way: renders finish
|
||||
// in order, and an edit every frame (a drag) would otherwise never show until it stopped.
|
||||
if let Some(done) = worker.poll() {
|
||||
let shown = app.session.ds().map(|ds| (ds.uid, ds.page)).ok();
|
||||
if let Some(done) = worker.poll()
|
||||
&& shows(&app.canvas, &done, shown)
|
||||
{
|
||||
if !done.coarse {
|
||||
app.canvas.render_work = Some(done.work);
|
||||
app.ui.render_ms = done.ms;
|
||||
}
|
||||
// A pixel preview render is magnified with hard pixel edges.
|
||||
let filter = if app.session.ds().ok().and_then(|d| d.view.pixel_preview_scale()) == Some(done.view.scale) {
|
||||
egui::TextureOptions::NEAREST
|
||||
@@ -183,9 +206,15 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
||||
Some(t) => t.set(done.image, filter),
|
||||
None => app.canvas.texture = Some(ctx.load_texture("figcraft-canvas", done.image, filter)),
|
||||
}
|
||||
let latency = Some(done.at.elapsed().as_secs_f64() * 1000.0);
|
||||
if done.coarse {
|
||||
app.canvas.preview_latency_ms = latency;
|
||||
} else {
|
||||
app.canvas.refine_latency_ms = latency;
|
||||
}
|
||||
app.canvas.tex_view = Some(done.view);
|
||||
app.canvas.render_work = Some(done.work);
|
||||
app.ui.render_ms = done.ms;
|
||||
app.canvas.tex_of = Some((done.uid, done.page));
|
||||
app.canvas.tex_coarse = done.coarse;
|
||||
}
|
||||
let painter = ui.painter_at(rect);
|
||||
let t = Tokens::get(&ctx);
|
||||
@@ -200,7 +229,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
||||
})
|
||||
.unwrap_or(t.canvas);
|
||||
painter.rect_filled(rect, CornerRadius::ZERO, bg);
|
||||
if let (Some(tex), Some(v)) = (&app.canvas.texture, &app.canvas.tex_view) {
|
||||
if let (Some(tex), Some(v), true) = (&app.canvas.texture, &app.canvas.tex_view, shown.is_some() && app.canvas.tex_of == shown) {
|
||||
// Re-project the last render onto the current view.
|
||||
let tl = app.to_screen(v.origin.0, v.origin.1);
|
||||
let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0);
|
||||
@@ -268,6 +297,30 @@ fn describe_box([x0, y0, x1, y1]: [f64; 4]) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a finished render goes on screen: an image of the file and page shown (`shown`), and
|
||||
/// not a coarse preview over a refined image that already shows the whole view asked for at its
|
||||
/// zoom (the same zoom elsewhere on the page is no such image).
|
||||
fn shows(c: &CanvasState, done: &Done, shown: Option<(u64, NodeId)>) -> bool {
|
||||
if shown != Some((done.uid, done.page)) {
|
||||
return false;
|
||||
}
|
||||
let refined_here = !c.tex_coarse
|
||||
&& c.tex_of == shown
|
||||
&& c.tex_view
|
||||
.as_ref()
|
||||
.zip(c.want_view.as_ref())
|
||||
.is_some_and(|(have, want)| have.scale.to_bits() == want.scale.to_bits() && covers(have, want));
|
||||
!(done.coarse && refined_here)
|
||||
}
|
||||
|
||||
/// Whether `outer` shows all of the page that `inner` does.
|
||||
fn covers(outer: &figcraft_render::View, inner: &figcraft_render::View) -> bool {
|
||||
let (o, i) = (outer.page_rect(), inner.page_rect());
|
||||
// Rounding in the snapped origin, well under a device pixel.
|
||||
let eps = 1e-6 / inner.scale.max(1e-9);
|
||||
o.x0 <= i.x0 + eps && o.y0 <= i.y0 + eps && o.x1 >= i.x1 - eps && o.y1 >= i.y1 - eps
|
||||
}
|
||||
|
||||
fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
|
||||
let Ok(ds) = app.session.ds() else { return };
|
||||
let rect = app.canvas.rect;
|
||||
@@ -313,7 +366,11 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
|
||||
skip: vec![],
|
||||
solo: key.solo,
|
||||
proof,
|
||||
uid: ds.uid,
|
||||
zoom_preview: ds.view.pixel_preview_scale().is_none(),
|
||||
at: web_time::Instant::now(),
|
||||
};
|
||||
app.canvas.want_view = Some(job.view.clone());
|
||||
worker.submit(job);
|
||||
app.canvas.submitted = Some(key);
|
||||
}
|
||||
@@ -371,6 +428,13 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
|
||||
}
|
||||
}
|
||||
let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else {
|
||||
// A drag released off the canvas, or off the window, ends where the pointer was last seen.
|
||||
if app.canvas.dragging && !ctx.input(|i| i.pointer.primary_down()) {
|
||||
app.canvas.dragging = false;
|
||||
if let Some(at) = hover_pos.or_else(|| app.canvas.last_pointer.map(|p| app.to_screen(p.x, p.y))) {
|
||||
send(app, PointerKind::Up, at, &mods);
|
||||
}
|
||||
}
|
||||
// Leaving the canvas: nothing is hovered there any more.
|
||||
if !app.canvas.dragging && std::mem::take(&mut app.canvas.hovering) {
|
||||
app.session.vector_pointer_left();
|
||||
@@ -482,19 +546,24 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
|
||||
app.canvas.pressure = force;
|
||||
}
|
||||
if let Some(k) = kind {
|
||||
let ev = pointer_event(app, k, pos, &mods);
|
||||
if k == PointerKind::Up {
|
||||
app.canvas.pressure = None;
|
||||
}
|
||||
app.canvas.last_pointer = Some(Point::new(ev.x, ev.y));
|
||||
if let Err(e) = app.session.pointer(&ev) {
|
||||
app.toast(e.to_string());
|
||||
}
|
||||
app.handle_ui_requests();
|
||||
send(app, k, pos, &mods);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Pointer event `kind` at screen point `pos`, to the tools.
|
||||
fn send(app: &mut FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers) {
|
||||
let ev = pointer_event(app, kind, pos, m);
|
||||
if kind == PointerKind::Up {
|
||||
app.canvas.pressure = None;
|
||||
}
|
||||
app.canvas.last_pointer = Some(Point::new(ev.x, ev.y));
|
||||
if let Err(e) = app.session.pointer(&ev) {
|
||||
app.toast(e.to_string());
|
||||
}
|
||||
app.handle_ui_requests();
|
||||
}
|
||||
|
||||
/// Right-click at screen `pos`: select what's under the pointer (`deep`: the nested layer), then
|
||||
/// open the context menu there.
|
||||
pub fn open_context_menu(app: &mut FigApp, pos: Pos2, deep: bool) {
|
||||
@@ -1365,3 +1434,7 @@ pub fn fmt(v: f64) -> String {
|
||||
let r = (v * 100.0).round() / 100.0;
|
||||
if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "zoom_preview_tests.rs"]
|
||||
mod zoom_preview_tests;
|
||||
|
||||
@@ -59,3 +59,65 @@ fn a_drag_shows_on_the_canvas_while_it_moves() {
|
||||
assert!(u.app.worker.as_ref().is_some_and(|w| !w.in_flight()), "nothing left to render");
|
||||
u.button(p + vec2(30.0, 10.0), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_a_canvas_drag_outside_finishes_the_interaction() {
|
||||
let mut s = Session::new();
|
||||
let r =
|
||||
figcraft_engine::params::id_of(&s.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 80, "height": 60})).unwrap()["id"]).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
let start = u.app.to_screen(80.0, 70.0);
|
||||
let outside = egui::pos2(u.app.canvas.rect.max.x + 20.0, start.y);
|
||||
u.button(start, true);
|
||||
u.frame(vec![Event::PointerMoved(outside)]);
|
||||
u.button(outside, false);
|
||||
|
||||
assert!(u.app.session.ds().unwrap().interaction.is_none(), "the document interaction is committed");
|
||||
}
|
||||
|
||||
fn key(u: &mut Ui, key: egui::Key) {
|
||||
let modifiers = egui::Modifiers::NONE;
|
||||
u.frame(vec![
|
||||
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
|
||||
Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_released_off_the_window_ends_where_the_pointer_was_last_seen_and_later_edits_stay() {
|
||||
// #278: a drag left open made a later text edit roll back the changes made since.
|
||||
let mut s = Session::new();
|
||||
let id = |v: serde_json::Value| figcraft_engine::params::id_of(&v["id"]).unwrap();
|
||||
let kept = id(s.execute("shape.rectangle", &json!({"x": 40, "y": 400, "width": 80, "height": 40})).unwrap());
|
||||
let gone = id(s.execute("shape.rectangle", &json!({"x": 160, "y": 400, "width": 80, "height": 40})).unwrap());
|
||||
let text = id(s.execute("text.create", &json!({"text": "A", "x": 40, "y": 500})).unwrap());
|
||||
s.execute("tool.rectangle", &json!({})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
// A rectangle drawn from the middle of the canvas, released off the window, the pointer gone.
|
||||
let start = u.app.canvas.rect.center();
|
||||
u.button(start, true);
|
||||
for i in 1..=6 {
|
||||
u.frame(vec![Event::PointerMoved(start + vec2(10.0, 6.0) * i as f32)]);
|
||||
}
|
||||
let release =
|
||||
Event::PointerButton { pos: egui::pos2(-50.0, -50.0), button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
|
||||
u.frame(vec![release, Event::PointerGone]);
|
||||
let ds = u.app.session.ds().unwrap();
|
||||
assert!(ds.interaction.is_none(), "the drawing is committed");
|
||||
let (drawn, zoom) = (ds.selection[0], ds.view.zoom);
|
||||
let b = ds.doc.world_bounds(drawn).unwrap();
|
||||
// Not the 100 x 100 shape of a click, as an end in the middle of the canvas would make.
|
||||
assert!((b.width() * zoom - 60.0).abs() < 1.5 && (b.height() * zoom - 36.0).abs() < 1.5, "{b:?} at zoom {zoom}");
|
||||
// #278's steps: a move in the panel, a delete, then a text edit.
|
||||
u.app.run("node.set", json!({"ids": [kept], "props": {"x": 650}}));
|
||||
u.app.run("edit.delete", json!({"ids": [gone]}));
|
||||
u.app.run("select.set", json!({"ids": [text]}));
|
||||
key(&mut u, egui::Key::Enter);
|
||||
u.frame(vec![Event::Text("B".into())]);
|
||||
key(&mut u, egui::Key::Escape);
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
assert_eq!(doc.world_bounds(kept).unwrap().x0, 650.0, "the move stays");
|
||||
assert!(!doc.contains(gone), "the delete stays");
|
||||
assert_eq!(doc.node(text).unwrap().kind.text().unwrap().characters, "B");
|
||||
}
|
||||
|
||||
@@ -63,6 +63,9 @@ fn big_job(doc: &Arc<figcraft_doc::Document>, page: figcraft_doc::NodeId, scale:
|
||||
skip: vec![],
|
||||
solo: None,
|
||||
proof: false,
|
||||
uid: 0,
|
||||
zoom_preview: false,
|
||||
at: web_time::Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ mod tests {
|
||||
assert_eq!(language("zh-Hant"), HAN);
|
||||
assert_eq!(language("zh-Hans"), HAN);
|
||||
assert_eq!(language("fr"), 0);
|
||||
assert!(["en", "es", "es-419", "fr", "de", "pt-BR"].iter().all(|c| can_show(c)));
|
||||
assert!(["en", "es", "es-419", "fr", "de", "pt-BR", "tr", "id"].iter().all(|c| can_show(c)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -460,23 +460,21 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
ui.add_space(12.0);
|
||||
if let Some(i) = widgets::segmented(ui, &kinds, Some(active), 30.0) {
|
||||
let base = paint.preview_color();
|
||||
let mut image_pending = false;
|
||||
paint.kind = match i {
|
||||
0 => PaintKind::Solid { color: base.with_alpha(1.0) },
|
||||
5 => {
|
||||
// Pick an image file.
|
||||
match app.services.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]) {
|
||||
Some(p) => match std::fs::read(&p) {
|
||||
Ok(bytes) => {
|
||||
let hash = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes));
|
||||
match hash {
|
||||
Some(h) => Paint::image(h, ImageScaleMode::Fill).kind,
|
||||
None => paint.kind.clone(),
|
||||
}
|
||||
}
|
||||
Err(_) => paint.kind.clone(),
|
||||
},
|
||||
None => paint.kind.clone(),
|
||||
}
|
||||
image_pending = true;
|
||||
let target = target.clone();
|
||||
app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| {
|
||||
let Ok(bytes) = std::fs::read(&p) else { return };
|
||||
let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return };
|
||||
if let Some(mut paint) = current_paint(app, &target) {
|
||||
paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind;
|
||||
write(app, &target, &paint, false);
|
||||
}
|
||||
});
|
||||
paint.kind.clone()
|
||||
}
|
||||
6 => match pattern_sources(app, &target).first() {
|
||||
Some((id, _)) => Paint::pattern(id.0).kind,
|
||||
@@ -495,7 +493,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
};
|
||||
write(app, &target, &paint, false);
|
||||
if !image_pending {
|
||||
write(app, &target, &paint, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -136,6 +136,51 @@ pub fn drain(app: &mut FigApp, ctx: &Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Methods that need no frame drawn (#269): a hidden window answers them too.
|
||||
const NO_FRAME: &[&str] = &["engine.execute", "engine.commands", "document.inspect", "node.get", "window.list", "ui.render"];
|
||||
|
||||
/// Whether window `id` draws no frames now: minimised, or covered by other windows (on macOS also
|
||||
/// on another Space).
|
||||
fn hidden(ctx: &Context, id: u64) -> bool {
|
||||
ctx.input(|i| i.raw.viewports.get(&crate::windows::viewport(id)).and_then(egui::ViewportInfo::visible)) == Some(false)
|
||||
}
|
||||
|
||||
/// A hidden window (minimised, or covered by other windows) draws no frames, but the desktop app's
|
||||
/// `logic` still runs, for the main window even while every window is hidden: it answers the
|
||||
/// requests for any hidden window that need no frame, in that window's file, so an attached agent
|
||||
/// can edit, inspect and save. The rest (screenshots, input, the interface's own state) wait for the
|
||||
/// window to show; another window on screen is asked to draw, which the main window's frame would
|
||||
/// do but doesn't while it is hidden. A request for a window that doesn't exist is an error at once.
|
||||
pub fn drain_hidden(app: &mut FigApp, ctx: &Context) {
|
||||
for r in std::mem::take(&mut app.control_queue) {
|
||||
if r.is_cancelled() {
|
||||
continue;
|
||||
}
|
||||
let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused);
|
||||
if !app.windows.exists(target) {
|
||||
let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) });
|
||||
continue;
|
||||
}
|
||||
if !hidden(ctx, target) || !NO_FRAME.contains(&r.method.as_str()) {
|
||||
// Another window on screen draws only when asked: while the main window is hidden no
|
||||
// frame of it does that (`drain`), so ask here.
|
||||
if target != app.windows.current && !hidden(ctx, target) {
|
||||
ctx.request_repaint_of(crate::windows::viewport(target));
|
||||
}
|
||||
app.control_queue.push(r);
|
||||
continue;
|
||||
}
|
||||
// Outside a frame, so outside the frame's guard: within the window's swap, a panic still
|
||||
// ends in recovery copies and leaves every window its own state.
|
||||
let res = crate::windows::as_window(app, target, |app| {
|
||||
let mut res = Err("internal error".to_string());
|
||||
crate::windows::guard(app, ctx, |app| res = handle(app, ctx, &r.method, &r.params));
|
||||
res
|
||||
});
|
||||
let _ = r.reply.send(ControlReply { id: r.id, result: res.unwrap_or_else(|| Err(format!("no window {target}"))) });
|
||||
}
|
||||
}
|
||||
|
||||
fn save_color_image(img: &egui::ColorImage, path: &str) -> Result<(), String> {
|
||||
let [w, h] = img.size;
|
||||
let mut rgba = Vec::with_capacity(w * h * 4);
|
||||
|
||||
@@ -15,6 +15,39 @@ use crate::theme::{ACCENT, Tokens, regular, semibold};
|
||||
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Where a report goes.
|
||||
const ISSUES: &str = "https://github.com/storytold/figcraft/issues";
|
||||
|
||||
/// What the desktop app says when its window couldn't open with `error` (no frame was drawn: the
|
||||
/// language is the one [`i18n::set`] picked from the preferences). `backend_set`: `WGPU_BACKEND`
|
||||
/// named the graphics API, so the hint is to unset it rather than to try another one.
|
||||
pub fn window_failed(error: &str, backend_set: bool) -> String {
|
||||
let hint = if backend_set {
|
||||
tr("WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose.").to_string()
|
||||
} else if cfg!(windows) {
|
||||
// Direct3D 12 and OpenGL are both offered, but wgpu keeps the Direct3D 12 adapter it picked
|
||||
// even when its device then fails, so naming OpenGL alone can still help.
|
||||
format!(
|
||||
"{}
|
||||
|
||||
set WGPU_BACKEND=gl
|
||||
figcraft.exe",
|
||||
tr("Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:")
|
||||
)
|
||||
} else {
|
||||
tr("Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try.").to_string()
|
||||
};
|
||||
format!(
|
||||
"{}
|
||||
|
||||
{hint}
|
||||
|
||||
{}",
|
||||
trf("FigCraft couldn't open its window: {error}", &[("error", &error)]),
|
||||
trf("If it keeps happening, please report it at {link} with this message.", &[("link", &ISSUES)])
|
||||
)
|
||||
}
|
||||
|
||||
/// A dialog window, centred.
|
||||
pub(crate) fn window<'a>(title: &'a str, ctx: &Context) -> egui::Window<'a> {
|
||||
floating(title, ctx).anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
|
||||
@@ -163,6 +196,10 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
||||
if app.services.reduce_motion() {
|
||||
crate::widgets::label(ui, tr("Your system asks for reduced motion: animations stay off."), t.text_secondary);
|
||||
}
|
||||
// The browser has no files to save to.
|
||||
if !cfg!(target_arch = "wasm32") && crate::widgets::checkbox(ui, prefs.auto_save, tr("Save files automatically")) {
|
||||
toggles.push("pref.autoSave");
|
||||
}
|
||||
for c in toggles {
|
||||
app.run(c, json!({}));
|
||||
}
|
||||
@@ -464,15 +501,17 @@ fn color_profile_dialog(app: &mut FigApp, ctx: &Context) {
|
||||
|
||||
/// Save the whole file as `.fig`; the toast says how much was left out.
|
||||
fn save_fig(app: &mut FigApp, name: &str) {
|
||||
let Some(p) = app.services.pick_save(tr("Export"), &format!("{name}.fig"), &["fig"]) else { return };
|
||||
let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
|
||||
if let Some(r) = app.run("file.export", json!({"format": "fig", "path": p.to_string_lossy()})) {
|
||||
let left_out = r["warnings"].as_array().map_or(0, Vec::len);
|
||||
app.toast(match left_out {
|
||||
0 => trf("Exported {name}", &[("name", &file)]),
|
||||
n => trn("Exported {name}; {n} kinds of content were left out", n, &[("name", &file)]),
|
||||
});
|
||||
}
|
||||
let filename = format!("{name}.fig");
|
||||
app.pick_save(tr("Export"), &filename, &["fig"], |app, p| {
|
||||
let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
|
||||
if let Some(r) = app.run("file.export", json!({"format": "fig", "path": p.to_string_lossy()})) {
|
||||
let left_out = r["warnings"].as_array().map_or(0, Vec::len);
|
||||
app.toast(match left_out {
|
||||
0 => trf("Exported {name}", &[("name", &file)]),
|
||||
n => trn("Exported {name}; {n} kinds of content were left out", n, &[("name", &file)]),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// What the Export dialog was asked to do.
|
||||
|
||||
@@ -318,11 +318,13 @@ pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId], assets: bool) {
|
||||
// The suggested name keeps only the file's own name (a layer's folders apply to folders).
|
||||
[one] => save_one(app, &[one.id], &one.setting, one.file.rsplit('/').next().unwrap_or(&one.file)),
|
||||
_ => {
|
||||
let Some(dir) = app.services.pick_folder(tr("Export")) else { return };
|
||||
if let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "dir": dir.to_string_lossy(), "assets": assets})) {
|
||||
let n = r["files"].as_array().map_or(0, Vec::len);
|
||||
app.toast(trn("Exported {n} files", n, &[]));
|
||||
}
|
||||
let ids = ids.to_vec();
|
||||
app.pick_folder(tr("Export"), move |app, dir| {
|
||||
if let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "dir": dir.to_string_lossy(), "assets": assets})) {
|
||||
let n = r["files"].as_array().map_or(0, Vec::len);
|
||||
app.toast(trn("Exported {n} files", n, &[]));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,14 +332,14 @@ pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId], assets: bool) {
|
||||
/// Export `ids` together as one file with `setting`, through a save dialog suggesting `file`.
|
||||
pub(crate) fn save_one(app: &mut FigApp, ids: &[NodeId], setting: &ExportSetting, file: &str) {
|
||||
let ext = setting.format.ext();
|
||||
let Some(p) = app.services.pick_save(tr("Export"), file, &[ext]) else { return };
|
||||
let path = p.to_string_lossy().to_string();
|
||||
let mut params = setting_params(setting);
|
||||
params["ids"] = json!(ids);
|
||||
params["path"] = json!(path);
|
||||
if app.run("file.export", params).is_some() {
|
||||
app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())]));
|
||||
}
|
||||
app.pick_save(tr("Export"), file, &[ext], move |app, p| {
|
||||
params["path"] = json!(p.to_string_lossy());
|
||||
if app.run("file.export", params).is_some() {
|
||||
app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())]));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The file name a one-off export of `ids` suggests: the first layer's name (or `fallback`) with
|
||||
|
||||
+55
-17
@@ -13,8 +13,13 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
/// One catalog entry.
|
||||
pub enum Text {
|
||||
Plain(&'static str),
|
||||
/// Plural forms: `one` (when the language has it) and `other`.
|
||||
Plural(Option<&'static str>, &'static str),
|
||||
/// Optional language-specific plural forms, with `other` as the fallback.
|
||||
Plural {
|
||||
one: Option<&'static str>,
|
||||
few: Option<&'static str>,
|
||||
many: Option<&'static str>,
|
||||
other: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
/// A language's catalog (generated from `locales/<code>.toml`).
|
||||
@@ -104,7 +109,7 @@ fn lookup(lang: usize, key: &str) -> Option<&'static Text> {
|
||||
/// two translations gets a context after `|` ("Fill|sizing"); the English catalog maps it back.
|
||||
pub fn tr(key: &str) -> &str {
|
||||
match lookup(CURRENT.load(Ordering::Relaxed), key) {
|
||||
Some(Text::Plain(s) | Text::Plural(_, s)) => s,
|
||||
Some(Text::Plain(s) | Text::Plural { other: s, .. }) => s,
|
||||
None => key,
|
||||
}
|
||||
}
|
||||
@@ -123,26 +128,59 @@ pub fn trf(key: &str, args: &[(&str, &dyn Display)]) -> String {
|
||||
/// The plural form of `key` (its English "other" form) for count `n`, with `{n}` and `args` filled.
|
||||
pub fn trn(key: &str, n: usize, args: &[(&str, &dyn Display)]) -> String {
|
||||
let lang = CURRENT.load(Ordering::Relaxed);
|
||||
let text = match lookup(lang, key) {
|
||||
Some(Text::Plural(one, other)) => one.filter(|_| plural_one(lang, n)).unwrap_or(other),
|
||||
Some(Text::Plain(s)) => s,
|
||||
None => key,
|
||||
};
|
||||
let text = counted(lang, key, n);
|
||||
let mut all: Vec<(&str, &dyn Display)> = vec![("n", &n)];
|
||||
all.extend_from_slice(args);
|
||||
fill(text, &all)
|
||||
}
|
||||
|
||||
/// Pick a plural using the catalog that supplies the text: an English fallback keeps English's
|
||||
/// rule, even when the selected language is Russian.
|
||||
fn counted(lang: usize, key: &str, n: usize) -> &str {
|
||||
chain(lang)
|
||||
.find_map(|l| {
|
||||
find(l.ui, key).map(|text| match text {
|
||||
Text::Plain(s) => *s,
|
||||
Text::Plural { one, few, many, other } => match plural(l.code, n) {
|
||||
Plural::One => one.unwrap_or(other),
|
||||
Plural::Few => few.unwrap_or(other),
|
||||
Plural::Many => many.unwrap_or(other),
|
||||
Plural::Other => other,
|
||||
},
|
||||
})
|
||||
})
|
||||
.unwrap_or(key)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Plural {
|
||||
One,
|
||||
Few,
|
||||
Many,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// CLDR integer rules. Existing one/other catalogs may omit the extra forms.
|
||||
fn plural(code: &str, n: usize) -> Plural {
|
||||
match code {
|
||||
"ru" if n % 10 == 1 && n % 100 != 11 => Plural::One,
|
||||
"ru" if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) => Plural::Few,
|
||||
"ru" => Plural::Many,
|
||||
"ja" | "ko" | "zh-Hans" => Plural::Other,
|
||||
"fr" | "pt-BR" if n <= 1 => Plural::One,
|
||||
_ if n == 1 => Plural::One,
|
||||
_ => Plural::Other,
|
||||
}
|
||||
}
|
||||
|
||||
// CLDR's million-count "many" in French, Spanish and Portuguese still reads their existing
|
||||
// "other" wording. Only Russian currently supplies separate few/many forms.
|
||||
|
||||
/// Whether count `n` takes the "one" form: 0 and 1 in French and Brazilian Portuguese, never in
|
||||
/// Japanese, Korean and Chinese (no plural forms: always "other"), 1 elsewhere (CLDR's integer rules).
|
||||
// shortcut: CLDR's "many" (French, Spanish and Portuguese millions) reads as "other" here; add it
|
||||
// when a language needs a third form.
|
||||
#[cfg(test)]
|
||||
fn plural_one(lang: usize, n: usize) -> bool {
|
||||
match LANGS.get(lang).map(|l| l.code) {
|
||||
Some("ja" | "ko" | "zh-Hans") => false,
|
||||
Some("fr" | "pt-BR") => n <= 1,
|
||||
_ => n == 1,
|
||||
}
|
||||
plural(LANGS.get(lang).map_or("en", |l| l.code), n) == Plural::One
|
||||
}
|
||||
|
||||
/// Replace each `{name}` in `text` with its argument, in one pass (an argument's own braces stay).
|
||||
@@ -186,8 +224,8 @@ pub fn menu_item<'a>(id: &str, english: &'a str) -> Cow<'a, str> {
|
||||
fn label<'a>(menu: bool, id: &str, english: &'a str) -> Cow<'a, str> {
|
||||
let text = chain(CURRENT.load(Ordering::Relaxed)).find_map(|l| menu.then(|| find(l.menu, id)).flatten().or_else(|| find(l.commands, id)));
|
||||
match text {
|
||||
Some(Text::Plain(s) | Text::Plural(_, s)) if english.ends_with('…') && !s.ends_with('…') => Cow::Owned(format!("{s}…")),
|
||||
Some(Text::Plain(s) | Text::Plural(_, s)) => Cow::Borrowed(s),
|
||||
Some(Text::Plain(s) | Text::Plural { other: s, .. }) if english.ends_with('…') && !s.ends_with('…') => Cow::Owned(format!("{s}…")),
|
||||
Some(Text::Plain(s) | Text::Plural { other: s, .. }) => Cow::Borrowed(s),
|
||||
None => Cow::Borrowed(english),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ fn placeholders(s: &str) -> BTreeSet<&str> {
|
||||
fn forms(t: &Text) -> Vec<&'static str> {
|
||||
match t {
|
||||
Text::Plain(s) => vec![s],
|
||||
Text::Plural(one, other) => one.iter().copied().chain([*other]).collect(),
|
||||
Text::Plural { one, few, many, other } => one.iter().chain(few).chain(many).copied().chain([*other]).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,10 +265,10 @@ fn catalogs_have_known_keys_and_matching_placeholders() {
|
||||
for (i, form) in forms(t).iter().enumerate() {
|
||||
let got = placeholders(form);
|
||||
// A "one" form may spell the count out ("una capa").
|
||||
let ok = if matches!(t, Text::Plural(Some(_), _)) && i == 0 { got.is_subset(&want) } else { got == want };
|
||||
let ok = if matches!(t, Text::Plural { one: Some(_), .. }) && i == 0 { got.is_subset(&want) } else { got == want };
|
||||
assert!(ok, "{}: {k:?} has placeholders {got:?}, English has {want:?}", lang.code);
|
||||
}
|
||||
assert_eq!(matches!(t, Text::Plural(..)), matches!(src, Text::Plural(..)), "{}: {k:?} plural or not as in English", lang.code);
|
||||
assert_eq!(matches!(t, Text::Plural { .. }), matches!(src, Text::Plural { .. }), "{}: {k:?} plural or not as in English", lang.code);
|
||||
}
|
||||
for (id, t) in lang.commands.iter().chain(lang.menu) {
|
||||
assert!(ids.contains(id), "{}: unknown command id {id:?}", lang.code);
|
||||
@@ -308,7 +308,14 @@ fn system_languages_pick_the_first_one_with_a_catalog() {
|
||||
let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code;
|
||||
assert_eq!(code("system", Some("fr-CA")), "fr");
|
||||
assert_eq!(code("system", Some("de_CH.UTF-8")), "de");
|
||||
assert_eq!(code("system", Some("cs_CZ.UTF-8")), "cs");
|
||||
assert_eq!(code("system", Some("cs-CZ,en-US")), "cs");
|
||||
assert_eq!(code("cs", None), "cs");
|
||||
assert_eq!(LANGS[resolve("cs", None)].name, "Čeština");
|
||||
assert!(LANGS[resolve("cs", None)].draft);
|
||||
assert_eq!(code("system", Some("pt-BR")), "pt-BR");
|
||||
assert_eq!(code("system", Some("tr_TR.UTF-8")), "tr");
|
||||
assert_eq!(code("system", Some("id_ID.UTF-8")), "id");
|
||||
// Portugal's Portuguese and a bare "pt" read the Brazilian catalog.
|
||||
assert_eq!(code("system", Some("pt-PT")), "pt-BR");
|
||||
assert_eq!(code("pt", None), "pt-BR");
|
||||
@@ -355,8 +362,10 @@ fn french_and_portuguese_count_zero_as_singular() {
|
||||
for code in ["fr", "pt-BR"] {
|
||||
assert!(one(code, 0) && one(code, 1) && !one(code, 2), "{code}");
|
||||
}
|
||||
for code in ["en", "es", "de"] {
|
||||
assert!(!one(code, 0) && one(code, 1) && !one(code, 2), "{code}");
|
||||
for code in ["en", "es", "de", "cs"] {
|
||||
for n in [0, 1, 2, 3, 4, 5, 21] {
|
||||
assert_eq!(one(code, n), n == 1, "{code}: {n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,7 +378,7 @@ fn japanese_korean_and_chinese_have_no_plural_forms() {
|
||||
for code in ["ja", "ko", "zh-Hans"] {
|
||||
let lang = LANGS.iter().position(|l| l.code == code).unwrap();
|
||||
assert!(!plural_one(lang, 0) && !plural_one(lang, 1) && !plural_one(lang, 2), "{code}");
|
||||
let ones: Vec<_> = LANGS[lang].ui.iter().filter(|(_, t)| matches!(t, Text::Plural(Some(_), _))).map(|(k, _)| k).collect();
|
||||
let ones: Vec<_> = LANGS[lang].ui.iter().filter(|(_, t)| matches!(t, Text::Plural { one: Some(_), .. })).map(|(k, _)| k).collect();
|
||||
assert!(ones.is_empty(), "{code}: plural entries give only `other`: {ones:?}");
|
||||
}
|
||||
}
|
||||
@@ -397,6 +406,50 @@ fn the_key_scanner_reads_rust_literals() {
|
||||
assert_eq!(literals(src), vec!["A \"b\"", "C {n}", "DE"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_language_tags_and_integer_plurals() {
|
||||
let ru = LANGS.iter().position(|l| l.code == "ru").unwrap();
|
||||
assert_eq!(LANGS[ru].name, "Русский");
|
||||
for tag in ["ru", "ru-RU", "ru_RU.UTF-8", "RU", "ru-BY", "ru-Cyrl-RU"] {
|
||||
assert_eq!(resolve(tag, None), ru, "{tag}");
|
||||
assert_eq!(resolve("system", Some(tag)), ru, "{tag}");
|
||||
}
|
||||
assert_eq!(resolve("system", Some("nl,ru-RU,en-US")), ru);
|
||||
for n in [1, 21, 31, 101, 1_001] {
|
||||
assert_eq!(plural("ru", n), Plural::One, "{n}");
|
||||
assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слой"));
|
||||
}
|
||||
for n in [2, 3, 4, 22, 23, 24, 102, 104, 1_002] {
|
||||
assert_eq!(plural("ru", n), Plural::Few, "{n}");
|
||||
assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слоя"));
|
||||
}
|
||||
for n in [0, 5, 10, 11, 12, 13, 14, 19, 20, 25, 100, 111, 112, 114, 1_000_000] {
|
||||
assert_eq!(plural("ru", n), Plural::Many, "{n}");
|
||||
assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слоёв"));
|
||||
}
|
||||
for (key, text) in LANGS[ru].ui {
|
||||
if let Text::Plural { one, few, many, .. } = text {
|
||||
assert!(one.is_some() && few.is_some() && many.is_some(), "{key}: missing Russian plural forms");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plural_fallback_uses_the_language_of_its_catalog() {
|
||||
let la = LANGS.iter().position(|l| l.code == "es-419").unwrap();
|
||||
assert_eq!(counted(la, "{n} layers", 1), "{n} capa");
|
||||
assert_eq!(counted(la, "{n} layers", 21), "{n} capas");
|
||||
// Unsupported optional categories use other; existing one/other languages are unchanged.
|
||||
for code in ["en", "es", "de"] {
|
||||
for n in [0, 2, 11, 21, 1_000_000] {
|
||||
assert_eq!(plural(code, n), Plural::Other, "{code}: {n}");
|
||||
}
|
||||
assert_eq!(plural(code, 1), Plural::One, "{code}");
|
||||
}
|
||||
assert_eq!(counted(0, "{n} layers", 21), "{n} layers");
|
||||
assert_eq!(counted(0, "unknown {n}", 21), "unknown {n}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_language_preference_takes_language_codes() {
|
||||
let mut s = figcraft_engine::Session::new();
|
||||
|
||||
@@ -212,6 +212,9 @@ fn a_finished_background_render_wakes_the_ui() {
|
||||
skip: vec![],
|
||||
solo: None,
|
||||
proof: false,
|
||||
uid: 0,
|
||||
zoom_preview: false,
|
||||
at: web_time::Instant::now(),
|
||||
});
|
||||
let start = Instant::now();
|
||||
while wakes.load(Ordering::SeqCst) == 0 {
|
||||
@@ -288,3 +291,72 @@ fn an_animated_gif_wakes_only_the_presenter_and_only_at_its_frames() {
|
||||
h.app.ui.present = None;
|
||||
h.assert_idle("the presenter closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_window_answers_requests_that_need_no_frame() {
|
||||
// #269: minimised or covered, the window draws no frames, but the app's logic still runs: it
|
||||
// answers edits, inspection and saves; what needs a frame waits for the window to show.
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-hidden-save-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("hidden.figcraft");
|
||||
let (mut h, _) = document();
|
||||
h.settle();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let request = |id: u64, method: &str, params: Value| ControlRequest {
|
||||
id: json!(id),
|
||||
method: method.into(),
|
||||
params,
|
||||
reply: tx.clone(),
|
||||
cancelled: Default::default(),
|
||||
};
|
||||
h.app.control_queue.push(request(1, "engine.execute", json!({"command": "file.save", "params": {"path": path.to_string_lossy()}})));
|
||||
h.app.control_queue.push(request(2, "ui.inspect", json!({})));
|
||||
let mut hidden = RawInput::default();
|
||||
hidden.viewports.insert(ViewportId::ROOT, egui::ViewportInfo { minimized: Some(true), ..Default::default() });
|
||||
let app = &mut h.app;
|
||||
let _ = h.ctx.run_logic(&hidden, |ctx| crate::control::drain_hidden(app, ctx));
|
||||
let saved = rx.try_recv().unwrap();
|
||||
assert_eq!(saved.id, json!(1));
|
||||
assert!(saved.result.is_ok() && path.exists(), "saved while hidden: {:?}", saved.result);
|
||||
assert!(rx.try_recv().is_err(), "the interface's state waits for a frame");
|
||||
// Shown again: the next frame answers it.
|
||||
h.frame(vec![]);
|
||||
assert_eq!(rx.try_recv().unwrap().id, json!(2));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_save_writes_a_changed_file_then_rests() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-auto-save-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("auto.figcraft").to_string_lossy().to_string();
|
||||
let (mut h, _) = document();
|
||||
h.run("file.save", json!({"path": path}));
|
||||
h.run("pref.autoSave", json!({"on": true}));
|
||||
h.assert_idle("a saved file");
|
||||
h.run("shape.rectangle", json!({"x": 500, "y": 300, "width": 40, "height": 40}));
|
||||
assert!(h.frame(vec![]) <= Duration::from_secs_f64(crate::AUTO_SAVE_SECS), "wakes up to save it");
|
||||
assert!(h.app.session.ds().unwrap().is_dirty(), "not saved at once");
|
||||
// Another change restarts the wait.
|
||||
h.time += crate::AUTO_SAVE_SECS - 0.5;
|
||||
h.run("shape.rectangle", json!({"x": 600, "y": 300, "width": 40, "height": 40}));
|
||||
h.frame(vec![]);
|
||||
h.time += 1.0;
|
||||
h.frame(vec![]);
|
||||
assert!(h.app.ui.auto_saving.is_empty() && h.app.session.ds().unwrap().is_dirty(), "waits for a pause in the changes");
|
||||
h.time += crate::AUTO_SAVE_SECS;
|
||||
// The file is encoded on a thread of its own, then written.
|
||||
let start = Instant::now();
|
||||
while h.app.session.ds().unwrap().is_dirty() {
|
||||
assert!(start.elapsed() < Duration::from_secs(20), "saved");
|
||||
let delay = h.frame(vec![]);
|
||||
assert!(h.app.ui.auto_saving.is_empty() || delay <= Duration::from_millis(100), "looks again for the file being encoded");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
let mut t = Session::empty();
|
||||
t.execute("file.open", &json!({"path": path})).unwrap();
|
||||
let ds = t.ds().unwrap();
|
||||
assert!(ds.doc.page_content_bounds(ds.page).unwrap().x1 >= 640.0, "the new rectangles are in the file");
|
||||
h.assert_idle("an auto-saved file");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -111,7 +111,8 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) {
|
||||
ui.painter().rect_filled(ir, CornerRadius::same(6), t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(ir.center(), vec2(16.0, 16.0)), icon, if active { t.accent_text } else { t.icon });
|
||||
ui.painter().text(pos2(r.center().x, y + 38.0), Align2::CENTER_CENTER, label, regular(10.0), t.text);
|
||||
let shown = crate::widgets::elide_font(ui, label, (r.width() - 6.0).max(0.0), regular(10.0));
|
||||
ui.painter().text(pos2(r.center().x, y + 38.0), Align2::CENTER_CENTER, shown, regular(10.0), t.text);
|
||||
if resp.clicked() {
|
||||
match tab {
|
||||
Some(tb) => {
|
||||
@@ -215,66 +216,77 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
|
||||
let mut move_to: Option<(NodeId, String)> = None;
|
||||
// Pages are dragged up and down the list to reorder them.
|
||||
let mut reorder = crate::list_drag::ListDrag::in_order(ui, "pages");
|
||||
for (k, (id, name, divider)) in pages.iter().cloned().enumerate() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag());
|
||||
reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() });
|
||||
a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur);
|
||||
let r = r.shrink2(vec2(8.0, 0.0));
|
||||
a11y::ring_around(ui, r, &resp);
|
||||
let renaming = app.ui.rename_page.as_ref().is_some_and(|(i, _)| *i == id);
|
||||
if divider && !renaming {
|
||||
// A divider is a line between pages: it opens nothing and takes no layers.
|
||||
ui.painter().line_segment([pos2(r.min.x + 10.0, r.center().y), pos2(r.max.x - 10.0, r.center().y)], Stroke::new(1.0, t.border));
|
||||
} else if resp.hovered() || id == cur {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
if dragging.is_some()
|
||||
&& id != cur
|
||||
&& !divider
|
||||
// The gap between rows counts too.
|
||||
&& pointer.is_some_and(|p| resp.interact_rect.expand2(vec2(0.0, 3.0)).contains(p))
|
||||
{
|
||||
ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside);
|
||||
if ui.input(|i| i.pointer.any_released()) {
|
||||
move_to = Some((id, name.clone()));
|
||||
// A long list scrolls in its own area, so the layers keep their room below it.
|
||||
let max_h = (ui.available_height() * 0.4).max(3.0 * ROW);
|
||||
let mut moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| {
|
||||
for (k, (id, name, divider)) in pages.iter().cloned().enumerate() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag());
|
||||
reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() });
|
||||
a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur);
|
||||
let r = r.shrink2(vec2(8.0, 0.0));
|
||||
a11y::ring_around(ui, r, &resp);
|
||||
let renaming = app.ui.rename_page.as_ref().is_some_and(|(i, _)| *i == id);
|
||||
if divider && !renaming {
|
||||
// A divider is a line between pages: it opens nothing and takes no layers.
|
||||
ui.painter().line_segment([pos2(r.min.x + 10.0, r.center().y), pos2(r.max.x - 10.0, r.center().y)], Stroke::new(1.0, t.border));
|
||||
} else if resp.hovered() || id == cur {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
}
|
||||
if renaming {
|
||||
let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0));
|
||||
let start = ui.data_mut(|d| d.remove_temp::<bool>(page_rename_start_id())).unwrap_or(false);
|
||||
let font = if id == cur { semibold(11.0) } else { regular(11.0) };
|
||||
if let (Some(new), _) = crate::widgets::rename_field(ui, Id::new(("pagename", id)), &name, tr, font, start) {
|
||||
rename_done = Some((id, new));
|
||||
if dragging.is_some()
|
||||
&& id != cur
|
||||
&& !divider
|
||||
// The gap between rows counts too.
|
||||
&& pointer.is_some_and(|p| resp.interact_rect.expand2(vec2(0.0, 3.0)).contains(p))
|
||||
{
|
||||
ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside);
|
||||
if ui.input(|i| i.pointer.any_released()) {
|
||||
move_to = Some((id, name.clone()));
|
||||
}
|
||||
}
|
||||
} else if !divider {
|
||||
let font = if id == cur { semibold(11.0) } else { regular(11.0) };
|
||||
crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 10.0, r.center().y), &name, font, t.text, r.width() - 14.0);
|
||||
}
|
||||
if resp.clicked() && !divider {
|
||||
select = Some(id);
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
app.ui.rename_page = Some((id, name.clone()));
|
||||
// The field takes the focus next frame, once it's drawn: focusing it now would put a
|
||||
// node that isn't there yet in the screen reader tree, which AccessKit aborts on.
|
||||
ui.data_mut(|d| d.insert_temp(page_rename_start_id(), true));
|
||||
}
|
||||
crate::right::context_popup(&resp).show(|ui| {
|
||||
if ui.button(tr("Rename page")).clicked() {
|
||||
if renaming {
|
||||
let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0));
|
||||
let start = ui.data_mut(|d| d.remove_temp::<bool>(page_rename_start_id())).unwrap_or(false);
|
||||
let font = if id == cur { semibold(11.0) } else { regular(11.0) };
|
||||
if let (Some(new), _) = crate::widgets::rename_field(ui, Id::new(("pagename", id)), &name, tr, font, start) {
|
||||
rename_done = Some((id, new));
|
||||
}
|
||||
} else if !divider {
|
||||
let font = if id == cur { semibold(11.0) } else { regular(11.0) };
|
||||
crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 10.0, r.center().y), &name, font, t.text, r.width() - 14.0);
|
||||
}
|
||||
if resp.clicked() && !divider {
|
||||
select = Some(id);
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
app.ui.rename_page = Some((id, name.clone()));
|
||||
ui.close();
|
||||
// The field takes the focus next frame, once it's drawn: focusing it now would put a
|
||||
// node that isn't there yet in the screen reader tree, which AccessKit aborts on.
|
||||
ui.data_mut(|d| d.insert_temp(page_rename_start_id(), true));
|
||||
}
|
||||
if ui.button(tr("Duplicate page")).clicked() {
|
||||
app.run("page.duplicate", json!({"id": id}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tr("Delete page")).clicked() {
|
||||
app.run("page.delete", json!({"id": id}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
crate::right::context_popup(&resp).show(|ui| {
|
||||
if ui.button(tr("Rename page")).clicked() {
|
||||
app.ui.rename_page = Some((id, name.clone()));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tr("Duplicate page")).clicked() {
|
||||
app.run("page.duplicate", json!({"id": id}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tr("Delete page")).clicked() {
|
||||
app.run("page.delete", json!({"id": id}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
reorder.finish(ui)
|
||||
});
|
||||
// A drop outside the list's visible area (and the row gap at its edges) isn't on a page row,
|
||||
// even one scrolled out of view.
|
||||
if !ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| moved.inner_rect.expand2(vec2(0.0, 3.0)).contains(p)) {
|
||||
moved.inner = None;
|
||||
move_to = None;
|
||||
}
|
||||
if let Some((from, to)) = reorder.finish(ui)
|
||||
if let Some((from, to)) = moved.inner
|
||||
&& let Some((id, ..)) = pages.get(from)
|
||||
{
|
||||
app.run("page.move", json!({"id": id, "index": to}));
|
||||
@@ -684,7 +696,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(16.0);
|
||||
let hint = if filter.is_empty() { tr("Add a frame (F) or a shape (R) to get started") } else { tr("No matching layers") };
|
||||
crate::widgets::label(ui, hint, t.text_secondary);
|
||||
ui.add(egui::Label::new(egui::RichText::new(hint).font(regular(11.0)).color(t.text_secondary)).wrap());
|
||||
});
|
||||
}
|
||||
// Dragging near the list's top or bottom edge scrolls it, faster closer to the edge.
|
||||
|
||||
@@ -227,6 +227,70 @@ fn pages_drag_to_reorder_and_dividers_open_nothing() {
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
}
|
||||
|
||||
/// The page opened by a click on each spot from just under the pages header down to `to`.
|
||||
fn pages_down_to(u: &mut Ui, to: f32) -> Vec<NodeId> {
|
||||
let mut opened = Vec::new();
|
||||
for y in (190..to as i32).step_by(8) {
|
||||
click(u, Pos2::new(150.0, y as f32), Modifiers::NONE);
|
||||
opened.push(u.app.session.page().unwrap());
|
||||
}
|
||||
opened
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_pages_list_scrolls_and_leaves_room_for_the_layers() {
|
||||
let mut s = Session::new();
|
||||
let first = s.page().unwrap();
|
||||
let rect = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
for _ in 0..59 {
|
||||
s.execute("page.add", &json!({})).unwrap();
|
||||
}
|
||||
let pages = s.doc().unwrap().pages.clone();
|
||||
let last = *pages.last().unwrap();
|
||||
s.execute("page.select", &json!({"id": first})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
// The layers list stays on screen under 60 pages.
|
||||
let layer = u.row_at(rect);
|
||||
// The wheel over the pages list scrolls it to its last page.
|
||||
let wheel = Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, -400.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
};
|
||||
for _ in 0..10 {
|
||||
u.frame(vec![Event::PointerMoved(Pos2::new(150.0, 200.0)), wheel.clone()]);
|
||||
}
|
||||
for _ in 0..30 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
let opened = pages_down_to(&mut u, layer.y - 60.0);
|
||||
assert!(opened.contains(&last), "the last page can be reached");
|
||||
let top = pages.iter().position(|p| Some(p) == opened.first()).unwrap();
|
||||
assert!(top > 30, "the list scrolled past its first pages (top row: page {top})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_dropped_below_the_pages_list_stays_where_it_was() {
|
||||
let mut s = Session::new();
|
||||
let first = s.page().unwrap();
|
||||
let rect = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let second = id(s.execute("page.add", &json!({})).unwrap());
|
||||
for _ in 0..58 {
|
||||
s.execute("page.add", &json!({})).unwrap();
|
||||
}
|
||||
let pages = s.doc().unwrap().pages.clone();
|
||||
s.execute("page.select", &json!({"id": first})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
let (row, layer) = (page_row(&mut u, second, first), u.row_at(rect));
|
||||
// Released over the layers, where pages scrolled out of view would be: no page row is there.
|
||||
u.drag_to(row, layer);
|
||||
u.button(layer, false);
|
||||
assert_eq!(u.app.session.doc().unwrap().pages, pages, "the pages keep their order");
|
||||
}
|
||||
|
||||
/// A double click at `p`, with the screen reader tree checked on every frame: the platform adapter
|
||||
/// (UI Automation on Windows) aborts the app when the focused node isn't in the tree.
|
||||
fn double_click_checking_focus(u: &mut Ui, p: Pos2) {
|
||||
|
||||
+261
-20
@@ -69,6 +69,8 @@ mod theme_startup_tests;
|
||||
#[cfg(test)]
|
||||
mod a11y_tests;
|
||||
#[cfg(test)]
|
||||
mod async_dialog_tests;
|
||||
#[cfg(test)]
|
||||
mod branch_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod canvas_drag_tests;
|
||||
@@ -131,6 +133,8 @@ mod panel_reorder_tests;
|
||||
#[cfg(test)]
|
||||
mod panel_width_tests;
|
||||
#[cfg(test)]
|
||||
mod pen_mode_tests;
|
||||
#[cfg(test)]
|
||||
mod perf_tests;
|
||||
#[cfg(test)]
|
||||
mod plugin_panel_tests;
|
||||
@@ -159,8 +163,12 @@ mod text_enter_tests;
|
||||
#[cfg(test)]
|
||||
mod titlebar_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_export_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_rename_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_panel_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_tail_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod web_clipboard_tests;
|
||||
@@ -170,7 +178,11 @@ mod windows_qa_tests;
|
||||
mod windows_tests;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context as TaskContext, Poll, Wake, Waker};
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
@@ -180,15 +192,24 @@ pub use control::{ControlReply, ControlRequest};
|
||||
|
||||
/// Platform services the app crate provides (file dialogs, clipboard, URLs).
|
||||
pub trait Services {
|
||||
fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> Option<PathBuf> {
|
||||
None
|
||||
fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture {
|
||||
Box::pin(std::future::ready(None))
|
||||
}
|
||||
fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> Option<PathBuf> {
|
||||
None
|
||||
fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> DialogFuture {
|
||||
Box::pin(std::future::ready(None))
|
||||
}
|
||||
/// The platform saves files by handing them over as downloads (the browser), not through
|
||||
/// save dialogs.
|
||||
fn downloads(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Hand `bytes` over as a download named `name`; false when nothing was delivered.
|
||||
fn download(&mut self, _name: &str, _bytes: &[u8]) -> bool {
|
||||
false
|
||||
}
|
||||
/// A folder to write several files into.
|
||||
fn pick_folder(&mut self, _title: &str) -> Option<PathBuf> {
|
||||
None
|
||||
fn pick_folder(&mut self, _title: &str) -> DialogFuture {
|
||||
Box::pin(std::future::ready(None))
|
||||
}
|
||||
fn clipboard_set_text(&mut self, _s: &str) {}
|
||||
fn clipboard_get_text(&mut self) -> Option<String> {
|
||||
@@ -224,6 +245,34 @@ pub trait Services {
|
||||
}
|
||||
}
|
||||
|
||||
pub type DialogFuture = Pin<Box<dyn Future<Output = Option<PathBuf>>>>;
|
||||
type DialogDone = Box<dyn FnOnce(&mut FigApp, PathBuf)>;
|
||||
|
||||
/// A native dialog in flight. The app keeps working while it is open, so its result goes to the
|
||||
/// file and window it was opened for, not to whichever is in front when it closes.
|
||||
struct PendingDialog {
|
||||
future: DialogFuture,
|
||||
/// The path picked, kept until the window that holds `doc` draws.
|
||||
path: Option<PathBuf>,
|
||||
done: DialogDone,
|
||||
/// The file it acts on (`None`: none, such as Open).
|
||||
doc: Option<u64>,
|
||||
/// The window it was opened from.
|
||||
window: u64,
|
||||
}
|
||||
|
||||
/// Wakes the main window, which hands the result on to the window it belongs to.
|
||||
struct RepaintWaker(egui::Context);
|
||||
impl Wake for RepaintWaker {
|
||||
fn wake(self: Arc<Self>) {
|
||||
self.0.request_repaint_of(egui::ViewportId::ROOT);
|
||||
}
|
||||
|
||||
fn wake_by_ref(self: &Arc<Self>) {
|
||||
self.0.request_repaint_of(egui::ViewportId::ROOT);
|
||||
}
|
||||
}
|
||||
|
||||
/// No-op services (tests, headless screenshots).
|
||||
pub struct NoServices;
|
||||
impl Services for NoServices {}
|
||||
@@ -273,6 +322,14 @@ pub enum TitleBar {
|
||||
/// How often open documents get an autosave checkpoint (seconds).
|
||||
pub const AUTOSAVE_SECS: f64 = 30.0 * 60.0;
|
||||
|
||||
/// With auto-save on: how long after a file last changed it is saved, and how long a failed save
|
||||
/// waits before trying again (seconds).
|
||||
pub const AUTO_SAVE_SECS: f64 = 3.0;
|
||||
const AUTO_SAVE_RETRY_SECS: f64 = 60.0;
|
||||
|
||||
/// A file being encoded for auto-save: its snapshot and, once done, its bytes.
|
||||
pub type AutoSaveEncoding = std::thread::JoinHandle<(figcraft_engine::cmd::file::FileSnapshot, figcraft_engine::Result<Vec<u8>>)>;
|
||||
|
||||
/// How long a toast shows (seconds).
|
||||
const TOAST_SECS: f64 = 3.0;
|
||||
|
||||
@@ -312,6 +369,8 @@ pub struct UiState {
|
||||
pub left_width: f32,
|
||||
pub right_width: f32,
|
||||
pub right_tab: RightTab,
|
||||
/// The tab of the previous frame, to see the right panel change mode.
|
||||
pub last_right_tab: RightTab,
|
||||
pub left_tab: LeftTab,
|
||||
pub pages_open: bool,
|
||||
pub expanded: BTreeSet<NodeId>,
|
||||
@@ -408,6 +467,11 @@ pub struct UiState {
|
||||
pub last_copy: Option<String>,
|
||||
/// When (UI clock, seconds) open documents next get an autosave checkpoint.
|
||||
pub next_autosave: f64,
|
||||
/// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved, and
|
||||
/// the revisions of those files it waits on (a change restarts the wait).
|
||||
pub auto_save_at: Option<(f64, u64)>,
|
||||
/// Files being encoded for auto-save on threads of their own (tab `uid`).
|
||||
pub auto_saving: Vec<(u64, AutoSaveEncoding)>,
|
||||
/// The Assets panel shows the libraries view (publish this file, enabled libraries).
|
||||
pub libraries: bool,
|
||||
/// Keyboard navigation: the region with the keyboard, keyboard mode (F6, Tab).
|
||||
@@ -432,6 +496,7 @@ impl Default for UiState {
|
||||
left_width: PANEL_DEFAULT,
|
||||
right_width: PANEL_DEFAULT,
|
||||
right_tab: RightTab::Design,
|
||||
last_right_tab: RightTab::Design,
|
||||
left_tab: LeftTab::File,
|
||||
pages_open: true,
|
||||
expanded: BTreeSet::new(),
|
||||
@@ -492,6 +557,8 @@ impl Default for UiState {
|
||||
|
||||
last_copy: None,
|
||||
next_autosave: 0.0,
|
||||
auto_save_at: None,
|
||||
auto_saving: Vec::new(),
|
||||
libraries: false,
|
||||
nav: a11y::Nav::default(),
|
||||
plugins_window: false,
|
||||
@@ -535,6 +602,10 @@ pub struct FigApp {
|
||||
window_requests: Vec<figcraft_engine::UiRequest>,
|
||||
/// Commands waiting for a server ([`FigApp::run_background`]) and what to do with their results.
|
||||
waiting: Vec<(String, Done)>,
|
||||
/// Native dialogs complete asynchronously so their modal event loop never re-enters winit.
|
||||
pending_dialog: Option<PendingDialog>,
|
||||
/// The latest UI context schedules the first poll and wakes the app when a dialog completes.
|
||||
ui_context: Option<egui::Context>,
|
||||
}
|
||||
|
||||
/// What to do with the result of a command run in the background.
|
||||
@@ -562,6 +633,8 @@ impl FigApp {
|
||||
windows: windows::Windows::default(),
|
||||
window_requests: Vec::new(),
|
||||
waiting: Vec::new(),
|
||||
pending_dialog: None,
|
||||
ui_context: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,6 +715,19 @@ impl FigApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Leaving Design ends a pen path as Escape does and goes back to Move: Prototype and Inspect
|
||||
/// don't draw (#307). Checked as the frame starts (the control channel) and after the panels
|
||||
/// and shortcuts (the mode switcher, the tabs, Shift+E/Shift+D), before the canvas sees more.
|
||||
fn end_pen_outside_design(&mut self) {
|
||||
if self.ui.right_tab == self.ui.last_right_tab {
|
||||
return;
|
||||
}
|
||||
self.ui.last_right_tab = self.ui.right_tab;
|
||||
if self.ui.right_tab != RightTab::Design && self.session.tool.tool == figcraft_engine::tools::ToolId::Pen {
|
||||
self.run("tool.set", json!({"tool": "move"}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a frame, after the panels: a preview nothing asked for this frame ends.
|
||||
fn end_unwanted_text_preview(&mut self, ctx: &egui::Context) {
|
||||
if !std::mem::take(&mut self.ui.text_preview_wanted) {
|
||||
@@ -822,12 +908,12 @@ impl FigApp {
|
||||
|
||||
pub fn save_as_dialog(&mut self) {
|
||||
let name = self.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into());
|
||||
if let Some(p) = self.services.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft"]) {
|
||||
self.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft"], |app, p| {
|
||||
let ps = p.to_string_lossy().to_string();
|
||||
if self.run("file.saveAs", json!({"path": ps})).is_some() {
|
||||
self.toast(i18n::trf("Saved {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())]));
|
||||
if app.run("file.saveAs", json!({"path": ps})).is_some() {
|
||||
app.toast(i18n::trf("Saved {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())]));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn save(&mut self) {
|
||||
@@ -843,14 +929,18 @@ impl FigApp {
|
||||
}
|
||||
|
||||
pub fn open_dialog(&mut self) {
|
||||
if let Some(p) =
|
||||
self.services.pick_open(i18n::tr("Open"), &["figcraft", "fig", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"])
|
||||
{
|
||||
let ps = p.to_string_lossy().to_string();
|
||||
if self.run("file.open", json!({"path": ps})).is_some() {
|
||||
self.ui.home = false;
|
||||
}
|
||||
}
|
||||
let exts = ["figcraft", "fig", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"];
|
||||
// Opening needs no file in front: it goes ahead even if that file closed meanwhile.
|
||||
self.start_dialog(
|
||||
|services| services.pick_open(i18n::tr("Open"), &exts),
|
||||
false,
|
||||
|app, p| {
|
||||
let ps = p.to_string_lossy().to_string();
|
||||
if app.run("file.open", json!({"path": ps})).is_some() {
|
||||
app.ui.home = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn import_dialog(&mut self, place: bool) {
|
||||
@@ -859,9 +949,78 @@ impl FigApp {
|
||||
} else {
|
||||
&["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig"]
|
||||
};
|
||||
if let Some(p) = self.services.pick_open(if place { i18n::tr("Place image") } else { i18n::tr("Import") }, exts) {
|
||||
let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") };
|
||||
self.pick_open(title, exts, move |app, p| {
|
||||
let ps = p.to_string_lossy().to_string();
|
||||
self.run("file.import", json!({"path": ps}));
|
||||
app.run("file.import", json!({"path": ps}));
|
||||
});
|
||||
}
|
||||
|
||||
/// Pick a file to open. `done` runs with the file in front now back in front (and not at all
|
||||
/// if that file closed meanwhile).
|
||||
pub fn pick_open(&mut self, title: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) {
|
||||
self.start_dialog(|services| services.pick_open(title, exts), true, done);
|
||||
}
|
||||
|
||||
/// Pick where to save; `done` runs as [`FigApp::pick_open`]'s does.
|
||||
pub fn pick_save(&mut self, title: &str, name: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) {
|
||||
self.start_dialog(|services| services.pick_save(title, name, exts), true, done);
|
||||
}
|
||||
|
||||
/// Pick a folder; `done` runs as [`FigApp::pick_open`]'s does.
|
||||
pub fn pick_folder(&mut self, title: &str, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) {
|
||||
self.start_dialog(|services| services.pick_folder(title), true, done);
|
||||
}
|
||||
|
||||
/// Open a native dialog from the window drawing now, for the file in front (`pin`).
|
||||
fn start_dialog(&mut self, start: impl FnOnce(&mut dyn Services) -> DialogFuture, pin: bool, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) {
|
||||
if self.pending_dialog.is_some() {
|
||||
return;
|
||||
}
|
||||
let doc = if pin { self.session.ds().ok().filter(|d| self.shows(d.uid)).map(|d| d.uid) } else { None };
|
||||
let future = start(self.services.as_mut());
|
||||
self.pending_dialog = Some(PendingDialog { future, path: None, done: Box::new(done), doc, window: self.windows.current });
|
||||
if let Some(ctx) = &self.ui_context {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// Once the dialog has a path, its continuation runs in the window that holds its file, with
|
||||
/// that file in front; then the tab in front before comes back (unless the dialog opened a file).
|
||||
fn poll_dialog(&mut self, ctx: &egui::Context) {
|
||||
let Some(d) = &mut self.pending_dialog else { return };
|
||||
if d.path.is_none() {
|
||||
let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone())));
|
||||
let Poll::Ready(path) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return };
|
||||
d.path = path;
|
||||
}
|
||||
let (doc, origin, picked) = (d.doc, d.window, d.path.is_some());
|
||||
let index = doc.map(|u| self.session.docs.iter().position(|ds| ds.uid == u));
|
||||
// Cancelled, or its file closed meanwhile.
|
||||
if !picked || index == Some(None) {
|
||||
self.pending_dialog = None;
|
||||
return;
|
||||
}
|
||||
let window = match doc {
|
||||
Some(u) => self.windows.of(u),
|
||||
None if self.windows.exists(origin) => origin,
|
||||
None => 0,
|
||||
};
|
||||
if window != self.windows.current {
|
||||
ctx.request_repaint_of(windows::viewport(window));
|
||||
return;
|
||||
}
|
||||
let Some(PendingDialog { path: Some(path), done, .. }) = self.pending_dialog.take() else { return };
|
||||
let before = self.session.ds().ok().map(|d| d.uid);
|
||||
if let Some(Some(i)) = index {
|
||||
self.session.active = i;
|
||||
}
|
||||
done(self, path);
|
||||
if doc.is_some()
|
||||
&& self.session.ds().ok().map(|d| d.uid) == doc
|
||||
&& let Some(i) = before.and_then(|u| self.session.docs.iter().position(|d| d.uid == u))
|
||||
{
|
||||
self.session.active = i;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -955,15 +1114,90 @@ impl FigApp {
|
||||
repaint_in(ctx, AUTOSAVE_SECS);
|
||||
}
|
||||
|
||||
/// With auto-save on, files that have a path are saved [`AUTO_SAVE_SECS`] after they last
|
||||
/// changed (not during a gesture, a live preview or while a version is shown). The file is
|
||||
/// encoded on a thread of its own, so a big one doesn't hold up the window, and written here.
|
||||
fn auto_save_tick(&mut self, ctx: &egui::Context) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
let mut failed = self.auto_save_done();
|
||||
// The browser has no files to save to.
|
||||
let on = !cfg!(target_arch = "wasm32") && self.session.prefs.auto_save;
|
||||
let saving = &self.ui.auto_saving;
|
||||
let due = |d: &&figcraft_engine::DocState| {
|
||||
d.path.is_some() && d.branch_of.is_none() && d.preview.is_none() && d.is_dirty() && !saving.iter().any(|(uid, _)| *uid == d.uid)
|
||||
};
|
||||
let changes = if on { self.session.docs.iter().filter(due).map(|d| d.revision).reduce(u64::wrapping_add) } else { None };
|
||||
match changes {
|
||||
None => self.ui.auto_save_at = None,
|
||||
Some(changes) => {
|
||||
let at = match self.ui.auto_save_at {
|
||||
Some((at, c)) if c == changes && failed.is_none() => at,
|
||||
_ => now + if failed.is_some() { AUTO_SAVE_RETRY_SECS } else { AUTO_SAVE_SECS },
|
||||
};
|
||||
self.ui.auto_save_at = Some((at, changes));
|
||||
if now < at || self.session.docs.iter().any(|d| d.interaction.is_some() || d.text_preview.is_some()) {
|
||||
// Due but busy: look again in a second.
|
||||
repaint_in(ctx, if now < at { at - now } else { 1.0 });
|
||||
} else {
|
||||
self.ui.auto_save_at = None;
|
||||
let files: Vec<_> =
|
||||
self.session.docs.iter().filter(due).map(|d| (d.uid, figcraft_engine::cmd::file::FileSnapshot::of(d))).collect();
|
||||
for (uid, file) in files {
|
||||
let encode = move || {
|
||||
let bytes = file.encode();
|
||||
(file, bytes)
|
||||
};
|
||||
match std::thread::Builder::new().name("figcraft-save".into()).spawn(encode) {
|
||||
Ok(h) => self.ui.auto_saving.push((uid, h)),
|
||||
Err(e) => {
|
||||
failed.get_or_insert(e.to_string());
|
||||
self.ui.auto_save_at = Some((now + AUTO_SAVE_RETRY_SECS, changes));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.ui.auto_saving.is_empty() {
|
||||
// Look again shortly for the files being encoded.
|
||||
repaint_in(ctx, 0.1);
|
||||
}
|
||||
if let Some(e) = failed {
|
||||
self.toast(i18n::trf("Couldn't save automatically: {error}", &[("error", &e)]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the files whose auto-save encoding finished; the first error, if one failed.
|
||||
fn auto_save_done(&mut self) -> Option<String> {
|
||||
if self.ui.auto_saving.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut failed = None;
|
||||
let done: Vec<_> = self.ui.auto_saving.extract_if(.., |(_, h)| h.is_finished()).collect();
|
||||
for (_, h) in done {
|
||||
let saved = match h.join() {
|
||||
Ok((snapshot, Ok(bytes))) => snapshot.save(&mut self.session, &bytes).map(|_| ()).map_err(|e| e.to_string()),
|
||||
Ok((_, Err(e))) => Err(e.to_string()),
|
||||
Err(_) => Err("the file couldn't be encoded".to_string()),
|
||||
};
|
||||
if let Err(e) = saved {
|
||||
failed.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
failed
|
||||
}
|
||||
|
||||
/// Draw the whole UI: the main window (the other windows draw themselves).
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let t0 = std::time::Instant::now();
|
||||
let ctx = ui.ctx().clone();
|
||||
self.ui_context = Some(ctx.clone());
|
||||
self.frame_stats.record(&ctx);
|
||||
let first = !self.fonts_installed;
|
||||
self.logic(&ctx);
|
||||
self.autosave_tick(&ctx);
|
||||
self.auto_save_tick(&ctx);
|
||||
if first {
|
||||
// New fonts apply from the next frame; draw nothing until then.
|
||||
ctx.request_repaint();
|
||||
@@ -1003,6 +1237,8 @@ impl FigApp {
|
||||
// even if it lets go during the frame (Enter commits it), or as it began (egui drops the
|
||||
// focus on Escape before the frame, which cancels the field's edit and nothing else).
|
||||
let typing = std::mem::take(&mut self.field_focus) || a11y::field_focused(&ctx);
|
||||
// A dialog that closed acts first, in the window it was opened from.
|
||||
self.poll_dialog(&ctx);
|
||||
// Control requests run first so their results reflect this frame.
|
||||
control::drain(self, &ctx);
|
||||
windows::requests(self, &ctx);
|
||||
@@ -1028,6 +1264,7 @@ impl FigApp {
|
||||
}
|
||||
}
|
||||
self.session.tool.dev_mode = dev;
|
||||
self.end_pen_outside_design();
|
||||
if self.ui.present.is_some() {
|
||||
present::show(self, ui);
|
||||
// What went wrong while presenting (a widget's plugin failing) shows over it.
|
||||
@@ -1130,6 +1367,7 @@ impl FigApp {
|
||||
variable_ui::picker(self, &ctx);
|
||||
style_picker::show(self, &ctx);
|
||||
self.end_unwanted_text_preview(&ctx);
|
||||
self.end_pen_outside_design();
|
||||
dialogs::show(self, &ctx);
|
||||
self.toast_ui(&ctx);
|
||||
// A rename started after the layers panel drew (Cmd/Ctrl+R, a double-click) opens its field
|
||||
@@ -1225,6 +1463,9 @@ impl FigApp {
|
||||
figcraft_render::Work::Partial { pixels } => json!({"kind": "partial", "pixels": pixels}),
|
||||
figcraft_render::Work::Full => json!({"kind": "full"}),
|
||||
}),
|
||||
"quality": if self.canvas.tex_coarse { "preview" } else { "refined" },
|
||||
"previewLatencyMs": self.canvas.preview_latency_ms,
|
||||
"refineLatencyMs": self.canvas.refine_latency_ms,
|
||||
},
|
||||
"live": live::inspect(self),
|
||||
"window": self.windows.current,
|
||||
|
||||
@@ -444,7 +444,8 @@ fn follow_frame(painter: &egui::Painter, canvas: Rect, name: &str, fill: Color32
|
||||
}
|
||||
|
||||
/// You and the others at the top of the right panel, within `area`: click someone to follow
|
||||
/// them (again to stop); the chevron lists everyone.
|
||||
/// them (again to stop); right-click them to remove them from the session (when you host it or
|
||||
/// own the file); the chevron lists everyone.
|
||||
pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
const R: f32 = 12.0;
|
||||
@@ -459,6 +460,8 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) {
|
||||
let peers: Vec<(u32, String, Color32)> = live.peers.values().map(|p| (p.client_id, p.name.clone(), color(&p.color))).collect();
|
||||
let follow = live.follow;
|
||||
let (in_session, spotlighting) = (live.status == Status::Live, live.spotlighting);
|
||||
let can_remove = figcraft_engine::live::can_remove_people(&app.session);
|
||||
let mut remove = None;
|
||||
// Room for the avatars after yours and the chevron.
|
||||
let fit = (((area.width() - 2.0 * R - 18.0) / STEP).floor() as usize).max(1);
|
||||
let (show, more) = if peers.len() > fit { (fit - 1, peers.len() - (fit - 1)) } else { (peers.len(), 0) };
|
||||
@@ -496,6 +499,14 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) {
|
||||
if resp.clicked() {
|
||||
clicked = Some(*id);
|
||||
}
|
||||
if can_remove {
|
||||
crate::right::context_popup(&resp).show(|ui| {
|
||||
if menu_item(ui, &trf("Remove {name} from session", &[("name", name)]), "") {
|
||||
remove = Some(*id);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if more > 0 {
|
||||
x += STEP;
|
||||
@@ -539,6 +550,10 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) {
|
||||
if spotlight {
|
||||
app.run("collab.spotlight", json!({"on": !spotlighting}));
|
||||
}
|
||||
// A server is asked without the window waiting for it.
|
||||
if let Some(id) = remove {
|
||||
app.run_background(&ui.ctx().clone(), "collab.removePerson", json!({"client": id}), Box::new(|_, _, _| {}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The Share button's popover: the live session (start, invite link, stop or leave) and export.
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Leaving Design with a pen path open (#307): the path ends as Escape ends it, the tool goes back
|
||||
//! to Move, and the canvas of Prototype or Inspect takes no more pen points. Driven by the panel
|
||||
//! keys and by the toolbar's mode switcher.
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Event, Key, Modifiers, Pos2, pos2};
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::tools::ToolId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::RightTab;
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
fn press(u: &mut Ui, key: Key, modifiers: Modifiers) {
|
||||
u.frame(vec![
|
||||
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
|
||||
Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
|
||||
]);
|
||||
}
|
||||
|
||||
fn click(u: &mut Ui, p: Pos2) {
|
||||
u.button(p, true);
|
||||
u.button(p, false);
|
||||
u.frame(vec![]);
|
||||
}
|
||||
|
||||
fn layers(u: &Ui) -> usize {
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
doc.node(u.app.session.page().unwrap()).unwrap().children.len()
|
||||
}
|
||||
|
||||
/// The middle of the toolbar's mode button named `label`, found in the AccessKit tree.
|
||||
fn mode_button(u: &mut Ui, label: &str) -> Pos2 {
|
||||
u.ctx.enable_accesskit();
|
||||
u.frame(vec![]);
|
||||
let out = u.frame_output(vec![]);
|
||||
let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;
|
||||
let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some(label)).expect(label);
|
||||
let b = n.bounds().expect("bounds");
|
||||
pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)
|
||||
}
|
||||
|
||||
/// A pen path with two anchors still open on an empty page.
|
||||
fn drawing() -> Ui {
|
||||
let mut u = Ui::new(Session::new());
|
||||
u.app.run("tool.set", json!({"tool": "pen"}));
|
||||
click(&mut u, pos2(600.0, 400.0));
|
||||
click(&mut u, pos2(700.0, 450.0));
|
||||
assert_eq!(u.app.session.tool.pen.as_ref().map(|p| p.points.len()), Some(2), "the path is open");
|
||||
u
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_mode_ends_the_pen_path_as_escape_does() {
|
||||
let mut esc = drawing();
|
||||
press(&mut esc, Key::Escape, Modifiers::NONE);
|
||||
assert!(esc.app.session.tool.pen.is_none());
|
||||
let finished = layers(&esc);
|
||||
assert_eq!(finished, 1, "Escape keeps the two-anchor path");
|
||||
|
||||
// Shift+E is Prototype, Shift+D is Inspect.
|
||||
for key in [Key::E, Key::D] {
|
||||
let mut u = drawing();
|
||||
press(&mut u, key, Modifiers::SHIFT);
|
||||
assert_ne!(u.app.ui.right_tab, RightTab::Design, "{key:?}");
|
||||
assert!(u.app.session.tool.pen.is_none(), "{key:?}: the path is finished");
|
||||
assert_eq!(u.app.session.tool.tool, ToolId::Move, "{key:?}: back to Move");
|
||||
assert_eq!(layers(&u), finished, "{key:?}: the path is kept as Escape keeps it");
|
||||
// The canvas takes no more pen points.
|
||||
click(&mut u, pos2(800.0, 300.0));
|
||||
assert!(u.app.session.tool.pen.is_none(), "{key:?}: a click starts no path");
|
||||
assert_eq!(layers(&u), finished, "{key:?}: a click adds nothing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mode_switcher_ends_the_pen_path_too() {
|
||||
let finished = {
|
||||
let mut esc = drawing();
|
||||
press(&mut esc, Key::Escape, Modifiers::NONE);
|
||||
layers(&esc)
|
||||
};
|
||||
for (label, tab) in [("Prototype", RightTab::Prototype), ("Inspect", RightTab::Inspect)] {
|
||||
let mut u = drawing();
|
||||
let at = mode_button(&mut u, label);
|
||||
click(&mut u, at);
|
||||
assert_eq!(u.app.ui.right_tab, tab, "{label}");
|
||||
assert!(u.app.session.tool.pen.is_none(), "{label}: the path is finished");
|
||||
assert_eq!(u.app.session.tool.tool, ToolId::Move, "{label}: back to Move");
|
||||
assert_eq!(layers(&u), finished, "{label}: the path is kept as Escape keeps it");
|
||||
}
|
||||
// Design is already the mode while the pen draws: pressing it again leaves nothing, so the
|
||||
// path goes on.
|
||||
let mut u = drawing();
|
||||
let at = mode_button(&mut u, "Design");
|
||||
click(&mut u, at);
|
||||
assert_eq!(u.app.ui.right_tab, RightTab::Design);
|
||||
assert!(u.app.session.tool.pen.is_some(), "Design: the path goes on");
|
||||
assert_eq!(u.app.session.tool.tool, ToolId::Pen, "Design: still the pen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_on_the_mode_switcher_does_not_reach_the_canvas() {
|
||||
// The switcher floats over the canvas: the pen must not place an anchor under it.
|
||||
for label in ["Prototype", "Design"] {
|
||||
let mut u = Ui::new(Session::new());
|
||||
u.app.run("tool.set", json!({"tool": "pen"}));
|
||||
let at = mode_button(&mut u, label);
|
||||
u.button(at, true);
|
||||
assert!(u.app.session.tool.pen.is_none(), "{label}: the press starts no path");
|
||||
u.button(at, false);
|
||||
u.frame(vec![]);
|
||||
assert!(u.app.session.tool.pen.is_none(), "{label}: no path after the click");
|
||||
assert_eq!(layers(&u), 0, "{label}: the click adds nothing");
|
||||
}
|
||||
}
|
||||
@@ -124,10 +124,11 @@ fn run(app: &mut FigApp, cmd: &str, params: Value) {
|
||||
|
||||
/// Plugins ▸ Development ▸ Import plugin from manifest…: pick a `figcraft-plugin.json`.
|
||||
pub fn import_dialog(app: &mut FigApp) {
|
||||
let Some(p) = app.services.pick_open(tr("Import plugin from manifest"), &["json"]) else { return };
|
||||
if let Some(v) = app.run("plugin.import", json!({"path": p.to_string_lossy()})) {
|
||||
app.toast(trf("Imported {name}", &[("name", &v["name"].as_str().unwrap_or(tr("the plugin")))]));
|
||||
}
|
||||
app.pick_open(tr("Import plugin from manifest"), &["json"], |app, p| {
|
||||
if let Some(v) = app.run("plugin.import", json!({"path": p.to_string_lossy()})) {
|
||||
app.toast(trf("Imported {name}", &[("name", &v["name"].as_str().unwrap_or(tr("the plugin")))]));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The installed plugins at the top of the Plugins menu: a plugin with one command is an item, one
|
||||
|
||||
@@ -3,12 +3,24 @@
|
||||
//! until the next one arrives.
|
||||
//! The renderer keeps its last image ([`figcraft_render::Retained`]): a pan renders only the
|
||||
//! uncovered strips and an edit only the areas of the layers that changed.
|
||||
//! A new zoom can't reuse that image, and the old one, re-projected, covers little of the view
|
||||
//! after a zoom out (an eighth of the width from 800% to 100%). When full renders are slow, the
|
||||
//! whole view is first rendered coarsely ([`PREVIEW_FACTOR`] times fewer pixels each way, kept
|
||||
//! apart from the full image so pans and edits still build on it), then refined unless a newer
|
||||
//! request is already waiting.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_color::ColorProfile;
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_render::{Pixmap, Retained, View, Work};
|
||||
// A monotonic clock in the browser too: preview decisions rest on how long renders take there.
|
||||
use web_time::Instant;
|
||||
|
||||
/// A preview has this many times fewer pixels each way than the view it stands in for.
|
||||
pub const PREVIEW_FACTOR: f64 = 4.0;
|
||||
/// Full renders quicker than this (about a frame) show without a preview first.
|
||||
const PREVIEW_AFTER_MS: f64 = 12.0;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Job {
|
||||
@@ -25,6 +37,13 @@ pub struct Job {
|
||||
pub solo: Option<NodeId>,
|
||||
/// Soft-proof sRGB: stripe the colours sRGB can't show.
|
||||
pub proof: bool,
|
||||
/// The document's [`figcraft_engine::DocState::uid`]: images of other files are never shown.
|
||||
pub uid: u64,
|
||||
/// A coarse preview may come first when the zoom changes (not in pixel preview, whose pixels
|
||||
/// are the point).
|
||||
pub zoom_preview: bool,
|
||||
/// When the UI asked for it: how long the image took to reach the screen.
|
||||
pub at: Instant,
|
||||
}
|
||||
|
||||
pub struct Done {
|
||||
@@ -34,6 +53,13 @@ pub struct Done {
|
||||
pub view: View,
|
||||
pub ms: f64,
|
||||
pub work: Work,
|
||||
/// What it is an image of ([`Job::uid`], [`Job::page`]).
|
||||
pub uid: u64,
|
||||
pub page: NodeId,
|
||||
/// A coarse preview, refined by a later image unless a newer request replaced it.
|
||||
pub coarse: bool,
|
||||
/// [`Job::at`].
|
||||
pub at: Instant,
|
||||
}
|
||||
|
||||
/// A render of a document in `profile` as a texture image for the screen (sRGB).
|
||||
@@ -58,9 +84,9 @@ fn mark_outside_srgb(image: &mut egui::ColorImage, pix: &Pixmap, profile: ColorP
|
||||
}
|
||||
}
|
||||
|
||||
fn run(job: &Job, retained: &mut Retained) -> Option<Done> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let t0 = std::time::Instant::now();
|
||||
/// Render `view` of the job (its own view, or the coarse one standing in for it).
|
||||
fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option<Done> {
|
||||
let t0 = Instant::now();
|
||||
// The canvas is a preview: what's too small to see in detail at this zoom is approximated.
|
||||
let opts = figcraft_render::Options {
|
||||
outline_mode: job.outline,
|
||||
@@ -71,16 +97,86 @@ fn run(job: &Job, retained: &mut Retained) -> Option<Done> {
|
||||
lod: true,
|
||||
..Default::default()
|
||||
};
|
||||
let (view, pix, work) = retained.render(&job.doc, job.page, &job.view, &opts)?;
|
||||
let (view, pix, work) = retained.render(&job.doc, job.page, view, &opts)?;
|
||||
let mut image = screen_image(pix, job.doc.color_profile);
|
||||
if job.proof {
|
||||
mark_outside_srgb(&mut image, pix, job.doc.color_profile);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let ms = 0.0;
|
||||
Some(Done { image, view, ms, work })
|
||||
Some(Done { image, view, ms, work, uid: job.uid, page: job.page, coarse, at: job.at })
|
||||
}
|
||||
|
||||
/// `view` with [`PREVIEW_FACTOR`] times fewer pixels each way, showing at least the same area.
|
||||
pub fn coarse_view(view: &View) -> View {
|
||||
let shrink = |n: u32| (f64::from(n) / PREVIEW_FACTOR).ceil().max(1.0) as u32;
|
||||
View { origin: view.origin, scale: view.scale / PREVIEW_FACTOR, width: shrink(view.width), height: shrink(view.height) }
|
||||
}
|
||||
|
||||
/// What the render thread keeps between jobs: the canvas image, the previews standing in for it
|
||||
/// while the zoom changes, and what the last full render drew and took.
|
||||
pub struct Renders {
|
||||
full: Retained,
|
||||
preview: Retained,
|
||||
/// The last full render's document, page and scale.
|
||||
last: Option<(u64, NodeId, f64)>,
|
||||
/// How long the last render from scratch took.
|
||||
full_ms: f64,
|
||||
/// Previews come first only when renders from scratch take at least this long.
|
||||
preview_after_ms: f64,
|
||||
}
|
||||
|
||||
impl Default for Renders {
|
||||
fn default() -> Self {
|
||||
Self::with_threshold(PREVIEW_AFTER_MS)
|
||||
}
|
||||
}
|
||||
|
||||
impl Renders {
|
||||
/// Previews when renders from scratch take at least `ms` (tests: 0, always).
|
||||
pub fn with_threshold(ms: f64) -> Self {
|
||||
Self { full: Retained::default(), preview: Retained::default(), last: None, full_ms: 0.0, preview_after_ms: ms }
|
||||
}
|
||||
|
||||
/// Whether `job` gets a preview first: the kept image is of another zoom, page or file (so the
|
||||
/// full render starts from scratch) and those have been slow. The first render has no
|
||||
/// measure yet and comes without one.
|
||||
fn wants_preview(&self, job: &Job) -> bool {
|
||||
job.zoom_preview
|
||||
&& self.full_ms >= self.preview_after_ms
|
||||
&& self.last.is_some_and(|(uid, page, scale)| uid != job.uid || page != job.page || scale.to_bits() != job.view.scale.to_bits())
|
||||
}
|
||||
|
||||
/// The coarse preview of the whole of `job`'s view, when one should come first.
|
||||
pub fn preview(&mut self, job: &Job) -> Option<Done> {
|
||||
if !self.wants_preview(job) {
|
||||
return None;
|
||||
}
|
||||
let view = coarse_view(&job.view);
|
||||
guarded(&mut self.preview, |r| run(job, &view, r, true))
|
||||
}
|
||||
|
||||
/// The image of `job`'s view, built on the kept one where it can be.
|
||||
pub fn refine(&mut self, job: &Job) -> Option<Done> {
|
||||
let done = guarded(&mut self.full, |r| run(job, &job.view, r, false));
|
||||
if let Some(d) = &done {
|
||||
self.last = Some((job.uid, job.page, job.view.scale));
|
||||
if d.work == Work::Full {
|
||||
self.full_ms = d.ms;
|
||||
}
|
||||
}
|
||||
done
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` on `retained`; after a panic, forget the image it may have left half drawn.
|
||||
fn guarded(retained: &mut Retained, f: impl FnOnce(&mut Retained) -> Option<Done>) -> Option<Done> {
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(retained))) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
retained.clear();
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -104,22 +200,26 @@ impl Worker {
|
||||
// The window that renders with it (each window has its own worker).
|
||||
let viewport = ctx.viewport_id();
|
||||
let spawned = std::thread::Builder::new().name("figcraft-render".into()).spawn(move || {
|
||||
let mut retained = Retained::default();
|
||||
while let Ok((mut seq, mut job)) = jobs.recv() {
|
||||
let mut renders = Renders::default();
|
||||
let mut next = None;
|
||||
loop {
|
||||
let Some((mut seq, mut job)) = next.take().or_else(|| jobs.recv().ok()) else { break };
|
||||
// Skip to the newest queued job.
|
||||
while let Ok(newer) = jobs.try_recv() {
|
||||
(seq, job) = newer;
|
||||
}
|
||||
let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&job, &mut retained)));
|
||||
let done = match res {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
// Don't build on an image a panic may have left half drawn.
|
||||
retained.clear();
|
||||
None
|
||||
if let Some(d) = renders.preview(&job) {
|
||||
if done_tx.send(d).is_err() {
|
||||
break;
|
||||
}
|
||||
};
|
||||
if let Some(d) = done
|
||||
ctx.request_repaint_of(viewport);
|
||||
// A newer request makes refining this one moot: it gets its own preview.
|
||||
if let Ok(newer) = jobs.try_recv() {
|
||||
next = Some(newer);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(d) = renders.refine(&job)
|
||||
&& done_tx.send(d).is_err()
|
||||
{
|
||||
break;
|
||||
@@ -155,30 +255,44 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub struct Worker {
|
||||
/// The browser's worker: renders inline, one image a frame: a preview first when one is due, the
|
||||
/// refined image in the next frame unless a newer request replaced the job. Built everywhere so
|
||||
/// tests run it.
|
||||
pub struct Inline {
|
||||
ctx: egui::Context,
|
||||
pending: Option<Job>,
|
||||
retained: Retained,
|
||||
previewed: bool,
|
||||
renders: Renders,
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
impl Worker {
|
||||
pub fn new(_ctx: egui::Context) -> Self {
|
||||
Self { pending: None, retained: Retained::default() }
|
||||
pub type Worker = Inline;
|
||||
|
||||
impl Inline {
|
||||
pub fn new(ctx: egui::Context) -> Self {
|
||||
Self { ctx, pending: None, previewed: false, renders: Renders::default() }
|
||||
}
|
||||
pub fn submit(&mut self, job: Job) {
|
||||
self.pending = Some(job);
|
||||
self.previewed = false;
|
||||
}
|
||||
pub fn in_flight(&self) -> bool {
|
||||
self.pending.is_some()
|
||||
}
|
||||
pub fn poll(&mut self) -> Option<Done> {
|
||||
if !self.previewed {
|
||||
self.previewed = true;
|
||||
if let Some(d) = self.pending.as_ref().and_then(|job| self.renders.preview(job)) {
|
||||
self.ctx.request_repaint();
|
||||
return Some(d);
|
||||
}
|
||||
}
|
||||
let job = self.pending.take()?;
|
||||
run(&job, &mut self.retained)
|
||||
self.renders.refine(&job)
|
||||
}
|
||||
}
|
||||
|
||||
/// Render synchronously from scratch (tests, screenshots).
|
||||
pub fn render_now(job: &Job) -> Option<Done> {
|
||||
run(job, &mut Retained::default())
|
||||
run(job, &job.view, &mut Retained::default(), false)
|
||||
}
|
||||
|
||||
@@ -746,7 +746,7 @@ fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) {
|
||||
(family_resp, style_resp)
|
||||
});
|
||||
crate::right::menu_popup(&family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()) {
|
||||
if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()).picked {
|
||||
set(out, &|d| {
|
||||
d.font_family = f.clone();
|
||||
let styles = figcraft_text::family_styles(&f);
|
||||
|
||||
@@ -329,6 +329,10 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) {
|
||||
/// toolbar region keyboard navigation moves through.
|
||||
fn pill(ui: &Ui, nav: &mut Nav, canvas: Rect, width: f32, t: &Tokens) -> Rect {
|
||||
let rect = Rect::from_center_size(pos2(canvas.center().x, canvas.max.y - 12.0 - 24.0), vec2(width, 48.0));
|
||||
// The pill takes the press and the drag over it: its buttons sense clicks only, and egui gives
|
||||
// a drag the canvas under them would sense to the canvas, so the pen placed an anchor there.
|
||||
// Not focusable: the keyboard walks the buttons, not the pill.
|
||||
ui.interact(rect, ui.id().with("pill"), Sense::CLICK | Sense::DRAG);
|
||||
a11y::area(nav, Region::Toolbar, rect);
|
||||
a11y::container_bounds(ui, rect);
|
||||
let painter = ui.painter();
|
||||
|
||||
@@ -181,10 +181,14 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle]
|
||||
let w = ui.available_width();
|
||||
let resp = widgets::dropdown(ui, family.as_deref().unwrap_or(tr(MIXED)), w, None);
|
||||
menu_popup(&resp).width(w.max(240.0)).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
if let Some(f) = font_picker(ui, family.as_deref(), resp.clicked()) {
|
||||
let pick = font_picker(ui, family.as_deref(), resp.clicked());
|
||||
if let Some(f) = pick.picked {
|
||||
remember_family(ui.ctx(), &f);
|
||||
apply_style(app, "fontFamily", json!(f));
|
||||
ui.close();
|
||||
} else if let Some(f) = pick.highlighted {
|
||||
// The canvas shows the family the arrow keys are on.
|
||||
app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, json!({"fontFamily": f})));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1061,6 +1065,8 @@ pub(crate) fn parse_letter_spacing(s: &str, cur: Option<LetterSpacing>) -> Optio
|
||||
|
||||
const RECENT_KEY: &str = "figcraft_recent_fonts";
|
||||
const ROW: f32 = 26.0;
|
||||
/// The font list's tallest (it scrolls past that).
|
||||
const LIST_HEIGHT: f32 = 380.0;
|
||||
|
||||
fn recent_families(ctx: &egui::Context) -> Vec<String> {
|
||||
ctx.data_mut(|d| d.get_persisted::<Vec<String>>(Id::new(RECENT_KEY))).unwrap_or_default()
|
||||
@@ -1080,14 +1086,31 @@ enum PickRow {
|
||||
Family(String),
|
||||
}
|
||||
|
||||
/// The searchable family list. Returns the family picked.
|
||||
pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> Option<String> {
|
||||
/// What the family list did this frame.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct FontPick {
|
||||
/// The family clicked, or the one Enter picked.
|
||||
pub picked: Option<String>,
|
||||
/// The family Up and Down are on (Enter picks it).
|
||||
pub highlighted: Option<String>,
|
||||
}
|
||||
|
||||
/// The searchable family list: click a family, or step through it with Up and Down and press Enter.
|
||||
pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> FontPick {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let q_id = Id::new("font_query");
|
||||
// The row Up and Down are on, and the search it is a row of.
|
||||
let hi_id = Id::new("font_highlight");
|
||||
let mut q: String = ui.data(|d| d.get_temp(q_id)).unwrap_or_default();
|
||||
if just_opened {
|
||||
q.clear();
|
||||
ui.data_mut(|d| d.remove::<(String, usize)>(hi_id));
|
||||
}
|
||||
// Taken before the search field sees them (it would move its cursor).
|
||||
let step = ui.input_mut(|i| {
|
||||
i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) as i64
|
||||
- i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) as i64
|
||||
});
|
||||
let search = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tr("Search fonts")).desired_width(ui.available_width()).font(regular(11.5)));
|
||||
if just_opened {
|
||||
search.request_focus();
|
||||
@@ -1096,9 +1119,6 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool)
|
||||
let ql = q.trim().to_lowercase();
|
||||
let all = figcraft_text::family_names();
|
||||
let matches: Vec<String> = all.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)).cloned().collect();
|
||||
if search.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
return matches.into_iter().next();
|
||||
}
|
||||
let mut rows: Vec<PickRow> = Vec::new();
|
||||
if ql.is_empty() {
|
||||
let recent: Vec<String> = recent_families(ui.ctx()).into_iter().filter(|f| all.iter().any(|a| a.eq_ignore_ascii_case(f))).collect();
|
||||
@@ -1110,13 +1130,65 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool)
|
||||
}
|
||||
if matches.is_empty() {
|
||||
widgets::label(ui, tr("No fonts match"), t.text_tertiary);
|
||||
return None;
|
||||
return FontPick::default();
|
||||
}
|
||||
rows.extend(matches.into_iter().map(PickRow::Family));
|
||||
let family = |i: usize| match rows.get(i) {
|
||||
Some(PickRow::Family(f)) => Some(f.clone()),
|
||||
_ => None,
|
||||
};
|
||||
// A new search starts again; the first step starts from the current family.
|
||||
let mut hi = ui.data(|d| d.get_temp::<(String, usize)>(hi_id)).filter(|(hq, _)| *hq == q).map(|(_, i)| i);
|
||||
let stride = ROW + ui.spacing().item_spacing.y;
|
||||
let mut offset = None;
|
||||
if step != 0 {
|
||||
let next = |i: usize| if step > 0 { i.checked_add(1) } else { i.checked_sub(1) };
|
||||
let mut from =
|
||||
hi.or_else(|| rows.iter().rposition(|r| matches!(r, PickRow::Family(f) if current.is_some_and(|c| c.eq_ignore_ascii_case(f)))));
|
||||
for _ in 0..step.unsigned_abs() {
|
||||
let mut at = match from {
|
||||
Some(i) => next(i),
|
||||
None if step > 0 => Some(0),
|
||||
None => rows.len().checked_sub(1),
|
||||
};
|
||||
// Past the headers; at either end it stays where it was.
|
||||
while let Some(i) = at.filter(|i| *i < rows.len() && family(*i).is_none()) {
|
||||
at = next(i);
|
||||
}
|
||||
if let Some(i) = at.filter(|i| family(*i).is_some()) {
|
||||
from = Some(i);
|
||||
hi = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(i) = hi {
|
||||
// Scrolled just enough to show it (the list is shorter than its tallest where the
|
||||
// window has less room).
|
||||
let (seen, height): (f32, f32) = ui.data(|d| d.get_temp(hi_id.with("view"))).unwrap_or((0.0, LIST_HEIGHT));
|
||||
let top = i as f32 * stride;
|
||||
offset = Some(seen.max(top + stride - height).min(top));
|
||||
}
|
||||
}
|
||||
match hi {
|
||||
Some(i) => ui.data_mut(|d| {
|
||||
d.insert_temp(hi_id, (q.clone(), i));
|
||||
}),
|
||||
None => ui.data_mut(|d| d.remove::<(String, usize)>(hi_id)),
|
||||
}
|
||||
if search.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
let first = rows.iter().find_map(|r| match r {
|
||||
PickRow::Family(f) => Some(f.clone()),
|
||||
PickRow::Header(_) => None,
|
||||
});
|
||||
return FontPick { picked: hi.and_then(family).or(first), highlighted: None };
|
||||
}
|
||||
let mut budget = 4;
|
||||
let mut picked = None;
|
||||
egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show_rows(ui, ROW, rows.len(), |ui, range| {
|
||||
for r in rows.get(range).unwrap_or(&[]) {
|
||||
let mut area = egui::ScrollArea::vertical().max_height(LIST_HEIGHT).auto_shrink([false, true]);
|
||||
if let Some(y) = offset {
|
||||
area = area.vertical_scroll_offset(y);
|
||||
}
|
||||
let shown = area.show_rows(ui, ROW, rows.len(), |ui, range| {
|
||||
for (i, r) in rows.iter().enumerate().skip(range.start).take(range.len()) {
|
||||
let w = ui.available_width();
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w, ROW), if matches!(r, PickRow::Family(_)) { Sense::click() } else { Sense::hover() });
|
||||
match r {
|
||||
@@ -1124,7 +1196,7 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool)
|
||||
ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, *h, semibold(10.5), t.text_secondary);
|
||||
}
|
||||
PickRow::Family(f) => {
|
||||
let hovered = resp.hovered();
|
||||
let hovered = resp.hovered() || hi == Some(i);
|
||||
if hovered {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(5), t.accent);
|
||||
}
|
||||
@@ -1160,7 +1232,8 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool)
|
||||
}
|
||||
}
|
||||
});
|
||||
picked
|
||||
ui.data_mut(|d| d.insert_temp(hi_id.with("view"), (shown.state.offset.y, shown.inner_rect.height())));
|
||||
FontPick { highlighted: hi.and_then(family), picked }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1207,6 +1280,42 @@ mod tests {
|
||||
assert_eq!(parse_letter_spacing("x", Some(LetterSpacing::Percent(0.0))), None);
|
||||
}
|
||||
|
||||
/// One frame of the font picker with `events`.
|
||||
fn pick(ctx: &egui::Context, current: &str, opened: bool, events: Vec<egui::Event>) -> FontPick {
|
||||
let mut out = FontPick::default();
|
||||
let mut shown = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| out = font_picker(ui, Some(current), opened));
|
||||
shown.textures_delta.clear();
|
||||
out
|
||||
}
|
||||
|
||||
fn key(key: egui::Key) -> egui::Event {
|
||||
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_step_through_the_families_and_enter_picks() {
|
||||
// #345
|
||||
figcraft_text::scan_system_fonts();
|
||||
let all = figcraft_text::family_names();
|
||||
if all.len() < 3 {
|
||||
eprintln!("fewer than three font families; skipping");
|
||||
return;
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let current = all[1].as_str();
|
||||
assert!(pick(&ctx, current, true, vec![]).highlighted.is_none());
|
||||
// The first step starts from the current family; each press is one step.
|
||||
let down = pick(&ctx, current, false, vec![key(egui::Key::ArrowDown)]);
|
||||
assert_eq!(down.highlighted.as_deref(), Some(all[2].as_str()));
|
||||
let up = pick(&ctx, current, false, vec![key(egui::Key::ArrowUp), key(egui::Key::ArrowUp)]);
|
||||
assert_eq!(up.highlighted.as_deref(), Some(all[0].as_str()));
|
||||
// At the top it stays; Enter picks the family it is on, not the first.
|
||||
assert_eq!(pick(&ctx, current, false, vec![key(egui::Key::ArrowUp)]).highlighted.as_deref(), Some(all[0].as_str()));
|
||||
pick(&ctx, current, false, vec![key(egui::Key::ArrowDown)]);
|
||||
let enter = pick(&ctx, current, false, vec![key(egui::Key::Enter)]);
|
||||
assert_eq!(enter.picked.as_deref(), Some(all[1].as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_values_round_by_axis_width() {
|
||||
let axis = |min: f64, max: f64| figcraft_text::Axis { tag: "test".into(), name: "Test".into(), min, default: min, max, hidden: false };
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Variables ▸ Export… where the platform saves by downloads (the browser, which has no save
|
||||
//! dialogs): the export arrives as a download instead of doing nothing.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::variables_panel::export_dialog;
|
||||
use crate::{FigApp, NoServices, Services};
|
||||
|
||||
type Downloads = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// Services that save by downloads and keep them (`deliver` false: the browser refused).
|
||||
struct Browser {
|
||||
got: Downloads,
|
||||
deliver: bool,
|
||||
}
|
||||
|
||||
impl Services for Browser {
|
||||
fn downloads(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn download(&mut self, name: &str, bytes: &[u8]) -> bool {
|
||||
self.got.lock().unwrap().push((name.to_string(), bytes.to_vec()));
|
||||
self.deliver
|
||||
}
|
||||
}
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
for (cmd, p) in [
|
||||
("variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]})),
|
||||
(
|
||||
"variable.create",
|
||||
json!({"collection": "Theme", "name": "color/primary", "type": "color", "values": {"Light": "#0000ff", "Dark": "#ffffff"}}),
|
||||
),
|
||||
("variable.create", json!({"collection": "Theme", "name": "radius", "type": "number", "value": 4})),
|
||||
] {
|
||||
s.execute(cmd, &p).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn browser(deliver: bool) -> (FigApp, Downloads) {
|
||||
let got = Downloads::default();
|
||||
(FigApp::new(session(), Box::new(Browser { got: got.clone(), deliver })), got)
|
||||
}
|
||||
|
||||
fn toast(app: &FigApp) -> String {
|
||||
app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_export_downloads_what_variable_export_writes() {
|
||||
let (mut app, got) = browser(true);
|
||||
let expected = app.session.execute("variable.export", &json!({"format": "json"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
export_dialog(&mut app, "json");
|
||||
let got = got.lock().unwrap();
|
||||
let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) };
|
||||
assert!(name.ends_with(".variables.json"), "{name}");
|
||||
assert_eq!(String::from_utf8(bytes.clone()).unwrap(), expected);
|
||||
let parsed: Value = serde_json::from_slice(bytes).unwrap();
|
||||
assert!(parsed.is_object());
|
||||
assert!(toast(&app).contains(name.as_str()), "{}", toast(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_export_downloads_custom_properties() {
|
||||
let (mut app, got) = browser(true);
|
||||
export_dialog(&mut app, "css");
|
||||
let got = got.lock().unwrap();
|
||||
let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) };
|
||||
assert!(name.ends_with(".variables.css"), "{name}");
|
||||
let css = String::from_utf8(bytes.clone()).unwrap();
|
||||
assert!(css.contains(":root") && css.contains("--"), "{css}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_download_says_so() {
|
||||
let (mut app, got) = browser(false);
|
||||
export_dialog(&mut app, "json");
|
||||
assert_eq!(got.lock().unwrap().len(), 1);
|
||||
assert_eq!(toast(&app), "Couldn't download the variables");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platforms_with_save_dialogs_keep_them() {
|
||||
// No dialog answer (cancelled) and no downloads: nothing happens, as before.
|
||||
let mut app = FigApp::new(session(), Box::new(NoServices));
|
||||
export_dialog(&mut app, "json");
|
||||
assert!(app.ui.toast.is_none());
|
||||
}
|
||||
@@ -204,27 +204,42 @@ fn header(app: &mut FigApp, ui: &mut Ui, open: &mut bool) {
|
||||
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border));
|
||||
}
|
||||
|
||||
/// Export this file's collections through a save dialog.
|
||||
fn export_dialog(app: &mut FigApp, format: &str) {
|
||||
/// Export this file's collections through a save dialog, or as a download where the platform has
|
||||
/// no save dialogs (the browser).
|
||||
pub(crate) fn export_dialog(app: &mut FigApp, format: &str) {
|
||||
let name = app.session.doc().map(|d| format!("{}.variables.{format}", d.name)).unwrap_or_else(|_| format!("variables.{format}"));
|
||||
let Some(p) = app.services.pick_save(tr("Export variables"), &name, &[format]) else { return };
|
||||
if app.run("variable.export", json!({"format": format, "path": p.to_string_lossy()})).is_some() {
|
||||
let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
|
||||
app.toast(trf("Exported {file}", &[("file", &file)]));
|
||||
if app.services.downloads() {
|
||||
let Some(r) = app.run("variable.export", json!({"format": format})) else { return };
|
||||
let text = r["text"].as_str().unwrap_or_default();
|
||||
if app.services.download(&name, text.as_bytes()) {
|
||||
app.toast(trf("Exported {file}", &[("file", &name)]));
|
||||
} else {
|
||||
app.toast(tr("Couldn't download the variables"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let format = format.to_owned();
|
||||
let ext = format.clone();
|
||||
app.pick_save(tr("Export variables"), &name, &[&ext], move |app, p| {
|
||||
if app.run("variable.export", json!({"format": format, "path": p.to_string_lossy()})).is_some() {
|
||||
let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
|
||||
app.toast(trf("Exported {file}", &[("file", &file)]));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Import a variables JSON picked in an open dialog.
|
||||
fn import_dialog(app: &mut FigApp) {
|
||||
let Some(p) = app.services.pick_open(tr("Import variables"), &["json"]) else { return };
|
||||
let Some(r) = app.run("variable.import", json!({"variables": p.to_string_lossy()})) else { return };
|
||||
let (added, updated) = (r["variables"].as_u64().unwrap_or(0), r["updated"].as_u64().unwrap_or(0));
|
||||
let skipped = r["warnings"].as_array().map_or(0, Vec::len);
|
||||
let mut msg = trn("Imported {n} new variables, updated {updated}", added as usize, &[("updated", &updated)]);
|
||||
if skipped > 0 {
|
||||
msg = format!("{msg} {}", trn("({n} warnings: some entries were skipped)", skipped, &[]));
|
||||
}
|
||||
app.toast(msg);
|
||||
app.pick_open(tr("Import variables"), &["json"], |app, p| {
|
||||
let Some(r) = app.run("variable.import", json!({"variables": p.to_string_lossy()})) else { return };
|
||||
let (added, updated) = (r["variables"].as_u64().unwrap_or(0), r["updated"].as_u64().unwrap_or(0));
|
||||
let skipped = r["warnings"].as_array().map_or(0, Vec::len);
|
||||
let mut msg = trn("Imported {n} new variables, updated {updated}", added as usize, &[("updated", &updated)]);
|
||||
if skipped > 0 {
|
||||
msg = format!("{msg} {}", trn("({n} warnings: some entries were skipped)", skipped, &[]));
|
||||
}
|
||||
app.toast(msg);
|
||||
});
|
||||
}
|
||||
|
||||
/// Extend library `lib`'s collection `c` and show the new collection.
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::FigApp;
|
||||
use crate::i18n::tr;
|
||||
use crate::icons::Icon;
|
||||
use crate::right::{CAP_ART_W, cap_art, join_label, menu_item, menu_item_with, menu_popup, row};
|
||||
use crate::widgets::{self, FieldEdit, Lead, PAD};
|
||||
use crate::widgets::{self, FieldEdit, Lead};
|
||||
|
||||
/// Run a point command from a field: typed values are one step, scrubs one step when released.
|
||||
fn point_field(app: &mut FigApp, cmd: &str, edit: Option<FieldEdit>, params: impl Fn(f64) -> Value) {
|
||||
@@ -38,11 +38,7 @@ fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
|
||||
let sel = ve.width_point.and_then(|i| Some((i, *stroke?.profile()?.points.get(i)?)));
|
||||
let Some((i, p)) = sel else {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(PAD);
|
||||
widgets::label(ui, tr("Drag the stroke to add a width point"), t.text_secondary);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
widgets::note(ui, tr("Drag the stroke to add a width point"), t.text_secondary);
|
||||
return;
|
||||
};
|
||||
let weight = stroke.map_or(1.0, |s| s.weight);
|
||||
@@ -85,11 +81,7 @@ pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
|
||||
widgets::section_header(ui, if pts.len() > 1 { tr("Points") } else { tr("Point") }, &[], false);
|
||||
if pts.is_empty() {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(PAD);
|
||||
widgets::label(ui, tr("Select points to edit their position, radius and mirroring"), t.text_secondary);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
widgets::note(ui, tr("Select points to edit their position, radius and mirroring"), t.text_secondary);
|
||||
return;
|
||||
}
|
||||
let col = widgets::col_width(ui.available_width());
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//! The Design panel in vector edit mode keeps its width, so the canvas doesn't move under the
|
||||
//! pointer when the panel's contents change.
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn the_canvas_stays_put_entering_vector_edit_mode_and_picking_a_region() {
|
||||
let (mut h, _) = crate::live_tests::Harness::new();
|
||||
h.app.session.execute("file.newFromTemplate", &json!({"template": "mobile-app"})).unwrap();
|
||||
// A wide vector with a filled area: the panel shows its long hint until something is picked.
|
||||
let id = {
|
||||
let doc = h.app.session.doc().unwrap();
|
||||
doc.nodes().filter(|n| n.name == "Near ridge").max_by(|a, b| a.height.total_cmp(&b.height)).map(|n| n.id).unwrap()
|
||||
};
|
||||
let canvas = |h: &mut crate::live_tests::Harness| {
|
||||
h.frames(4);
|
||||
h.app.inspect()["canvas"]["rect"].clone()
|
||||
};
|
||||
h.app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let before = canvas(&mut h);
|
||||
h.app.session.execute("vector.edit.begin", &json!({})).unwrap();
|
||||
assert_eq!(canvas(&mut h), before, "the hint for an empty point selection wraps instead of widening the panel");
|
||||
h.app.session.execute("vector.edit.select", &json!({"regions": [0]})).unwrap();
|
||||
assert_eq!(canvas(&mut h), before, "picking a region doesn't move the canvas either");
|
||||
h.app.session.execute("vector.edit.tool", &json!({"tool": "width"})).unwrap();
|
||||
assert_eq!(canvas(&mut h), before, "nor does the width tool's hint");
|
||||
}
|
||||
@@ -433,21 +433,74 @@ pub(crate) fn show(app: &FigApp, ctx: &Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame of window `id`, with its state swapped in.
|
||||
fn draw(app: &mut FigApp, id: u64, ui: &mut egui::Ui) {
|
||||
let Some(k) = app.windows.list.iter().position(|w| w.id == id) else { return };
|
||||
let ctx = ui.ctx().clone();
|
||||
/// A window's state swapped into the app ([`enter`]) until [`leave`] swaps it back out.
|
||||
struct Entered {
|
||||
id: u64,
|
||||
pane: Pane,
|
||||
/// The newest open file before: files opened after it are the window's own.
|
||||
newest: u64,
|
||||
}
|
||||
|
||||
/// Swap window `id`'s state into the app and show its file; `None` when there is no such window.
|
||||
fn enter(app: &mut FigApp, id: u64) -> Option<Entered> {
|
||||
let k = app.windows.list.iter().position(|w| w.id == id)?;
|
||||
let main_active = app.session.ds().ok().map(|d| d.uid);
|
||||
let newest = app.session.docs.iter().map(|d| d.uid).max().unwrap_or(0);
|
||||
let files_before = app.session.docs.len();
|
||||
let prefs = app.session.prefs.clone();
|
||||
let Some(w) = app.windows.list.get_mut(k) else { return };
|
||||
let w = app.windows.list.get_mut(k)?;
|
||||
let want = w.active;
|
||||
let mut pane = std::mem::take(&mut w.pane);
|
||||
exchange(app, &mut pane);
|
||||
app.windows.current = id;
|
||||
app.windows.main_active = main_active;
|
||||
show_own(app, want);
|
||||
Some(Entered { id, pane, newest })
|
||||
}
|
||||
|
||||
/// Files the entered window opened are its own.
|
||||
fn adopt(app: &mut FigApp, e: &Entered) {
|
||||
let new: Vec<u64> = app.session.docs.iter().map(|d| d.uid).filter(|u| *u > e.newest && app.windows.of(*u) == 0).collect();
|
||||
if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == e.id) {
|
||||
w.docs.extend(new);
|
||||
}
|
||||
}
|
||||
|
||||
/// Swap the entered window's state back out (with where it is on screen, when known) and the
|
||||
/// main window's back in.
|
||||
fn leave(app: &mut FigApp, mut e: Entered, outer: Option<egui::Rect>, inner: Option<egui::Rect>) {
|
||||
let shown = app.session.ds().ok().map(|d| d.uid).filter(|u| app.windows.of(*u) == e.id);
|
||||
exchange(app, &mut e.pane);
|
||||
if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == e.id) {
|
||||
w.active = shown.or(w.active);
|
||||
w.pos = outer.map(|r| r.min).or(w.pos);
|
||||
w.size = inner.map_or(w.size, |r| r.size());
|
||||
w.pane = e.pane;
|
||||
}
|
||||
app.windows.current = 0;
|
||||
let main_active = app.windows.main_active.take();
|
||||
show_own(app, main_active);
|
||||
}
|
||||
|
||||
/// Run `f` as window `id` (0: the main window) outside its frame, with its state swapped in as
|
||||
/// while it draws; `None` when there is no such window. For a window that draws nothing now
|
||||
/// (minimized, or occluded: behind other windows or on another Space).
|
||||
pub(crate) fn as_window<R>(app: &mut FigApp, id: u64, f: impl FnOnce(&mut FigApp) -> R) -> Option<R> {
|
||||
if id == 0 {
|
||||
// Between frames the app holds the main window's state.
|
||||
return Some(f(app));
|
||||
}
|
||||
let e = enter(app, id)?;
|
||||
let r = f(app);
|
||||
adopt(app, &e);
|
||||
leave(app, e, None, None);
|
||||
Some(r)
|
||||
}
|
||||
|
||||
/// One frame of window `id`, with its state swapped in.
|
||||
fn draw(app: &mut FigApp, id: u64, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let files_before = app.session.docs.len();
|
||||
let prefs = app.session.prefs.clone();
|
||||
let Some(e) = enter(app, id) else { return };
|
||||
if ctx.input(|i| i.viewport().focused == Some(true)) {
|
||||
app.windows.focused = id;
|
||||
}
|
||||
@@ -455,25 +508,11 @@ fn draw(app: &mut FigApp, id: u64, ui: &mut egui::Ui) {
|
||||
app.logic(&ctx);
|
||||
// Within the swap, so a panic leaves every window its own state.
|
||||
guard(app, &ctx, |app| app.frame(ui));
|
||||
// Files it opened are its own.
|
||||
let new: Vec<u64> = app.session.docs.iter().map(|d| d.uid).filter(|u| *u > newest && app.windows.of(*u) == 0).collect();
|
||||
if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == id) {
|
||||
w.docs.extend(new);
|
||||
}
|
||||
adopt(app, &e);
|
||||
settle(app, &ctx, files_before);
|
||||
if app.session.prefs != prefs {
|
||||
repaint_others(app, &ctx);
|
||||
}
|
||||
let shown = app.session.ds().ok().map(|d| d.uid).filter(|u| app.windows.of(*u) == id);
|
||||
let (outer, inner) = ctx.input(|i| (i.viewport().outer_rect, i.viewport().inner_rect));
|
||||
exchange(app, &mut pane);
|
||||
if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == id) {
|
||||
w.active = shown.or(w.active);
|
||||
w.pos = outer.map(|r| r.min).or(w.pos);
|
||||
w.size = inner.map_or(w.size, |r| r.size());
|
||||
w.pane = pane;
|
||||
}
|
||||
app.windows.current = 0;
|
||||
let main_active = app.windows.main_active.take();
|
||||
show_own(app, main_active);
|
||||
leave(app, e, outer, inner);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
//! Several windows: a window holds its own tabs; closing it closes its files, asking first when
|
||||
//! one has unsaved changes, and the main window takes over another window instead of quitting.
|
||||
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use egui::{RawInput, ViewportEvent, ViewportId, ViewportInfo};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::control::{ControlReply, ControlRequest};
|
||||
use crate::{FigApp, NoServices};
|
||||
|
||||
/// One main-window frame, as the desktop app runs it; `close` asks the window to close.
|
||||
@@ -21,6 +24,23 @@ fn frame(ctx: &egui::Context, app: &mut FigApp, close: bool) {
|
||||
.clear();
|
||||
}
|
||||
|
||||
/// A control request queued for the UI, and where its reply arrives.
|
||||
fn request(app: &mut FigApp, method: &str, params: Value) -> Receiver<ControlReply> {
|
||||
let (reply, rx) = channel();
|
||||
app.control_queue.push(ControlRequest { id: json!(1), method: method.into(), params, reply, cancelled: Default::default() });
|
||||
rx
|
||||
}
|
||||
|
||||
/// The desktop app's logic as eframe runs it for the main window, with each window's visibility
|
||||
/// (`hidden`: minimised or covered). While the main window is hidden too this is all that runs.
|
||||
fn logic(ctx: &egui::Context, app: &mut FigApp, hidden: &[ViewportId]) {
|
||||
let mut input = RawInput::default();
|
||||
for id in std::iter::once(ViewportId::ROOT).chain(app.windows.list.iter().map(|w| crate::windows::viewport(w.id))) {
|
||||
input.viewports.insert(id, ViewportInfo { minimized: Some(false), occluded: Some(hidden.contains(&id)), ..Default::default() });
|
||||
}
|
||||
let _ = ctx.run_logic(&input, |ctx| crate::control::drain_hidden(app, ctx));
|
||||
}
|
||||
|
||||
fn names(app: &FigApp) -> Vec<String> {
|
||||
app.session.docs.iter().map(|d| d.doc.name.clone()).collect()
|
||||
}
|
||||
@@ -64,3 +84,69 @@ fn windows_hold_tabs_and_close_with_their_files() {
|
||||
assert!(!app.ui.quit, "the app keeps running");
|
||||
assert_eq!(app.session.ds().unwrap().doc.name, "A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_hidden_window_answers_requests_that_need_no_frame_in_its_own_file() {
|
||||
let mut s = Session::empty();
|
||||
for n in ["A", "B"] {
|
||||
s.execute("file.new", &json!({"name": n})).unwrap();
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_embed_viewports(false);
|
||||
let mut app = FigApp::new(s, Box::new(NoServices));
|
||||
frame(&ctx, &mut app, false);
|
||||
let w = crate::control::handle(&mut app, &ctx, "window.new", &json!({"index": 0})).unwrap()["window"].as_u64().unwrap();
|
||||
frame(&ctx, &mut app, false);
|
||||
let other = crate::windows::viewport(w);
|
||||
let rect = |size: u32| json!({"window": w, "command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": size, "height": size}});
|
||||
let dirty = |app: &FigApp, name: &str| app.session.docs.iter().find(|d| d.doc.name == name).unwrap().is_dirty();
|
||||
|
||||
// The main window is on screen, the other one covered: the request to it is answered in its
|
||||
// file (A), and the main window keeps showing its own (B).
|
||||
let edit = request(&mut app, "engine.execute", rect(10));
|
||||
logic(&ctx, &mut app, &[other]);
|
||||
assert!(edit.try_recv().expect("answered without the other window's frame").result.is_ok());
|
||||
assert!(dirty(&app, "A") && !dirty(&app, "B"), "in the other window's file");
|
||||
assert_eq!(app.session.ds().unwrap().doc.name, "B");
|
||||
assert_eq!(app.windows.current, 0);
|
||||
|
||||
// Both hidden: still answered, and inspection sees the other window's file.
|
||||
let inspect = request(&mut app, "document.inspect", json!({"window": w, "depth": 0}));
|
||||
logic(&ctx, &mut app, &[ViewportId::ROOT, other]);
|
||||
assert_eq!(inspect.try_recv().expect("answered").result.unwrap()["name"], json!("A"));
|
||||
assert_eq!(app.session.ds().unwrap().doc.name, "B");
|
||||
|
||||
// What needs a frame keeps waiting for the other window; an unknown window is an error at once.
|
||||
let ui = request(&mut app, "ui.inspect", json!({"window": w}));
|
||||
let unknown = request(&mut app, "window.list", json!({"window": 99}));
|
||||
logic(&ctx, &mut app, &[ViewportId::ROOT, other]);
|
||||
assert!(ui.try_recv().is_err(), "waits for the window to show");
|
||||
assert!(unknown.try_recv().expect("answered").result.unwrap_err().contains("no window 99"));
|
||||
assert_eq!(app.control_queue.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_for_a_window_on_screen_wakes_it_while_the_main_window_is_hidden() {
|
||||
let mut s = Session::empty();
|
||||
for n in ["A", "B"] {
|
||||
s.execute("file.new", &json!({"name": n})).unwrap();
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_embed_viewports(false);
|
||||
let mut app = FigApp::new(s, Box::new(NoServices));
|
||||
frame(&ctx, &mut app, false);
|
||||
let w = crate::control::handle(&mut app, &ctx, "window.new", &json!({"index": 0})).unwrap()["window"].as_u64().unwrap();
|
||||
frame(&ctx, &mut app, false);
|
||||
let other = crate::windows::viewport(w);
|
||||
// Which windows are asked to draw from now on (an idle window draws no frames until asked).
|
||||
let woken = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let log = woken.clone();
|
||||
ctx.set_request_repaint_callback(move |info| log.lock().unwrap().push(info.viewport_id));
|
||||
|
||||
// Only the main window's logic runs while it is hidden: the request for the other window,
|
||||
// which is on screen, waits for that window's frame, and that window is asked to draw it.
|
||||
let ui = request(&mut app, "ui.inspect", json!({"window": w}));
|
||||
logic(&ctx, &mut app, &[ViewportId::ROOT]);
|
||||
assert!(ui.try_recv().is_err(), "answered in the other window's frame");
|
||||
assert!(woken.lock().unwrap().contains(&other), "the other window is asked to draw: {:?}", woken.lock().unwrap());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Coarse previews while the zoom changes (#295): a new zoom first shows the whole view coarsely,
|
||||
//! then the refined image, which matches a render from scratch; pans and edits still build on the
|
||||
//! kept image; previews never replace a refined image that already shows the whole view asked
|
||||
//! for, and no image of another file or page goes on screen. The browser's inline worker previews
|
||||
//! by the same measure as the render thread.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::params::id_of;
|
||||
use figcraft_render::{View, Work};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{CanvasState, shows};
|
||||
use crate::render_worker::{Done, Inline, Job, PREVIEW_FACTOR, Renders, coarse_view, render_now};
|
||||
|
||||
/// A page of ellipses with a rectangle on top; the session and the rectangle.
|
||||
fn document() -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
for k in 0..12 {
|
||||
let k = f64::from(k);
|
||||
s.execute("shape.ellipse", &json!({"x": k * 60.0, "y": k * 40.0, "width": 120, "height": 90})).unwrap();
|
||||
}
|
||||
let made = s.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 300, "height": 200})).unwrap();
|
||||
let rect = id_of(&made["id"]).unwrap();
|
||||
(s, rect)
|
||||
}
|
||||
|
||||
/// A `w` × `h` device-pixel view at `scale` centred on page point `c`.
|
||||
fn view_at(c: (f64, f64), scale: f64, w: u32, h: u32) -> View {
|
||||
View { origin: (c.0 - f64::from(w) / scale / 2.0, c.1 - f64::from(h) / scale / 2.0), scale, width: w, height: h }
|
||||
}
|
||||
|
||||
fn job(doc: &Arc<Document>, page: NodeId, uid: u64, view: View) -> Job {
|
||||
Job {
|
||||
doc: doc.clone(),
|
||||
page,
|
||||
view,
|
||||
outline: false,
|
||||
outline_extras: Default::default(),
|
||||
outlined: vec![],
|
||||
skip: vec![],
|
||||
solo: None,
|
||||
proof: false,
|
||||
uid,
|
||||
zoom_preview: true,
|
||||
at: web_time::Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every pixel of `a` and `b` alike.
|
||||
fn same_pixels(a: &Done, b: &Done) -> bool {
|
||||
a.image.size == b.image.size && a.image.pixels == b.image.pixels
|
||||
}
|
||||
|
||||
fn covers(outer: &View, inner: &View) -> bool {
|
||||
let (o, i) = (outer.page_rect(), inner.page_rect());
|
||||
let eps = 1e-6;
|
||||
o.x0 <= i.x0 + eps && o.y0 <= i.y0 + eps && o.x1 >= i.x1 - eps && o.y1 >= i.y1 - eps
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zoom_out_previews_the_whole_view_then_refines_to_a_fresh_render() {
|
||||
let (s, _) = document();
|
||||
let ds = s.ds().unwrap();
|
||||
let mut r = Renders::with_threshold(0.0);
|
||||
let zoomed_in = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 8.0, 400, 300));
|
||||
assert!(r.preview(&zoomed_in).is_none(), "the first render has nothing to measure previews by");
|
||||
assert!(r.refine(&zoomed_in).is_some());
|
||||
// 800% → 100% about the same centre: the old image covers 1/64 of the new view.
|
||||
let out = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 1.0, 400, 300));
|
||||
let p = r.preview(&out).expect("a preview first");
|
||||
assert!(p.coarse && (p.uid, p.page) == (ds.uid, ds.page));
|
||||
assert!(covers(&p.view, &out.view), "the preview shows the whole view asked for");
|
||||
let pixels = |d: &Done| d.image.size[0] * d.image.size[1];
|
||||
let fresh = render_now(&out).unwrap();
|
||||
assert!(
|
||||
(pixels(&p) as f64) * PREVIEW_FACTOR * PREVIEW_FACTOR < 1.5 * pixels(&fresh) as f64,
|
||||
"a sixteenth of the pixels, give or take the snapping"
|
||||
);
|
||||
let refined = r.refine(&out).unwrap();
|
||||
assert!(!refined.coarse && refined.work == Work::Full);
|
||||
assert!(same_pixels(&refined, &fresh), "the settled image is a render from scratch");
|
||||
}
|
||||
|
||||
/// Zoom, pan and edit with `r`; whether each step was previewed, and the pan's and the edit's
|
||||
/// images.
|
||||
fn zoom_pan_edit(r: &mut Renders) -> (Vec<bool>, Done, Done) {
|
||||
let (mut s, rect) = document();
|
||||
let (doc, page, uid) = {
|
||||
let ds = s.ds().unwrap();
|
||||
(ds.doc.clone(), ds.page, ds.uid)
|
||||
};
|
||||
r.refine(&job(&doc, page, uid, view_at((300.0, 200.0), 4.0, 1280, 960))).unwrap();
|
||||
let mut previewed = vec![];
|
||||
// A zoom; the preview is kept apart from the full image.
|
||||
let zoomed = job(&doc, page, uid, view_at((300.0, 200.0), 2.0, 1280, 960));
|
||||
previewed.push(r.preview(&zoomed).is_some());
|
||||
r.refine(&zoomed).unwrap();
|
||||
// A pan at that zoom.
|
||||
let mut pan = zoomed;
|
||||
pan.view.origin.0 += 10.0;
|
||||
previewed.push(r.preview(&pan).is_some());
|
||||
let panned = r.refine(&pan).unwrap();
|
||||
// An edit there.
|
||||
s.execute("node.set", &json!({"id": rect, "props": {"x": 120}})).unwrap();
|
||||
let edit = job(&s.ds().unwrap().doc, page, uid, pan.view);
|
||||
previewed.push(r.preview(&edit).is_some());
|
||||
let edited = r.refine(&edit).unwrap();
|
||||
(previewed, panned, edited)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pans_and_edits_get_no_preview_and_build_on_the_kept_image() {
|
||||
let (previewed, panned, edited) = zoom_pan_edit(&mut Renders::with_threshold(0.0));
|
||||
assert_eq!(previewed, [true, false, false], "the zoom only");
|
||||
assert!(matches!(panned.work, Work::Partial { .. }), "a pan after a previewed zoom renders strips only: {:?}", panned.work);
|
||||
assert!(matches!(edited.work, Work::Partial { .. }), "an edit renders its area only: {:?}", edited.work);
|
||||
// The same steps without previews: the same work and the same pixels.
|
||||
let (none, panned_alone, edited_alone) = zoom_pan_edit(&mut Renders::with_threshold(f64::INFINITY));
|
||||
assert_eq!(none, [false, false, false]);
|
||||
assert_eq!((panned.work, edited.work), (panned_alone.work, edited_alone.work));
|
||||
assert!(same_pixels(&panned, &panned_alone) && same_pixels(&edited, &edited_alone));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_come_for_other_pages_and_files_but_not_in_pixel_preview_or_when_renders_are_quick() {
|
||||
let (mut s, _) = document();
|
||||
let (doc, page, uid) = {
|
||||
let ds = s.ds().unwrap();
|
||||
(ds.doc.clone(), ds.page, ds.uid)
|
||||
};
|
||||
let v = view_at((300.0, 200.0), 1.0, 200, 150);
|
||||
let mut r = Renders::with_threshold(0.0);
|
||||
r.refine(&job(&doc, page, uid, v.clone())).unwrap();
|
||||
s.execute("page.add", &json!({})).unwrap();
|
||||
let ds = s.ds().unwrap();
|
||||
assert!(r.preview(&job(&ds.doc, ds.page, uid, v.clone())).is_some(), "another page");
|
||||
assert!(r.preview(&job(&doc, page, uid + 1, v.clone())).is_some(), "another file");
|
||||
let zoom = view_at((300.0, 200.0), 2.0, 200, 150);
|
||||
let mut pixel_preview = job(&doc, page, uid, zoom.clone());
|
||||
pixel_preview.zoom_preview = false;
|
||||
assert!(r.preview(&pixel_preview).is_none(), "pixel preview shows real pixels only");
|
||||
let mut quick = Renders::with_threshold(f64::INFINITY);
|
||||
quick.refine(&job(&doc, page, uid, v)).unwrap();
|
||||
assert!(quick.preview(&job(&doc, page, uid, zoom)).is_none(), "quick renders show without one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_cover_the_view_for_any_zoom_and_density() {
|
||||
let (s, _) = document();
|
||||
let ds = s.ds().unwrap();
|
||||
let mut r = Renders::with_threshold(0.0);
|
||||
r.refine(&job(&ds.doc, ds.page, ds.uid, view_at((0.0, 0.0), 0.37, 64, 64))).unwrap();
|
||||
let mut n = 0;
|
||||
// Zooms from 2% to 6400% at 1x, 1.5x and 2x displays, odd sizes, centres off the pixel grid.
|
||||
for zoom in [0.02, 0.1, 0.333, 1.0, 2.5, 8.0, 64.0] {
|
||||
for ppp in [1.0, 1.5, 2.0] {
|
||||
for (w, h) in [(1, 1), (3, 7), (401, 299), (1280, 720)] {
|
||||
let v = view_at((123.456, -78.9), zoom * ppp, w, h);
|
||||
let c = coarse_view(&v);
|
||||
assert!(covers(&c, &v), "coarse view of {v:?}");
|
||||
let p = r.preview(&job(&ds.doc, ds.page, ds.uid, v.clone())).expect("a preview");
|
||||
assert!(covers(&p.view, &v), "preview of {v:?} shows {:?}", p.view);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(n, 84);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_mode_proofing_and_glass_preview_and_settle_like_fresh_renders() {
|
||||
let (mut s, rect) = document();
|
||||
s.execute("effect.add", &json!({"ids": [rect], "type": "GLASS"})).unwrap();
|
||||
let ds = s.ds().unwrap();
|
||||
for (outline, proof) in [(false, false), (true, false), (false, true)] {
|
||||
let mut r = Renders::with_threshold(0.0);
|
||||
let mut first = job(&ds.doc, ds.page, ds.uid, view_at((250.0, 150.0), 3.0, 300, 200));
|
||||
(first.outline, first.proof) = (outline, proof);
|
||||
r.refine(&first).unwrap();
|
||||
let mut next = first.clone();
|
||||
next.view = view_at((250.0, 150.0), 0.75, 300, 200);
|
||||
let p = r.preview(&next).expect("a preview");
|
||||
assert!(p.coarse && covers(&p.view, &next.view));
|
||||
let d = r.refine(&next).unwrap();
|
||||
assert!(same_pixels(&d, &render_now(&next).unwrap()), "outline {outline}, proof {proof}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_canvas_shows_images_of_its_own_page_and_never_a_coarser_one_of_its_view() {
|
||||
let (s, _) = document();
|
||||
let ds = s.ds().unwrap();
|
||||
let here = Some((ds.uid, ds.page));
|
||||
let mut r = Renders::with_threshold(0.0);
|
||||
let near = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 2.0, 200, 150));
|
||||
let refined = r.refine(&near).unwrap();
|
||||
let far = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 0.5, 200, 150));
|
||||
let preview = r.preview(&far).unwrap();
|
||||
|
||||
let mut c = CanvasState { want_view: Some(near.view.clone()), ..Default::default() };
|
||||
assert!(shows(&c, &refined, here));
|
||||
assert!(!shows(&c, &refined, Some((ds.uid + 1, ds.page))), "another file");
|
||||
assert!(!shows(&c, &refined, Some((ds.uid, NodeId(ds.page.0 + 1)))), "another page");
|
||||
assert!(!shows(&c, &refined, None), "no file open");
|
||||
// Showing the refined image of the view asked for: a preview arriving late stays off.
|
||||
(c.tex_view, c.tex_of, c.tex_coarse) = (Some(refined.view.clone()), here, false);
|
||||
assert!(!shows(&c, &preview, here));
|
||||
// Once the view asked for moves on, the preview is the better match.
|
||||
c.want_view = Some(far.view.clone());
|
||||
assert!(shows(&c, &preview, here));
|
||||
// Over another preview, or a refined image of another page, it goes on.
|
||||
c.want_view = Some(near.view.clone());
|
||||
c.tex_coarse = true;
|
||||
assert!(shows(&c, &preview, here));
|
||||
(c.tex_coarse, c.tex_of) = (false, Some((ds.uid, NodeId(ds.page.0 + 1))));
|
||||
assert!(shows(&c, &preview, here));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_replaces_a_refined_image_of_the_same_zoom_elsewhere_on_the_page() {
|
||||
// 800 × 600: A at 100% from the page origin, B at 800% about its top-left, A' back at 100%
|
||||
// about B's bottom-right corner. A shows 100 × 75 of A', 1/64 of it.
|
||||
let (s, _) = document();
|
||||
let ds = s.ds().unwrap();
|
||||
let here = Some((ds.uid, ds.page));
|
||||
let view = |origin, scale| View { origin, scale, width: 800, height: 600 };
|
||||
let a = job(&ds.doc, ds.page, ds.uid, view((0.0, 0.0), 1.0));
|
||||
let b = job(&ds.doc, ds.page, ds.uid, view((0.0, 0.0), 8.0));
|
||||
let a2 = job(&ds.doc, ds.page, ds.uid, view((-700.0, -525.0), 1.0));
|
||||
let mut r = Renders::with_threshold(0.0);
|
||||
let refined_a = r.refine(&a).unwrap();
|
||||
// The canvas shows A; B is asked for, then A' while B renders. The thread finishes B, then
|
||||
// previews A' before the canvas polls: the queue holds [refined B, preview A'], and a poll
|
||||
// takes the last.
|
||||
let queue = [r.refine(&b).unwrap(), r.preview(&a2).expect("A' is a new zoom for the thread")];
|
||||
let polled = queue.last().unwrap();
|
||||
assert!(polled.coarse && covers(&polled.view, &a2.view));
|
||||
let c = CanvasState {
|
||||
tex_view: Some(refined_a.view.clone()),
|
||||
tex_of: here,
|
||||
tex_coarse: false,
|
||||
want_view: Some(a2.view.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(shows(&c, polled, here), "A at the same zoom shows 1/64 of A': the preview of all of it goes on");
|
||||
// Once A' itself is refined on screen, a preview of it would be a step down.
|
||||
let refined_a2 = r.refine(&a2).unwrap();
|
||||
let c = CanvasState { tex_view: Some(refined_a2.view.clone()), ..c };
|
||||
assert!(!shows(&c, polled, here));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_browser_worker_previews_by_measured_render_time_then_refines_the_next_frame() {
|
||||
// Big enough that a render from scratch takes well over a frame: blurs and glass on 2400².
|
||||
let (mut s, rect) = document();
|
||||
s.execute("effect.add", &json!({"ids": [rect], "type": "GLASS"})).unwrap();
|
||||
s.execute("effect.add", &json!({"ids": [rect], "type": "BACKGROUND_BLUR"})).unwrap();
|
||||
let ds = s.ds().unwrap();
|
||||
let at = |scale| job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), scale, 2400, 2400));
|
||||
// The production threshold, as the browser has it.
|
||||
let mut w = Inline::new(egui::Context::default());
|
||||
w.submit(at(2.0));
|
||||
let first = w.poll().expect("the first image");
|
||||
assert!(!first.coarse && !w.in_flight(), "the first render has no measure to preview by");
|
||||
assert!(first.ms >= 12.0, "renders are timed in the browser worker too (and this one is slow): {} ms", first.ms);
|
||||
// A new zoom: the preview this frame, the refined image the next.
|
||||
w.submit(at(1.0));
|
||||
let p = w.poll().expect("a preview");
|
||||
assert!(p.coarse && w.in_flight());
|
||||
let d = w.poll().expect("the refined image");
|
||||
assert!(!d.coarse && !w.in_flight());
|
||||
assert!(same_pixels(&d, &render_now(&at(1.0)).unwrap()));
|
||||
// A newer request before the refinement: refining the old one is skipped.
|
||||
w.submit(at(0.5));
|
||||
assert!(w.poll().is_some_and(|d| d.coarse));
|
||||
w.submit(at(0.25));
|
||||
let p = w.poll().expect("the newer one's preview");
|
||||
assert!(p.coarse && (p.view.scale - 0.25 / PREVIEW_FACTOR).abs() < 1e-12);
|
||||
assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 0.25));
|
||||
assert!(w.poll().is_none() && !w.in_flight());
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user