Merge upstream/main into fix/selection-colors-single-2188

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Trevor Brown
2026-11-11 11:39:44 -05:00
co-authored by Claude Opus 5.5
17 changed files with 610 additions and 45 deletions
+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
+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");
}
+3 -2
View File
File diff suppressed because one or more lines are too long
+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