mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-12 08:51:11 +00:00
Merge upstream/main into feat/annotation-tool-1840
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
607a196e35
21 files changed
+621
-50
No files matched your search
+1
-1
@@ -45,7 +45,7 @@ lines, a typical feature PR 1–2.5 h). Details and what needs a human:
|
||||
## Stage
|
||||
|
||||
**Alpha.** Core workflows exist end to end (frames, shapes, vectors, text, auto layout, components
|
||||
with variants and overrides, styles, variables, prototyping, Dev Mode, SVG/PNG/JPG/PDF), but
|
||||
with variants and overrides, styles, variables, prototyping, Developer Mode, SVG/PNG/JPG/PDF), but
|
||||
FigCraft opens `.fig` files only best effort, has only a preview of multiplayer, and nothing has been checked
|
||||
against real Figma documents. Ready-for-real-work is ~53%, inside alpha's ~40–75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is unproven on real files, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs
|
||||
~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured
|
||||
|
||||
@@ -220,6 +220,7 @@ impl LiveLink for NetLink {
|
||||
let (server, query) = Endpoint::parse(&o.server)?;
|
||||
// Without a key: as anyone may, when the server is open to the public (it says if not).
|
||||
let key = requests::server_key(o.key, query);
|
||||
requests::key_over_tls(&server, key.as_deref())?;
|
||||
// Until the upload is done the invite names only the server.
|
||||
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, None, &o.me);
|
||||
let inbox = self.sessions.inbox();
|
||||
@@ -342,6 +343,7 @@ fn upload(o: PublishOptions, limits: &NetLimits) -> Result<Published, String> {
|
||||
let (server, query) = Endpoint::parse(&o.server)?;
|
||||
let key = requests::server_key(o.key, query)
|
||||
.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?;
|
||||
requests::key_over_tls(&server, Some(&key))?;
|
||||
upload_file(&server, Some(&key), &o.name, &o.doc, limits, CONNECT).map(|(_, published, _)| published)
|
||||
}
|
||||
|
||||
@@ -358,6 +360,7 @@ fn hosting(server: &str, file: &str, owner: &str, req: Option<Admin>, limits: &N
|
||||
fn share(server: &str, file: &str, owner: &str, revoke: &[String], create: bool, limits: &NetLimits) -> Result<Option<Shared>, String> {
|
||||
let (endpoint, query) = Endpoint::parse(server)?;
|
||||
let key = requests::server_key(None, query);
|
||||
requests::key_over_tls(&endpoint, key.as_deref())?;
|
||||
manage::share(&endpoint, key.as_deref(), file, owner, revoke, create, limits, CONNECT)
|
||||
}
|
||||
|
||||
@@ -365,6 +368,7 @@ fn share(server: &str, file: &str, owner: &str, revoke: &[String], create: bool,
|
||||
fn plugins(server: &str, req: PluginReq, limits: &NetLimits) -> Result<PluginReply, String> {
|
||||
let (server, query) = Endpoint::parse(server)?;
|
||||
let key = query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v).unwrap_or_default();
|
||||
requests::key_over_tls(&server, Some(&key))?;
|
||||
manage::plugins(&server, key.trim(), req, limits, CONNECT)
|
||||
}
|
||||
|
||||
|
||||
@@ -242,18 +242,30 @@ pub fn shutdown(s: &TcpStream) {
|
||||
/// Connect to `server` (through TLS for `wss://`): the reader's and the writer's handles, and the
|
||||
/// socket to shut it down with.
|
||||
pub fn open(server: &Endpoint, timeout: Duration, limits: &NetLimits) -> Result<(Duplex, Duplex, TcpStream), String> {
|
||||
let stream = dial(&server.host, timeout, limits)?;
|
||||
// A plain `ws://localhost` is trusted as this machine (a key may go over it), so only loopback
|
||||
// addresses are connected to: a resolver mapping the name elsewhere must not carry it off-machine.
|
||||
let stream = dial(&server.host, !server.secure && server.loopback(), timeout, limits)?;
|
||||
let tls = if server.secure { Some(tls::handshake(&stream, &server.host)?) } else { None };
|
||||
Duplex::split(stream, tls).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Connect to `host` (`name:port`), trying each of its addresses within `timeout`, with Nagle off
|
||||
/// and reads and writes bounded by `limits`' hello and write timeouts.
|
||||
fn dial(host: &str, timeout: Duration, limits: &NetLimits) -> Result<TcpStream, String> {
|
||||
/// Whether `a` may be connected to: any address, or with `loopback_only` just this machine's.
|
||||
fn dialable(a: &std::net::SocketAddr, loopback_only: bool) -> bool {
|
||||
!loopback_only || a.ip().is_loopback()
|
||||
}
|
||||
|
||||
/// Connect to `host` (`name:port`), trying each of its addresses within `timeout` (only loopback
|
||||
/// ones with `loopback_only`), with Nagle off and reads and writes bounded by `limits`' hello and
|
||||
/// write timeouts.
|
||||
fn dial(host: &str, loopback_only: bool, timeout: Duration, limits: &NetLimits) -> Result<TcpStream, String> {
|
||||
use std::net::ToSocketAddrs;
|
||||
let addrs = host.to_socket_addrs().map_err(|e| format!("can't find {host}: {e}"))?;
|
||||
let mut last = format!("can't find {host}");
|
||||
for a in addrs {
|
||||
if !dialable(&a, loopback_only) {
|
||||
last = format!("{host} resolves to {a}, which is not this machine");
|
||||
continue;
|
||||
}
|
||||
match TcpStream::connect_timeout(&a, timeout) {
|
||||
Ok(s) => {
|
||||
let _ = s.set_nodelay(true);
|
||||
@@ -318,3 +330,21 @@ impl Bucket {
|
||||
self.tokens = (self.tokens + n).min(self.burst);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dial_tests {
|
||||
use super::dialable;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
#[test]
|
||||
fn a_loopback_only_dial_skips_addresses_off_this_machine() {
|
||||
let at = |s: &str| s.parse::<SocketAddr>().unwrap();
|
||||
for a in ["127.0.0.1:9", "[::1]:9", "127.5.5.5:9"] {
|
||||
assert!(dialable(&at(a), true), "{a}");
|
||||
}
|
||||
for a in ["10.0.0.5:9", "203.0.113.7:9", "[2001:db8::1]:9"] {
|
||||
assert!(!dialable(&at(a), true), "{a}");
|
||||
assert!(dialable(&at(a), false), "{a} when anywhere is allowed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,15 @@ pub(crate) fn server_key(given: Option<String>, query: Vec<(String, String)>) ->
|
||||
given.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Refuse to send the server's key (its whole administrative authority, reusable) in the clear:
|
||||
/// `ws://` is only for a server on this machine; anywhere else needs `wss://`.
|
||||
pub(crate) fn key_over_tls(server: &Endpoint, key: Option<&str>) -> Result<(), String> {
|
||||
if key.is_some_and(|k| !k.trim().is_empty()) && !server.secure && !server.loopback() {
|
||||
return Err("the server's key is only sent over wss:// (or ws:// to this machine): it would be readable on the network".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Why asking `server` about its hosting failed with `e`: a server older than open hosting can't
|
||||
/// read the question.
|
||||
pub(crate) fn hosting_error(server: &Endpoint, e: String) -> String {
|
||||
@@ -145,3 +154,21 @@ pub(crate) fn hosted(server: &Endpoint, reply: Option<AdminReply>) -> Option<Adm
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod key_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_key_never_goes_over_plain_tcp_to_another_machine() {
|
||||
let at = |url: &str| Endpoint::parse(url).unwrap().0;
|
||||
assert!(key_over_tls(&at("ws://share.example:9000/"), Some("k")).is_err());
|
||||
assert!(key_over_tls(&at("ws://10.0.0.5:9000/"), Some("k")).is_err());
|
||||
assert!(key_over_tls(&at("wss://share.example/"), Some("k")).is_ok());
|
||||
assert!(key_over_tls(&at("ws://127.0.0.1:9000/"), Some("k")).is_ok());
|
||||
assert!(key_over_tls(&at("ws://localhost:9000/"), Some("k")).is_ok());
|
||||
// Browsing or publishing as anyone sends no key, so plain is fine.
|
||||
assert!(key_over_tls(&at("ws://share.example:9000/"), None).is_ok());
|
||||
assert!(key_over_tls(&at("ws://share.example:9000/"), Some(" ")).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -268,10 +268,16 @@ impl FigApp {
|
||||
return;
|
||||
}
|
||||
if !self.has_files() || self.ui.home {
|
||||
let docs = &self.session.docs;
|
||||
self.canvas.drop_closed(|uid| docs.iter().any(|d| d.uid == uid));
|
||||
home::show(self, ui);
|
||||
// The shortcuts panel belongs to the canvas; it doesn't hang over the home screen
|
||||
// (cleared after the home widgets, which may have just asked for it).
|
||||
self.ui.shortcuts_panel = false;
|
||||
// The keymap runs with the canvas; here the one shortcut that still applies is read directly.
|
||||
if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Comma)) {
|
||||
self.run("app.preferences", serde_json::json!({}));
|
||||
}
|
||||
dialogs::keys(self, &ctx);
|
||||
dialogs::show(self, &ctx);
|
||||
updates_ui::show(self, &ctx);
|
||||
|
||||
@@ -57,6 +57,10 @@ pub struct CanvasState {
|
||||
pub tex_coarse: bool,
|
||||
/// The request the texture answers ([`crate::render_worker::Done::seq`]).
|
||||
tex_seq: u64,
|
||||
/// Refined images of the files and pages shown before, newest first: switching back to one
|
||||
/// shows its image at once while it renders again, instead of a blank canvas (and, where
|
||||
/// renders are slow, a coarse preview).
|
||||
kept: Vec<Kept>,
|
||||
submitted: Option<JobKey>,
|
||||
/// The view of the last render asked for.
|
||||
want_view: Option<figcraft_render::View>,
|
||||
@@ -98,6 +102,7 @@ impl Default for CanvasState {
|
||||
tex_of: None,
|
||||
tex_coarse: false,
|
||||
tex_seq: 0,
|
||||
kept: Vec::new(),
|
||||
submitted: None,
|
||||
want_view: None,
|
||||
render_work: None,
|
||||
@@ -119,6 +124,45 @@ impl Default for CanvasState {
|
||||
}
|
||||
}
|
||||
|
||||
/// At most this many images of files and pages not shown are kept (each is a canvas-sized texture).
|
||||
const KEPT: usize = 4;
|
||||
|
||||
/// A refined canvas image of a file and page not shown now ([`CanvasState::kept`]).
|
||||
struct Kept {
|
||||
texture: egui::TextureHandle,
|
||||
view: figcraft_render::View,
|
||||
of: (u64, NodeId),
|
||||
seq: u64,
|
||||
}
|
||||
|
||||
impl CanvasState {
|
||||
/// When the file or page shown is no longer the one the texture is of, set the texture aside
|
||||
/// (if refined) and bring back the image kept for `shown`, if any. Images of files that are no
|
||||
/// longer open (`open` says which are) are dropped every frame, not only on a switch: a
|
||||
/// background tab can close while the file shown stays the same.
|
||||
fn switch_texture(&mut self, shown: Option<(u64, NodeId)>, open: impl Fn(u64) -> bool) {
|
||||
if shown.is_some() && self.tex_of != shown {
|
||||
let back = self.kept.iter().position(|k| Some(k.of) == shown).map(|i| self.kept.remove(i));
|
||||
let (texture, view, of) = (self.texture.take(), self.tex_view.take(), self.tex_of.take());
|
||||
if let (Some(texture), Some(view), Some(of), false) = (texture, view, of, self.tex_coarse) {
|
||||
self.kept.insert(0, Kept { texture, view, of, seq: self.tex_seq });
|
||||
}
|
||||
self.tex_coarse = false;
|
||||
if let Some(k) = back {
|
||||
(self.texture, self.tex_view, self.tex_of, self.tex_seq) = (Some(k.texture), Some(k.view), Some(k.of), k.seq);
|
||||
}
|
||||
}
|
||||
self.drop_closed(open);
|
||||
self.kept.truncate(KEPT);
|
||||
}
|
||||
|
||||
/// Drop the kept images of files that are no longer open (`open` says which are). Home calls
|
||||
/// this too: the canvas isn't shown there, and closing the last file leads to it.
|
||||
pub(crate) fn drop_closed(&mut self, open: impl Fn(u64) -> bool) {
|
||||
self.kept.retain(|k| open(k.of.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// What the canvas shows while an IME composition is in progress: the document with the
|
||||
/// composition inserted at the caret (the real document only gets it on commit).
|
||||
pub struct PreeditView {
|
||||
@@ -264,6 +308,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
||||
// Every finished render is shown, even when a newer one is already on its way: renders finish
|
||||
// in order, and an edit every frame (a drag) would otherwise never show until it stopped.
|
||||
let shown = app.session.ds().map(|ds| (ds.uid, ds.page)).ok();
|
||||
let docs = &app.session.docs;
|
||||
app.canvas.switch_texture(shown, |uid| docs.iter().any(|d| d.uid == uid));
|
||||
if let Some(done) = worker.poll()
|
||||
&& shows(&app.canvas, &done, shown)
|
||||
{
|
||||
@@ -408,3 +454,6 @@ mod wheel_zoom_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod zoom_preview_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tab_switch_tests;
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Switching tabs: the canvas shows the image a file had when it was last on screen from the first
|
||||
//! frame back, instead of a blank page (and, where renders are slow, a coarse preview) while the
|
||||
//! file renders again.
|
||||
|
||||
use egui::{Pos2, RawInput, Rect, vec2};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::idle_tests::wait_for_render;
|
||||
use crate::{FigApp, NoServices};
|
||||
|
||||
/// The whole UI, driven headless, with files of their own names open.
|
||||
struct Harness {
|
||||
ctx: egui::Context,
|
||||
app: FigApp,
|
||||
time: f64,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
/// The UI with a file named after each of `names` (each with a rectangle); the last is shown.
|
||||
fn new(names: &[&str]) -> Self {
|
||||
figcraft_text::scan_system_fonts();
|
||||
let mut s = Session::empty();
|
||||
for n in names {
|
||||
s.execute("file.new", &json!({"name": n})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 200, "height": 120})).unwrap();
|
||||
}
|
||||
Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0 }
|
||||
}
|
||||
|
||||
/// One frame with no input, 1/60 s after the last.
|
||||
fn frame(&mut self) {
|
||||
self.time += 1.0 / 60.0;
|
||||
self.app.clock.set_test_now(Some(self.time));
|
||||
let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), ..Default::default() };
|
||||
let app = &mut self.app;
|
||||
let mut out = self.ctx.run_ui(input, |ui| app.ui(ui));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// Frames until the canvas shows a refined image of the file and page on screen.
|
||||
fn settle(&mut self) {
|
||||
for _ in 0..200 {
|
||||
self.frame();
|
||||
wait_for_render(&self.app);
|
||||
let ds = self.app.session.ds().unwrap();
|
||||
let c = &self.app.canvas;
|
||||
if c.tex_of == Some((ds.uid, ds.page)) && !c.tex_coarse && c.texture.is_some() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("the canvas never showed a refined image");
|
||||
}
|
||||
|
||||
/// Show the file named `name`, as clicking its tab does.
|
||||
fn switch(&mut self, name: &str) {
|
||||
let i = self.app.session.docs.iter().position(|d| d.doc.name == name).unwrap();
|
||||
self.app.run("doc.switch", json!({"index": i}));
|
||||
}
|
||||
|
||||
/// The file and page on screen.
|
||||
fn shown(&self) -> (u64, figcraft_doc::NodeId) {
|
||||
let ds = self.app.session.ds().unwrap();
|
||||
(ds.uid, ds.page)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_back_to_a_tab_shows_its_image_on_the_first_frame() {
|
||||
let mut h = Harness::new(&["A", "B"]);
|
||||
h.switch("A");
|
||||
h.settle();
|
||||
let a = (h.shown(), h.app.canvas.tex_seq, h.app.canvas.texture.as_ref().map(egui::TextureHandle::id));
|
||||
h.switch("B");
|
||||
h.settle();
|
||||
let b = (h.shown(), h.app.canvas.tex_seq);
|
||||
assert_ne!(h.app.canvas.tex_of, Some(a.0));
|
||||
|
||||
// The image brought back is the one A had when it was last shown. Looked at without a frame:
|
||||
// in one, a render of A asked for in it could already have come back and replaced it.
|
||||
let c = &mut h.app.canvas;
|
||||
c.switch_texture(Some(a.0), |_| true);
|
||||
assert_eq!((c.tex_of, c.tex_seq, c.texture.as_ref().map(egui::TextureHandle::id)), (Some(a.0), a.1, a.2));
|
||||
c.switch_texture(Some(b.0), |_| true);
|
||||
assert_eq!((c.tex_of, c.tex_seq), (Some(b.0), b.1), "B's image was kept in turn");
|
||||
|
||||
h.switch("A");
|
||||
h.frame();
|
||||
let c = &h.app.canvas;
|
||||
assert_eq!(c.tex_of, Some(a.0), "A's image is on screen at once");
|
||||
assert!(!c.tex_coarse, "and it is the refined one, not a preview");
|
||||
assert!(c.kept.iter().any(|k| k.of == b.0), "B's image is kept in turn");
|
||||
h.settle();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_back_to_a_page_shows_its_image_on_the_first_frame() {
|
||||
let mut h = Harness::new(&["A"]);
|
||||
h.settle();
|
||||
let first = (h.shown(), h.app.canvas.tex_seq, h.app.canvas.texture.as_ref().map(egui::TextureHandle::id));
|
||||
h.app.run("page.add", json!({}));
|
||||
h.app.run("shape.ellipse", json!({"x": 40, "y": 40, "width": 120, "height": 120}));
|
||||
h.settle();
|
||||
let second = (h.shown(), h.app.canvas.tex_seq);
|
||||
assert_ne!(first.0, second.0);
|
||||
|
||||
// Looked at without a frame, as for tabs: a render asked for in one could replace the image.
|
||||
let c = &mut h.app.canvas;
|
||||
c.switch_texture(Some(first.0), |_| true);
|
||||
assert_eq!((c.tex_of, c.tex_seq, c.texture.as_ref().map(egui::TextureHandle::id)), (Some(first.0), first.1, first.2));
|
||||
c.switch_texture(Some(second.0), |_| true);
|
||||
assert_eq!((c.tex_of, c.tex_seq), (Some(second.0), second.1), "the second page's image was kept in turn");
|
||||
|
||||
h.app.run("page.select", json!({"index": 0}));
|
||||
h.frame();
|
||||
let c = &h.app.canvas;
|
||||
assert_eq!(c.tex_of, Some(first.0), "the first page's image is on screen at once");
|
||||
assert!(!c.tex_coarse);
|
||||
assert!(c.kept.iter().any(|k| k.of == second.0), "the second page's image is kept in turn");
|
||||
h.settle();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_images_are_bounded_and_dropped_with_their_files() {
|
||||
let names = ["A", "B", "C", "D", "E", "F", "G"];
|
||||
let mut h = Harness::new(&names);
|
||||
for n in names {
|
||||
h.switch(n);
|
||||
h.settle();
|
||||
}
|
||||
assert_eq!(h.app.canvas.kept.len(), super::KEPT);
|
||||
|
||||
let f = h.app.session.docs.iter().position(|d| d.doc.name == "F").unwrap();
|
||||
let f_uid = h.app.session.docs[f].uid;
|
||||
assert!(h.app.canvas.kept.iter().any(|k| k.of.0 == f_uid));
|
||||
// A tab closed in the background: the file shown stays the same.
|
||||
let before = h.shown();
|
||||
h.app.run("file.close", json!({"index": f, "force": true}));
|
||||
h.frame();
|
||||
assert_eq!(h.shown(), before);
|
||||
assert!(h.app.canvas.kept.iter().all(|k| k.of.0 != f_uid), "a closed file's image goes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_the_last_file_drops_its_kept_images() {
|
||||
let mut h = Harness::new(&["A", "B"]);
|
||||
h.switch("A");
|
||||
h.settle();
|
||||
h.switch("B");
|
||||
h.settle();
|
||||
assert_eq!(h.app.canvas.kept.len(), 1, "A's image is kept");
|
||||
// Both close before the next frame, which then shows Home, not the canvas.
|
||||
let a = h.app.session.docs.iter().position(|d| d.doc.name == "A").unwrap();
|
||||
h.app.run("file.close", json!({"index": a, "force": true}));
|
||||
h.app.run("file.close", json!({"index": 0, "force": true}));
|
||||
assert!(h.app.session.docs.is_empty());
|
||||
h.frame();
|
||||
assert!(h.app.canvas.kept.is_empty(), "no closed file's image stays behind Home");
|
||||
}
|
||||
@@ -23,8 +23,12 @@ use crate::render_worker::{
|
||||
Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Refined, Renders, SETTLE_MS, Worker, better, coarse_view, preview_with, render_now,
|
||||
};
|
||||
|
||||
/// A page of ellipses with a rectangle on top; the session and the rectangle.
|
||||
/// A page of ellipses with a rectangle on top; the session and the rectangle. The system fonts are
|
||||
/// scanned first (once per process, and a concurrent caller waits for it): the scan another test
|
||||
/// starts bumps the global font generation, and a render kept from before it is stale, so a pan
|
||||
/// or an edit meanwhile would redraw in full instead of patching.
|
||||
fn document() -> (Session, NodeId) {
|
||||
figcraft_text::scan_system_fonts();
|
||||
let mut s = Session::new();
|
||||
for k in 0..12 {
|
||||
let k = f64::from(k);
|
||||
|
||||
@@ -190,8 +190,8 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the Home sidebar: app icon and wordmark, file/template search (Escape clears it)
|
||||
/// and section tabs; choosing a section clears the file query.
|
||||
/// Draw the Home sidebar: app icon and wordmark, file/template search (Escape clears it), section
|
||||
/// tabs (choosing one clears the file query) and Preferences at the bottom.
|
||||
fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) {
|
||||
let mut y = left.min.y + 20.0;
|
||||
crate::theme::paint_app_icon(ui, Rect::from_min_size(pos2(left.min.x + 12.0, y - 4.0), vec2(32.0, 32.0)));
|
||||
@@ -229,6 +229,26 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) {
|
||||
}
|
||||
y += 34.0;
|
||||
}
|
||||
let pref_y = (left.max.y - 44.0).max(y + 12.0);
|
||||
if pref_y > y + 12.0 {
|
||||
ui.painter().line_segment([pos2(left.min.x + 12.0, pref_y - 6.0), pos2(left.max.x - 12.0, pref_y - 6.0)], Stroke::new(1.0, t.border));
|
||||
}
|
||||
let r = Rect::from_min_size(pos2(left.min.x + 8.0, pref_y), vec2(SIDEBAR - 16.0, 32.0));
|
||||
let mut resp = ui.interact(r, ui.scope_id().with("home_preferences"), Sense::click());
|
||||
let current = app.ui.preferences;
|
||||
a11y::button(&resp, tr("Preferences"));
|
||||
if current || resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), if current { t.selected_row } else { t.hover });
|
||||
}
|
||||
focus_ring(ui, t, r, &resp, 5);
|
||||
icons::paint(ui.painter(), Rect::from_min_size(pos2(r.min.x + 10.0, r.center().y - 8.0), vec2(16.0, 16.0)), Icon::Adjust, t.icon);
|
||||
ui.painter().text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, tr("Preferences"), regular(12.0), t.text);
|
||||
let key_label = figcraft_engine::keymap::display("Cmd+,", crate::shortcuts::platform());
|
||||
ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, &key_label, regular(11.0), t.text_secondary);
|
||||
resp = resp.on_hover_text(format!("{} ({})", tr("Preferences"), key_label));
|
||||
if resp.clicked() {
|
||||
app.ui.preferences = !app.ui.preferences;
|
||||
}
|
||||
}
|
||||
|
||||
fn file_matches(name: &str, location: &str, query: &str) -> bool {
|
||||
|
||||
@@ -917,3 +917,34 @@ fn a_jam_file_is_a_document_open_and_drops_take() {
|
||||
assert!(crate::open::opens(path) && crate::open::is_document(path), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The Preferences dialog can be opened and toggled from the home screen via
|
||||
/// the `Cmd/Ctrl + ,` shortcut as well as by clicking Preferences in the sidebar.
|
||||
#[test]
|
||||
fn home_screen_preferences_shortcut_and_sidebar_access() {
|
||||
let mut u = first_run();
|
||||
u.app.ui.home = true;
|
||||
u.frame(vec![]);
|
||||
assert!(!u.app.ui.preferences);
|
||||
|
||||
// Ctrl/Cmd + Comma opens Preferences on the Home screen.
|
||||
u.frame(vec![
|
||||
Event::Key { key: Key::Comma, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND },
|
||||
Event::Key { key: Key::Comma, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::COMMAND },
|
||||
]);
|
||||
assert!(u.app.ui.preferences, "Ctrl/Cmd+, should open Preferences from the Home screen");
|
||||
|
||||
// Escape closes Preferences.
|
||||
press(&mut u, Key::Escape);
|
||||
assert!(!u.app.ui.preferences, "Escape should close Preferences");
|
||||
|
||||
// Clicking Preferences in the sidebar opens Preferences.
|
||||
let s = shapes(&mut u);
|
||||
click(&mut u, text_center(&s, "Preferences"));
|
||||
assert!(u.app.ui.preferences, "Clicking Preferences in sidebar should open Preferences");
|
||||
|
||||
// Clicking it again toggles it closed.
|
||||
let s = shapes(&mut u);
|
||||
click(&mut u, text_center(&s, "Preferences"));
|
||||
assert!(!u.app.ui.preferences, "Clicking Preferences in sidebar again should close it");
|
||||
}
|
||||
@@ -180,6 +180,8 @@ mod menu_contrast_tests;
|
||||
#[cfg(test)]
|
||||
mod menu_input_tests;
|
||||
#[cfg(test)]
|
||||
mod menu_overflow_tests;
|
||||
#[cfg(test)]
|
||||
mod mirror_tests;
|
||||
#[cfg(test)]
|
||||
mod missing_fonts_tests;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! The main menu and its submenus stay inside the window (#2217): a submenu that would run past
|
||||
//! the bottom moves up to fit, and one taller than the window scrolls.
|
||||
|
||||
use egui::{Event, Id, MouseWheelUnit, Rect, TouchPhase, pos2, vec2};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::layers_drag_tests::Ui;
|
||||
use crate::live_tests::{flat, text_center};
|
||||
use crate::menus::{HOVER_DELAY, main_menu_id};
|
||||
use crate::present_tests::id;
|
||||
|
||||
/// A window `h` tall with the main menu open and the Object submenu showing.
|
||||
fn object_menu_open(s: Session, h: f32) -> Ui {
|
||||
let mut u = Ui::new(s);
|
||||
u.screen = vec2(1280.0, h);
|
||||
u.app.ui.main_menu = true;
|
||||
u.frame(vec![Event::PointerMoved(pos2(1000.0, 10.0))]);
|
||||
u.frame(vec![]);
|
||||
let m = u.ctx.memory(|m| m.area_rect(main_menu_id())).expect("main menu drawn");
|
||||
// Frame margin, the Actions row and the space under it, then three rows down to Object.
|
||||
let y = m.min.y + 8.0 + 30.0 + 6.0 + 28.0 * 3.0 + 14.0;
|
||||
u.frame(vec![Event::PointerMoved(pos2(m.min.x + 60.0, y))]);
|
||||
for _ in 0..((HOVER_DELAY + 0.3) * 60.0) as usize {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
u
|
||||
}
|
||||
|
||||
fn object_panel(u: &Ui) -> Rect {
|
||||
u.ctx.memory(|m| m.area_rect(Id::unique(("menu", "Object")))).expect("the Object submenu is drawn")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_object_submenu_stays_inside_a_short_window() {
|
||||
let u = object_menu_open(Session::new(), 600.0);
|
||||
let panel = object_panel(&u);
|
||||
let window = Rect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 600.0));
|
||||
assert!(window.contains_rect(panel), "the Object submenu {panel:?} runs outside the window {window:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_menu_taller_than_the_window_scrolls_to_its_last_command() {
|
||||
let mut s = Session::new();
|
||||
let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap());
|
||||
let b = id(&s.execute("shape.rectangle", &json!({"x": 100, "y": 30, "width": 20, "height": 20})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
let mut u = object_menu_open(s, 420.0);
|
||||
let panel = object_panel(&u);
|
||||
assert!(Rect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 420.0)).contains_rect(panel), "{panel:?} outside the window");
|
||||
// Turn the wheel over the panel until the end of the list is in view.
|
||||
for _ in 0..4 {
|
||||
let wheel = Event::MouseWheel {
|
||||
unit: MouseWheelUnit::Point,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
delta: vec2(0.0, -1500.0),
|
||||
phase: TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
};
|
||||
u.frame(vec![Event::PointerMoved(panel.center()), wheel]);
|
||||
for _ in 0..20 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
}
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
let at = text_center(&shapes, "Move to page");
|
||||
assert!(panel.contains(at), "the last commands are out of reach: at {at:?} outside {panel:?}");
|
||||
// And a command above it, reached by scrolling back up, runs on click.
|
||||
let wheel = Event::MouseWheel {
|
||||
unit: MouseWheelUnit::Point,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
delta: vec2(0.0, 1.0e5),
|
||||
phase: TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
};
|
||||
u.frame(vec![Event::PointerMoved(panel.center()), wheel]);
|
||||
for _ in 0..20 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
// Scroll down in small steps until "Send to back" shows inside the panel, then click it.
|
||||
let mut hit = None;
|
||||
for _ in 0..40 {
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
let at = text_center(&shapes, "Send to back");
|
||||
if panel.contains(at) {
|
||||
hit = Some(at);
|
||||
break;
|
||||
}
|
||||
let wheel = Event::MouseWheel {
|
||||
unit: MouseWheelUnit::Point,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
delta: vec2(0.0, -120.0),
|
||||
phase: TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
};
|
||||
u.frame(vec![Event::PointerMoved(panel.center()), wheel]);
|
||||
for _ in 0..10 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
}
|
||||
let at = hit.expect("Send to back scrolls into view");
|
||||
crate::component_swap_default_tests::click(&mut u, at);
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
let page = u.app.session.page().unwrap();
|
||||
let kids = &doc.node(page).unwrap().children;
|
||||
assert_eq!((kids.first().copied(), kids.last().copied()), (Some(b), Some(a)), "Send to back ran");
|
||||
}
|
||||
+40
-31
@@ -382,40 +382,49 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
|
||||
// Main menu.
|
||||
if app.ui.main_menu {
|
||||
let mut close = false;
|
||||
let area = egui::Area::new(main_menu_id()).fixed_pos(pos2(8.0, crate::tabs::HEIGHT + 48.0)).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
menu_frame(ui, &t, At { root: main_menu_id(), path: String::new() }, |ui| {
|
||||
// Quick actions search row.
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
|
||||
plain_row_hover(ui, &resp);
|
||||
a11y::menu_item(&resp, tr("Actions…"), crate::shortcuts::shortcut("app.actions").unwrap_or(""), None);
|
||||
ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(5), Color32::from_white_alpha(18));
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(14.0, 14.0)), Icon::Search, t.menu_dim);
|
||||
ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, tr("Actions…"), regular(12.0), t.menu_dim);
|
||||
ui.painter().text(
|
||||
pos2(r.max.x - 10.0, r.center().y),
|
||||
Align2::RIGHT_CENTER,
|
||||
pretty_shortcut(crate::shortcuts::shortcut("app.actions").unwrap_or("")),
|
||||
regular(12.0),
|
||||
t.menu_dim,
|
||||
);
|
||||
if resp.clicked() {
|
||||
set_palette(&mut app.ui, true);
|
||||
close = true;
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
for top in TOP_MENUS {
|
||||
if submenu_at(
|
||||
ui,
|
||||
SubmenuOptions { area: Id::unique(("menu", *top)), key: top, label: tr(top), gap: 12.0, constrain: false, tokens: &t },
|
||||
|ui| menu_level(app, ui, top).then_some(()),
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
let area = egui::Area::new(main_menu_id())
|
||||
.fixed_pos(pos2(8.0, crate::tabs::HEIGHT + 48.0))
|
||||
.order(egui::Order::Foreground)
|
||||
.constrain_to(tree::vertical_bounds(ctx))
|
||||
.show(ctx, |ui| {
|
||||
menu_frame(ui, &t, At { root: main_menu_id(), path: String::new() }, |ui| {
|
||||
// Quick actions search row.
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
|
||||
plain_row_hover(ui, &resp);
|
||||
a11y::menu_item(&resp, tr("Actions…"), crate::shortcuts::shortcut("app.actions").unwrap_or(""), None);
|
||||
ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(5), Color32::from_white_alpha(18));
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(14.0, 14.0)),
|
||||
Icon::Search,
|
||||
t.menu_dim,
|
||||
);
|
||||
ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, tr("Actions…"), regular(12.0), t.menu_dim);
|
||||
ui.painter().text(
|
||||
pos2(r.max.x - 10.0, r.center().y),
|
||||
Align2::RIGHT_CENTER,
|
||||
pretty_shortcut(crate::shortcuts::shortcut("app.actions").unwrap_or("")),
|
||||
regular(12.0),
|
||||
t.menu_dim,
|
||||
);
|
||||
if resp.clicked() {
|
||||
set_palette(&mut app.ui, true);
|
||||
close = true;
|
||||
}
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
for top in TOP_MENUS {
|
||||
if submenu_at(
|
||||
ui,
|
||||
SubmenuOptions { area: Id::unique(("menu", *top)), key: top, label: tr(top), gap: 12.0, constrain: false, tokens: &t },
|
||||
|ui| menu_level(app, ui, top).then_some(()),
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
close = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
let clicked_outside = ctx.input(|i| i.pointer.any_pressed()) && !over_menu(ctx) || ctx.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
if close || (clicked_outside && !area.response.hovered()) {
|
||||
app.ui.main_menu = false;
|
||||
|
||||
@@ -7,8 +7,8 @@ use crate::i18n::trf;
|
||||
/// Paste: the system clipboard wins when it holds something newer than our own copy (layers copied
|
||||
/// from another design app, an image, SVG or text from another app); otherwise our copied layers.
|
||||
/// `event_text` is the text the platform handed over with the paste event (the browser has no
|
||||
/// other way to read it). `at`: the page point to paste at (Paste here); `None` pastes in the
|
||||
/// middle of the view.
|
||||
/// other way to read it; the desktop's clipboard bridge has already read it). `at`: the page
|
||||
/// point to paste at (Paste here); `None` pastes in the middle of the view.
|
||||
/// In Dev Mode only a comment's link pastes (it opens the thread); everything else would edit.
|
||||
pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) {
|
||||
// A gesture injected through the control channel pastes only our own copied layers: it must not
|
||||
@@ -47,7 +47,15 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>)
|
||||
}
|
||||
return;
|
||||
}
|
||||
let sys_text = app.services.clipboard_get_text().or_else(|| event_text.map(str::to_string)).filter(|t| !t.is_empty());
|
||||
// The text the platform handed over with the paste event is the system clipboard's text: the
|
||||
// browser delivers it, and the desktop's async clipboard bridge read it while the window
|
||||
// thread was free to serve the transfer. Never re-read the clipboard synchronously for it —
|
||||
// on the desktop that read blocks the window thread for the read timeout, and when this app
|
||||
// itself owns the clipboard (a self-paste) its own source can only be served by that very
|
||||
// thread, so the read waits the full timeout and the window freezes for seconds (#2021).
|
||||
// A paste the platform didn't hand text over (a chord, an image, the menu) reads it, as before.
|
||||
let sys_text =
|
||||
if let Some(t) = event_text { (!t.is_empty()).then(|| t.to_string()) } else { app.services.clipboard_get_text().filter(|t| !t.is_empty()) };
|
||||
// The text matching `last_copy` means the system clipboard still holds what we mirrored only
|
||||
// while the engine clipboard is the one we mirrored. A control-channel copy changes the engine
|
||||
// clipboard without writing the system clipboard, so its generation no longer matches and the
|
||||
|
||||
@@ -241,9 +241,8 @@ fn draw_row(ui: &mut Ui, t: &Tokens, row: &ActionRow, highlighted: bool) -> egui
|
||||
resp
|
||||
}
|
||||
|
||||
/// Whether `label` (or `extra`) matches the query, in any case.
|
||||
/// What a search reads besides the label in the UI's language: the English label, the id, and a
|
||||
/// name the feature had before it was renamed ([`figcraft_engine::catalog::SEARCH_ALIASES`]).
|
||||
/// The text a search reads besides the label in the UI's language: the English label, the id, and
|
||||
/// a name the feature had before it was renamed ([`figcraft_engine::catalog::SEARCH_ALIASES`]).
|
||||
fn search_text(english: &str, id: &str) -> String {
|
||||
let mut text = format!("{english} {id}");
|
||||
for (alias_id, alias) in figcraft_engine::catalog::SEARCH_ALIASES {
|
||||
@@ -255,6 +254,7 @@ fn search_text(english: &str, id: &str) -> String {
|
||||
text
|
||||
}
|
||||
|
||||
/// Whether `label` (or `extra`) matches the query, in any case.
|
||||
fn matches(q: &str, label: &str, extra: &str) -> bool {
|
||||
q.is_empty() || label.to_lowercase().contains(q) || extra.to_lowercase().contains(q)
|
||||
}
|
||||
|
||||
@@ -289,6 +289,7 @@ pub(super) fn submenu_at<R>(ui: &mut Ui, options: SubmenuOptions<'_>, add: impl
|
||||
.fixed_pos(pos)
|
||||
.order(egui::Order::Foreground)
|
||||
.constrain(constrain || cramped)
|
||||
.constrain_to(if constrain || cramped { screen } else { vertical_bounds(ui.ctx()) })
|
||||
.show(ui.ctx(), |ui| menu_frame(ui, t, child, add));
|
||||
let panel = shown.response.rect;
|
||||
with_level(ui.ctx(), &at, |l| l.panel = Some(panel));
|
||||
@@ -316,12 +317,64 @@ pub(super) fn menu_frame<R>(ui: &mut Ui, t: &Tokens, at: At, add: impl FnOnce(&m
|
||||
ui.set_width(w);
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
|
||||
let key = at_key(ui);
|
||||
ui.data_mut(|d| d.insert_temp(key, at));
|
||||
add(ui)
|
||||
ui.data_mut(|d| d.insert_temp(key, at.clone()));
|
||||
// Taller than the window (as measured last frame): its rows scroll. Otherwise they
|
||||
// draw straight into the panel, so a menu that fits behaves as it always did.
|
||||
let max_h = (ui.ctx().content_rect().height() - 2.0 * EDGE_MARGIN - 16.0).max(MENU_ROW_H);
|
||||
let height_key = at_key(ui).with("content_h");
|
||||
let last_h = ui.data(|d| d.get_temp::<f32>(height_key)).unwrap_or(0.0);
|
||||
if last_h <= max_h {
|
||||
let top = ui.cursor().min.y;
|
||||
let r = add(ui);
|
||||
let h = ui.min_rect().max.y - top;
|
||||
ui.data_mut(|d| d.insert_temp(height_key, h));
|
||||
if h > max_h {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
return r;
|
||||
}
|
||||
// The scroll area's ui has its own scope, so it gets the level and width state of this
|
||||
// one, and hands back the width its rows asked for.
|
||||
let outer_w = width_key(ui);
|
||||
let asked = ui.data(|d| d.get_temp::<f32>(outer_w));
|
||||
let shown = egui::ScrollArea::vertical()
|
||||
.max_height(max_h)
|
||||
.auto_shrink([false, true])
|
||||
.scroll_source(egui::scroll_area::ScrollSource::MOUSE_WHEEL | egui::scroll_area::ScrollSource::SCROLL_BAR)
|
||||
.show(ui, |ui| {
|
||||
let (inner_at, inner_w) = (at_key(ui), width_key(ui));
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(inner_at, at);
|
||||
if let Some(a) = asked {
|
||||
d.insert_temp(inner_w, a);
|
||||
}
|
||||
});
|
||||
let r = add(ui);
|
||||
(r, ui.data(|d| d.get_temp::<f32>(inner_w)))
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(height_key, shown.content_size.y));
|
||||
let (r, wanted) = shown.inner;
|
||||
if let Some(wanted) = wanted.filter(|w| asked.is_none_or(|a| *w > a)) {
|
||||
ui.data_mut(|d| d.insert_temp(outer_w, wanted));
|
||||
}
|
||||
r
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
/// The gap a menu keeps from the window's top and bottom edges.
|
||||
pub(super) const EDGE_MARGIN: f32 = 8.0;
|
||||
|
||||
/// A menu row's height.
|
||||
const MENU_ROW_H: f32 = 28.0;
|
||||
|
||||
/// Where a menu may sit: anywhere across, within the window's height less the margins. A menu that
|
||||
/// would run past the bottom moves up (never above the top); one taller than that scrolls.
|
||||
pub(super) fn vertical_bounds(ctx: &egui::Context) -> Rect {
|
||||
let s = ctx.content_rect();
|
||||
Rect::from_min_max(pos2(-1.0e5, s.min.y + EDGE_MARGIN), pos2(1.0e5, s.max.y - EDGE_MARGIN))
|
||||
}
|
||||
|
||||
/// A menu's narrowest width.
|
||||
const MENU_W: f32 = 240.0;
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
//! whatever the system clipboard has. A copy that never reached the clipboard pasted the text that
|
||||
//! was there before as a text layer instead of the copied layers.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use egui::{Event, Key, Modifiers, OutputCommand, Pos2, RawInput, Rect, vec2};
|
||||
use figcraft_doc::NodeKind;
|
||||
use figcraft_engine::Session;
|
||||
@@ -223,3 +226,49 @@ fn a_mismatched_image_buffer_is_dropped() {
|
||||
assert!(out.platform_output.commands.is_empty(), "{w}x{h}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The system clipboard's text read, counted so a test can assert when it ran. The desktop's
|
||||
/// arboard read blocks the window thread up to `CLIPBOARD_TIMEOUT`; against the app's own Wayland
|
||||
/// source (a self-paste) it waits the full timeout — the whole UI freezes for seconds (#2021) —
|
||||
/// and its result is discarded in favour of the paste event's text. A paste whose platform event
|
||||
/// already carries the clipboard text must never perform that synchronous re-read.
|
||||
struct CountingReads {
|
||||
/// How often the synchronous text read was asked for.
|
||||
reads: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Services for CountingReads {
|
||||
fn clipboard_get_text(&mut self) -> Option<String> {
|
||||
self.reads.fetch_add(1, Ordering::Relaxed);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paste_event_its_own_text_never_triggers_the_synchronous_text_read() {
|
||||
// The platform (the desktop's async clipboard bridge, or the browser) already read the
|
||||
// clipboard and handed the text with the paste event.
|
||||
let reads = Arc::new(AtomicUsize::new(0));
|
||||
let mut s = Session::new();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
let mut p = Page {
|
||||
ctx: egui::Context::default(),
|
||||
app: FigApp::new(s, Box::new(CountingReads { reads: reads.clone() })),
|
||||
time: 0.0,
|
||||
clipboard: String::new(),
|
||||
};
|
||||
for _ in 0..3 {
|
||||
p.frame(vec![]);
|
||||
}
|
||||
|
||||
// The desktop delivers the copied layers' SVG as the paste event's text.
|
||||
let text = "<svg width=\"100\" height=\"50\"><rect/></svg>";
|
||||
p.frame(vec![Page::key(Key::V, true), Event::Paste(text.to_string())]);
|
||||
p.frame(vec![Page::key(Key::V, false)]);
|
||||
assert_eq!(reads.load(Ordering::Relaxed), 0, "the text came with the paste event: no blocking re-read of the system clipboard");
|
||||
|
||||
// Even a second paste must not re-read: the clipboard still holds the pasted text.
|
||||
p.frame(vec![Page::key(Key::V, true), Event::Paste(text.to_string())]);
|
||||
p.frame(vec![Page::key(Key::V, false)]);
|
||||
assert_eq!(reads.load(Ordering::Relaxed), 0, "still the event's text: no blocking re-read");
|
||||
}
|
||||
@@ -3,3 +3,9 @@
|
||||
FigCraft describes itself as an **interface design tool**, without the comparative Figma-class tag. Interface-size command help describes the size steps directly. Command IDs, file-format interoperability references, behavioral documentation and attribution remain unchanged.
|
||||
|
||||
Regression tests prevent the retired comparative display phrases from returning to MCP instructions or command help. This copy change makes no legal claim about the comparative terms and does not change behavior.
|
||||
|
||||
## Developer Mode
|
||||
|
||||
**Old label:** "Dev Mode". **New label:** "Developer Mode", since #2135.
|
||||
|
||||
Figma holds a US registration for DEV MODE ([US 7574348](https://tsdr.uspto.gov/statusview/sn98045640), registered 2024-11-19, owner Figma, Inc.), so neither a label nor a translation calls the mode by the old name. The identifiers keep their names (`view.devMode`, `devMode.*`), and so do the preference keys and saved document fields; the Actions palette still finds the command by the old name. Some translations had a generic "dev mode" where the proper name belongs (French "Mode Dev", Portuguese "Modo Dev", Dutch "Dev Mode"); they now read "Mode développeur", "Modo do desenvolvedor" and "Ontwikkelaarsmodus", so every language names the mode the same way. `crates/ui-egui/src/naming_tests.rs` keeps the retired name out of the labels and the catalogs.
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the Inspect tab then opens (never toggles away) with the caret in that layer's new-note field, and the engine never writes the note itself (#1840); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a booleaLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the Inspect tab then opens (never toggles away) with the caret in that layer's new-note field, and the engine never writes the note itself (#1840); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about thLine truncated
|
||||
|
||||
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
|
||||
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
|
||||
@@ -25,7 +25,7 @@ interaction feel not yet compared.
|
||||
| Actions palette (Cmd+K, Cmd+/) | done | tabs: All (every command, grouped by menu, or Other; a command that needs parameters only through its menu item, and none twice under one label, #1574), Assets (components and styles) and Plugins & widgets; results ranked prefix first, then shortest, at most 60 rows; Up/Down move a highlight and Enter runs it; Recents list what was run from the palette (matching translated labels); one open/toggle path from every entry point (`palette_tests`). Ranking and the Assets results not compared with Figma's |
|
||||
| Context menus | done | canvas and layers; submenus as in the main menu below, and a right-click in a new spot starts a fresh menu (Select layer closed); Shift+F10 or the keyboard's Menu key opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard (`layers_keyboard_tests`) |
|
||||
| Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder |
|
||||
| Main menu from the logo | done | generated from `catalog.rs`; popups sized to content. Submenus open after 0.15 s on their row (a click or the keyboard opens one at once) and close at once when another row is hovered; while the pointer heads for an open submenu inside the triangle to its near edge, the rows it crosses don't take over, and resting 0.3 s on one hands over; closing the menu, however it closes, clears which submenus were open, so it reopens with none (`menu_aim_tests`, 10 tests; timings tuned by feel, not measured against Figma). Menu and palette items that need a value ask for it first: Create link, Offset path and Move to page in a dialog of the shared controls, the language, user name, server and nudge items in Preferences, Open recent on the home screen's Recents; File ▸ Rename edits the file name in place |
|
||||
| Main menu from the logo | done | generated from `catalog.rs`; popups sized to content. Submenus open after 0.15 s on their row (a click or the keyboard opens one at once) and close at once when another row is hovered; while the pointer heads for an open submenu inside the triangle to its near edge, the rows it crosses don't take over, and resting 0.3 s on one hands over; closing the menu, however it closes, clears which submenus were open, so it reopens with none (`menu_aim_tests`, 10 tests; timings tuned by feel, not measured against Figma). A menu or submenu that would run past the window's bottom moves up to fit (never above the top), and one taller than the window scrolls with the wheel, the keyboard's focus kept in view; the context menu's submenus share this (#2217, `menu_overflow_tests`). Menu and palette items that need a value ask for it first: Create link, Offset path and Move to page in a dialog of the shared controls, the language, user name, server and nudge items in Preferences, Open recent on the home screen's Recents; File ▸ Rename edits the file name in place |
|
||||
| Keyboard shortcuts panel | done | closes when the home screen shows (#548); docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings; a label too long for its column ends with "…" and shows in full on hover |
|
||||
| Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored |
|
||||
| Multiplayer avatars, Share button | missing | waits for multiplayer |
|
||||
@@ -189,6 +189,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-09 | minor | Shortcuts: Shift+T picks the new Annotation tool (`tool.annotation {id?}`, Accessibility at Figma, Add an annotation): with a layer selected, or an `id`, it asks the UI for that layer's note at once; in the tool a press on a layer selects it and asks, a press on empty canvas does nothing; the Inspect tab opens with the caret in the new-note field and the engine writes nothing itself; documented shortcuts 137, bound 136 (#1840). Figma's placement, cursor and whether the tool stays selected are unmeasured (Developer Mode is paywalled in the web app); staying selected until V or Escape, like the Comment tool, is inferred, and there is no toolbar button or icon yet |
|
||||
| 2026-11-10 | patch | #2217: the main menu's submenus (Object, say) ran past the window's bottom, behind the taskbar, with the lowest commands out of reach, because submenus were not constrained to the window (to keep them beside their row). Every menu panel now scrolls when taller than the window less 8 pt margins, and a submenu is held within the window's height (moved up, never above the top) while keeping its place beside its row, so menu-aim is unchanged. Tests: `menu_overflow_tests.rs`. Numbers unchanged. |
|
||||
| 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` |
|
||||
| 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged |
|
||||
| 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. |
|
||||
|
||||
@@ -27,7 +27,7 @@ fi
|
||||
# Size gate: Cloudflare Pages/Workers reject any single file over 25 MiB, and other
|
||||
# hosts and CDNs have similar caps. Fail well before that so a regression shows up here, not at
|
||||
# upload time. Override with FIGCRAFT_WASM_MAX_BYTES only to investigate.
|
||||
MAX_WASM_BYTES="${FIGCRAFT_WASM_MAX_BYTES:-25165824}" # 24 MiB
|
||||
MAX_WASM_BYTES="${FIGCRAFT_WASM_MAX_BYTES:-26214400}" # 25 MiB (Cloudflare per-file cap)
|
||||
WASM_COUNT=0
|
||||
for wasm in "$SITE"/*.wasm; do
|
||||
[[ -f "$wasm" ]] || continue
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
sonar.projectKey=storytold_figcraft
|
||||
sonar.organization=storytold
|
||||
|
||||
# S4790 (weak hash): a .fig file names each image by the SHA-1 of its bytes (20 bytes, the only
|
||||
# length readers accept). It is an identifier the format fixes, not a security choice.
|
||||
sonar.issue.ignore.multicriteria=fig_image_sha1
|
||||
sonar.issue.ignore.multicriteria.fig_image_sha1.ruleKey=rust:S4790
|
||||
sonar.issue.ignore.multicriteria.fig_image_sha1.resourceKey=crates/format/src/fig/export.rs
|
||||
Reference in new issue
Block a user