Merge upstream/main into feat/annotation-tool-1840

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Trevor Brown
2026-11-11 11:39:56 -05:00
co-authored by Claude Opus 5.5
21 changed files with 621 additions and 50 deletions
+1 -1
View File
@@ -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
+4
View File
@@ -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)
}
+34 -4
View File
@@ -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");
}
}
}
+27
View File
@@ -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());
}
}
+6
View File
@@ -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);
+49
View File
@@ -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);
+22 -2
View File
@@ -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 {
+31
View File
@@ -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");
}
+2
View File
@@ -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;
+107
View File
@@ -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
View File
@@ -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;
+11 -3
View File
@@ -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
+3 -3
View File
@@ -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)
}
+55 -2
View File
@@ -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;
+49
View File
@@ -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");
}
+6
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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
+6
View File
@@ -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