mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 19:36:46 -06:00
Community batch 4: 11 reviewed PRs (#530)
Lands 11 reviewed community PRs with their own commits plus maintainer fixes on their branches: #305 #363 #400 #338 #272 #368 #352 #308 #322 #317 #306.
This commit is contained in:
Generated
+1
@@ -1721,6 +1721,7 @@ dependencies = [
|
||||
"figcraft-collab-net",
|
||||
"figcraft-engine",
|
||||
"figcraft-ui-egui",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -167,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);
|
||||
@@ -207,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| {
|
||||
@@ -220,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
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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()}))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
@@ -1400,6 +1407,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;
|
||||
@@ -1818,7 +1830,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()?,
|
||||
}
|
||||
@@ -1943,15 +1955,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();
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
+66
-25
@@ -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) {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -301,7 +301,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"
|
||||
@@ -457,6 +459,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"
|
||||
@@ -568,6 +571,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)"
|
||||
@@ -998,6 +1002,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"
|
||||
@@ -1071,6 +1076,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"
|
||||
@@ -1323,6 +1329,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"
|
||||
@@ -1364,6 +1372,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"
|
||||
@@ -1569,6 +1578,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"
|
||||
@@ -1785,6 +1795,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"
|
||||
|
||||
@@ -310,7 +310,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"
|
||||
@@ -475,6 +477,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"
|
||||
@@ -594,6 +597,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)"
|
||||
@@ -1046,6 +1050,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"
|
||||
@@ -1113,6 +1118,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"
|
||||
@@ -1389,6 +1395,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"
|
||||
@@ -1431,6 +1439,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)"
|
||||
@@ -312,6 +313,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"
|
||||
@@ -476,6 +478,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"
|
||||
@@ -595,6 +598,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)"
|
||||
@@ -1047,6 +1051,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"
|
||||
@@ -1114,6 +1119,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"
|
||||
@@ -1390,6 +1396,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"
|
||||
@@ -1432,6 +1440,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"
|
||||
@@ -1644,6 +1653,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"
|
||||
@@ -1866,6 +1876,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"
|
||||
|
||||
@@ -301,7 +301,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"
|
||||
@@ -457,6 +459,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é"
|
||||
@@ -568,6 +571,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)"
|
||||
@@ -998,6 +1002,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"
|
||||
@@ -1071,6 +1076,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é"
|
||||
@@ -1323,6 +1329,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"
|
||||
@@ -1364,6 +1372,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é"
|
||||
@@ -1569,6 +1578,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"
|
||||
@@ -1785,6 +1795,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"
|
||||
|
||||
@@ -314,6 +314,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" = "先に変数コレクションを作成"
|
||||
@@ -478,6 +479,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" = "ファイルが見つからない"
|
||||
@@ -597,6 +599,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)" = "オートレイアウトを無視(絶対位置)"
|
||||
@@ -1049,6 +1052,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" = "サーバーから削除済み"
|
||||
@@ -1122,6 +1126,7 @@ status = "draft — needs native review"
|
||||
"Save" = "保存"
|
||||
"Save as" = "名前を付けて保存"
|
||||
"Save as preset…" = "プリセットとして保存…"
|
||||
"Save files automatically" = "ファイルを自動保存"
|
||||
"Save version {key}" = "バージョンを保存 {key}"
|
||||
"Saved" = "保存済み"
|
||||
"Saved {name}" = "{name} を保存済み"
|
||||
@@ -1392,6 +1397,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" = "固定値を使用"
|
||||
@@ -1434,6 +1441,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" = "波線"
|
||||
@@ -1646,6 +1654,7 @@ status = "draft — needs native review"
|
||||
"collab.publish" = "サーバーに公開"
|
||||
"collab.recoverFile" = "オンラインファイルを復元"
|
||||
"collab.registerLinks" = "招待リンクを FigCraft で開く"
|
||||
"collab.removePerson" = "セッションから削除"
|
||||
"collab.report" = "ファイルをサーバーの運営者に報告"
|
||||
"collab.serverInfo" = "サーバー情報"
|
||||
"collab.share" = "参加コードで共有"
|
||||
@@ -1868,6 +1877,7 @@ status = "draft — needs native review"
|
||||
"plugin.ui" = "プラグインパネル"
|
||||
"plugins.manage" = "プラグインを管理…"
|
||||
"plugins.runLast" = "最後のプラグインを実行"
|
||||
"pref.autoSave" = "ファイルを自動保存"
|
||||
"pref.highlightHover" = "ホバー時にレイヤーをハイライト"
|
||||
"pref.keepTool" = "使用後もツールを選択したままにする"
|
||||
"pref.language" = "インターフェースの言語"
|
||||
|
||||
@@ -303,6 +303,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" = "먼저 변수 컬렉션을 만드세요"
|
||||
@@ -458,6 +459,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" = "파일 게시됨"
|
||||
@@ -569,6 +571,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)" = "자동 레이아웃 무시(절대 위치)"
|
||||
@@ -999,6 +1002,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" = "서버에서 제거됨"
|
||||
@@ -1072,6 +1076,7 @@ status = "draft — needs native review"
|
||||
"Save" = "저장"
|
||||
"Save as" = "다른 이름으로 저장"
|
||||
"Save as preset…" = "프리셋으로 저장…"
|
||||
"Save files automatically" = "파일 자동 저장"
|
||||
"Save version {key}" = "버전 저장 {key}"
|
||||
"Saved" = "저장됨"
|
||||
"Saved {name}" = "{name} 저장됨"
|
||||
@@ -1324,6 +1329,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" = "고정 값 사용"
|
||||
@@ -1365,6 +1372,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" = "물결선"
|
||||
@@ -1570,6 +1578,7 @@ status = "draft — needs native review"
|
||||
"collab.publish" = "서버에 게시"
|
||||
"collab.recoverFile" = "온라인 파일 복구"
|
||||
"collab.registerLinks" = "초대 링크를 FigCraft에서 열기"
|
||||
"collab.removePerson" = "세션에서 제거"
|
||||
"collab.report" = "서버 운영자에게 파일 신고"
|
||||
"collab.serverInfo" = "서버 정보"
|
||||
"collab.share" = "참여 코드로 공유"
|
||||
@@ -1786,6 +1795,7 @@ status = "draft — needs native review"
|
||||
"plugin.ui" = "플러그인 패널"
|
||||
"plugins.manage" = "플러그인 관리…"
|
||||
"plugins.runLast" = "마지막 플러그인 실행"
|
||||
"pref.autoSave" = "파일 자동 저장"
|
||||
"pref.highlightHover" = "마우스를 올리면 레이어 강조"
|
||||
"pref.keepTool" = "사용 후에도 도구 선택 유지"
|
||||
"pref.language" = "인터페이스 언어"
|
||||
|
||||
@@ -300,7 +300,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"
|
||||
@@ -456,6 +458,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"
|
||||
@@ -567,6 +570,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)"
|
||||
@@ -997,6 +1001,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"
|
||||
@@ -1070,6 +1075,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"
|
||||
@@ -1322,6 +1328,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"
|
||||
@@ -1363,6 +1371,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"
|
||||
@@ -1568,6 +1577,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"
|
||||
@@ -1784,6 +1794,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"
|
||||
|
||||
@@ -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" = "请先创建变量集合"
|
||||
@@ -459,6 +460,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" = "文件已发布"
|
||||
@@ -570,6 +572,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)" = "忽略自动布局(绝对定位)"
|
||||
@@ -1000,6 +1003,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" = "已从服务器移除"
|
||||
@@ -1073,6 +1077,7 @@ status = "draft — needs native review"
|
||||
"Save" = "保存"
|
||||
"Save as" = "另存为"
|
||||
"Save as preset…" = "存储为预设…"
|
||||
"Save files automatically" = "自动保存文件"
|
||||
"Save version {key}" = "保存版本 {key}"
|
||||
"Saved" = "已保存"
|
||||
"Saved {name}" = "已保存 {name}"
|
||||
@@ -1325,6 +1330,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" = "使用固定值"
|
||||
@@ -1366,6 +1373,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" = "波浪线"
|
||||
@@ -1571,6 +1579,7 @@ status = "draft — needs native review"
|
||||
"collab.publish" = "发布到服务器"
|
||||
"collab.recoverFile" = "恢复在线文件"
|
||||
"collab.registerLinks" = "在 FigCraft 中打开邀请链接"
|
||||
"collab.removePerson" = "移出会话"
|
||||
"collab.report" = "向服务器运营者举报文件"
|
||||
"collab.serverInfo" = "服务器信息"
|
||||
"collab.share" = "用加入代码分享"
|
||||
@@ -1787,6 +1796,7 @@ status = "draft — needs native review"
|
||||
"plugin.ui" = "插件面板"
|
||||
"plugins.manage" = "管理插件…"
|
||||
"plugins.runLast" = "运行上一个插件"
|
||||
"pref.autoSave" = "自动保存文件"
|
||||
"pref.highlightHover" = "悬停时高亮图层"
|
||||
"pref.keepTool" = "使用后保持工具选中"
|
||||
"pref.language" = "界面语言"
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
|
||||
@@ -324,3 +324,39 @@ fn a_hidden_window_answers_requests_that_need_no_frame() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -216,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}));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -131,6 +131,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 +161,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;
|
||||
@@ -190,6 +196,15 @@ pub trait Services {
|
||||
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) -> DialogFuture {
|
||||
Box::pin(std::future::ready(None))
|
||||
@@ -305,6 +320,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;
|
||||
|
||||
@@ -344,6 +367,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>,
|
||||
@@ -440,6 +465,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).
|
||||
@@ -464,6 +494,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(),
|
||||
@@ -524,6 +555,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,
|
||||
@@ -680,6 +713,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) {
|
||||
@@ -1066,6 +1112,79 @@ 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"))]
|
||||
@@ -1076,6 +1195,7 @@ impl FigApp {
|
||||
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();
|
||||
@@ -1132,6 +1252,7 @@ impl FigApp {
|
||||
widgets::set_nudge(&ctx, self.session.prefs.nudge_small, self.session.prefs.nudge_big);
|
||||
// The Inspect tab measures on hover without Alt.
|
||||
self.session.tool.redlines = self.ui.right_tab == RightTab::Inspect;
|
||||
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.
|
||||
@@ -1234,6 +1355,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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,9 +204,20 @@ 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}"));
|
||||
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| {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -386,6 +386,14 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
outlines in the person's colour, and avatars (initials) at the top of the right panel beside
|
||||
Share; click an avatar to **follow** that person's viewport (`collab.follow {client}`), click
|
||||
again, press Escape or pan/zoom yourself to stop. The chevron lists everyone.
|
||||
- **Removing someone.** Right-click their avatar ▸ "Remove <name> from session"
|
||||
(`collab.removePerson {client}`), when you host the session or own the file on a server. The
|
||||
hub closes their connection with "You were removed from this session" (close code 1001: the
|
||||
session is over for them, no reconnecting) and bars their address (its `ip_key`) from that file
|
||||
while it runs, so the invite they hold no longer gets them in; everyone else stays connected. On
|
||||
a server the owner asks on an open hosting connection with the file's owner token
|
||||
(`Admin::RemovePeer {file, client}`; the operator may too, with the server key). Addresses are
|
||||
the only identity a session has: someone behind the same address (one NAT) is barred too.
|
||||
- **Text carets.** While someone types in a text layer of the shared document on your page, their
|
||||
caret and selected characters show on it in their colour (the text layout's own caret and
|
||||
selection geometry, so they sit at the right glyphs of your copy), with their name in a tag above
|
||||
@@ -463,7 +471,7 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
`collab.share {via?: "online" | "local", access?, …}`,
|
||||
`collab.host {port?, bind?: "local" | "lan" | <ip>}`, `collab.stop`, `collab.join {code | link}`,
|
||||
`collab.leave`, `collab.peers`, `collab.follow {client?}`, `collab.cursor {x?, y?}` (an agent's
|
||||
pointer), `collab.cursorChat {text?}`, `collab.spotlight {on?}`.
|
||||
pointer), `collab.cursorChat {text?}`, `collab.spotlight {on?}`, `collab.removePerson {client}`.
|
||||
- **Sessions are per document.** Every open file has its own session or none: hosting, joined,
|
||||
being published, reconnecting or failed. Several run at once (host one file, join another,
|
||||
publish a third), each with its own people, links, undo history, cursor chat, spotlight and
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Collaboration parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
|
||||
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
|
||||
@@ -15,7 +15,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Left: server archives beyond Linux | 25–55 |
|
||||
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
|
||||
| Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 |
|
||||
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The list is per computer: no accounts, teams or projects | 8–17 |
|
||||
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 |
|
||||
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) |
|
||||
| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — |
|
||||
| Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 |
|
||||
@@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Removing someone from a live session (M9, from the owner's use: whoever joined with the invite couldn't be put out short of ending the session for everyone): right-click a person's avatar ▸ "Remove <name> from session", or `collab.removePerson {client}`, when you host the session or own the file on a server (owner token). The hub closes their connection as the session's end for them (they keep their copy and don't reconnect) and bars their address from that file while the session or server runs, so the code or link they hold no longer gets them in; everyone else stays. Protocol addition within version 2 (`Admin removePeer`; older servers say they can't). Addresses are the only identity without accounts: people behind one address are barred together. Percentages and hours unchanged |
|
||||
| 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. |
|
||||
| 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. Files are known per computer, not per person (no accounts): percentages and hours unchanged |
|
||||
| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Usability and hosting rather than a new capability: percentages and hours unchanged |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
|
||||
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
|
||||
@@ -69,13 +69,14 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
| Explicit modes per frame, library collections' modes | done | |
|
||||
| Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed |
|
||||
| Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) |
|
||||
| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name); CSS custom properties export |
|
||||
| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name); CSS custom properties export; in the browser the export arrives as a download (import there still needs a file picker) |
|
||||
| Expressions in prototype set-variable actions | done | see [prototyping-parity](prototyping-parity.md) |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Variables export in the browser: Export… ▸ Variables JSON / CSS custom properties did nothing in the web build, which has no save dialogs; it now hands the file over as a download (`Services::downloads` / `download`, `variables_export_ui_tests`). Import in the browser still needs a file picker, as do the other exports there. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. |
|
||||
| 2026-10-12 | minor | Layers and Assets panels behaviour suite: Assets folders by page, frame and slash names; a leading `.` or `_` keeps a component out of the library even after it was published, and removing it brings the component back |
|
||||
|
||||
@@ -69,6 +69,15 @@ JSON or the gradient shorthand `{"type":"linearGradient","stops":["#hex","#hex"]
|
||||
{"id":3,"method":"engine.execute","params":{"command":"command.batch","params":{"label":"Tidy","commands":[{"command":"align.left","params":{"ids":[2,3]}},{"command":"distribute.vertical","params":{"ids":[2,3]}}]}}}
|
||||
```
|
||||
|
||||
Inside `command.batch`, `{"$step": n}` in a step's params stands for the `id` in step `n`'s result
|
||||
(zero-based; `{"$step": n, "field": "…"}` for another field; any object with a `$step` key is read
|
||||
as one, and a nested `command.batch` step keeps its own references to its own steps), so one batch
|
||||
can create a frame and fill it:
|
||||
|
||||
```json
|
||||
{"id":4,"method":"engine.execute","params":{"command":"command.batch","params":{"commands":[{"command":"node.create","params":{"type":"frame","width":200,"height":100}},{"command":"node.create","params":{"type":"rectangle","parent":{"$step":0},"width":20,"height":20}}]}}}
|
||||
```
|
||||
|
||||
### `engine.commands {}`
|
||||
|
||||
Every command: `[{id, label, params, menu, shortcut, undoable}]`.
|
||||
|
||||
+34
-4
@@ -219,7 +219,7 @@ Or in a project's `.mcp.json`:
|
||||
|---|---|
|
||||
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. |
|
||||
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, SwiftUI or Jetpack Compose for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React and Leptos, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
|
||||
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. |
|
||||
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: `{"$step": n}` anywhere in its params becomes the `id` in step n's result (zero-based), and `{"$step": n, "field": "…"}` another field of it (see Step references below). |
|
||||
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
|
||||
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. |
|
||||
| `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). |
|
||||
@@ -349,6 +349,8 @@ on; `key` resolves keys the way the canvas does. Commands behind keys:
|
||||
nested layers), Escape or a click drops it.
|
||||
- `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's
|
||||
accessibility setting turns it on too). Prototype transitions still play.
|
||||
- `pref.autoSave {on?}`: files that have a path save themselves a few seconds after they change
|
||||
(off by default; the desktop app's timer does it, so scripts still call `file.save`).
|
||||
- `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system":
|
||||
the OS or browser language; a language without a catalog shows English). Command ids, labels and
|
||||
every result stay English.
|
||||
@@ -1090,6 +1092,11 @@ only: a host stops hosting it, a guest leaves it.
|
||||
pointer. Your selection, page and view go out by themselves when they change.
|
||||
- `collab.follow {client}` follows that person's viewport; `collab.follow {}` stops (so does any
|
||||
pan or zoom of your own, or Escape).
|
||||
- `collab.removePerson {client}` removes that person from the session: they are told the session
|
||||
is over for them (they keep their copy and don't reconnect), and their address can't join the
|
||||
file again while the session runs. Only the person hosting, or the owner of a
|
||||
file on a server (with its owner token), can; a server older than this request says it can't.
|
||||
Returns `{removed, name}`.
|
||||
- `collab.cursorChat {text}` shows `text` next to your cursor for everyone in the session (put the
|
||||
cursor somewhere first with `collab.cursor`): at most 100 characters, control characters become
|
||||
spaces, `""` closes it. Others see it fade 5 seconds after its last change, so send it again to
|
||||
@@ -1515,6 +1522,29 @@ Align and distribute as one undo step:
|
||||
{"command":"distribute.horizontal","params":{"ids":[3,4,5]}}]}}}
|
||||
```
|
||||
|
||||
The end-to-end tests in `crates/mcp/tests/agent_tasks.rs` are worked examples of ten realistic
|
||||
tasks (auto layout, components and overrides, gradients and shadows, paint styles, Light/Dark
|
||||
variables, align/distribute/group, boolean subtract + flatten, export, undo/redo, drawing with tools).
|
||||
### Step references
|
||||
|
||||
A step in a `batch` can use what an earlier step returned. `{"$step": n}` anywhere in a step's
|
||||
params (a value, an item in `ids`, a nested field) is replaced by the `id` in the result of step `n`
|
||||
(zero-based) before the step runs; `{"$step": n, "field": "name"}` takes another field of that
|
||||
result. This builds a nested tree in one call and one undo step, without guessing ids:
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"batch","arguments":{"label":"Card","commands":[
|
||||
{"command":"node.create","params":{"type":"frame","name":"Card","props":{"layout":"vertical","gap":12}}},
|
||||
{"command":"node.create","params":{"type":"text","parent":{"$step":0},"text":"Ada Lovelace"}},
|
||||
{"command":"node.create","params":{"type":"frame","parent":{"$step":0},"name":"Button"}},
|
||||
{"command":"node.create","params":{"type":"text","parent":{"$step":2},"text":"Follow"}}]}}}
|
||||
```
|
||||
|
||||
A reference to the same or a later step, a negative or non-integer index, a missing `field` or any
|
||||
other key next to `$step` fails the whole batch, which is rolled back. Only an object whose keys are
|
||||
`$step` (and optionally `field`) is a reference: text such as `"$step 0"` is left as written. The
|
||||
`$step` key is reserved in step params: an object holding it anywhere (in plugin data or a variable
|
||||
value too) is read as a reference. A step that is itself a `command.batch` is passed on as written,
|
||||
so the references in its steps are to its own steps, not the outer batch's.
|
||||
|
||||
The end-to-end tests in `crates/mcp/tests/agent_tasks.rs` are worked examples of realistic tasks
|
||||
(auto layout, a card built in one batch with step references, components and overrides, gradients
|
||||
and shadows, paint styles, Light/Dark variables, align/distribute/group, boolean subtract + flatten,
|
||||
export, undo/redo, drawing with tools).
|
||||
|
||||
Reference in New Issue
Block a user