mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-11 22:21:58 +00:00
Merge main into #1911
This commit is contained in:
+4
-2
File diff suppressed because one or more lines are too long
@@ -112,3 +112,6 @@ display_name = "Denis Gomes"
|
||||
[people.Hamza0101]
|
||||
real_name = "Hamza Naveed"
|
||||
display_name = "Hamza0101"
|
||||
|
||||
[people.ashokram-si]
|
||||
real_name = "Ashok Ram"
|
||||
|
||||
@@ -737,6 +737,9 @@ impl<B: Backend> Hub<B> {
|
||||
continue;
|
||||
}
|
||||
let Some(&conn) = room.ids.get(&id) else { continue };
|
||||
if matches!(msg, ServerMsg::Presence { .. }) && self.conns.get(&conn).is_some_and(|c| !c.out.takes_presence()) {
|
||||
continue;
|
||||
}
|
||||
let Some(b) = bytes.get_or_insert_with(|| encode(&msg)).clone() else { continue };
|
||||
if !deliver(&self.conns, conn, &b, &mut held, &mut stuck) {
|
||||
stuck.push(conn);
|
||||
|
||||
@@ -74,6 +74,8 @@ fn a_kicked_connection_with_a_full_queue_is_shut() {
|
||||
let out = Line {
|
||||
tx,
|
||||
queued: Arc::default(),
|
||||
waiting: Arc::default(),
|
||||
max_presence: 32,
|
||||
block: Arc::new(AtomicUsize::new(0)),
|
||||
all: Arc::new(AtomicUsize::new(0)),
|
||||
max: 1 << 20,
|
||||
|
||||
@@ -235,6 +235,8 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
|
||||
let out = Line {
|
||||
tx,
|
||||
queued: Arc::default(),
|
||||
waiting: Arc::default(),
|
||||
max_presence: limits.queue.max(4) / 2,
|
||||
block: block.queued.clone(),
|
||||
all: shared.queued.clone(),
|
||||
max: limits.max_queued_bytes,
|
||||
|
||||
@@ -48,6 +48,7 @@ pub(crate) struct Charges {
|
||||
pub(super) struct Charge {
|
||||
n: usize,
|
||||
mine: Arc<AtomicUsize>,
|
||||
waiting: Arc<AtomicUsize>,
|
||||
_all: Arc<Held>,
|
||||
_block: Option<Arc<Held>>,
|
||||
}
|
||||
@@ -55,6 +56,7 @@ pub(super) struct Charge {
|
||||
impl Drop for Charge {
|
||||
fn drop(&mut self) {
|
||||
self.mine.fetch_sub(self.n, Ordering::Relaxed);
|
||||
self.waiting.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +78,10 @@ pub(crate) enum Push {
|
||||
pub(crate) struct Line {
|
||||
pub(crate) tx: SyncSender<Out>,
|
||||
pub(crate) queued: Arc<AtomicUsize>,
|
||||
/// Messages waiting in the queue.
|
||||
pub(crate) waiting: Arc<AtomicUsize>,
|
||||
/// With this many waiting, presence is left out (see [`Line::takes_presence`]).
|
||||
pub(crate) max_presence: usize,
|
||||
pub(crate) block: Arc<AtomicUsize>,
|
||||
pub(crate) all: Arc<AtomicUsize>,
|
||||
pub(crate) max: usize,
|
||||
@@ -84,6 +90,15 @@ pub(crate) struct Line {
|
||||
}
|
||||
|
||||
impl Line {
|
||||
/// Whether presence goes in the queue now. A client still taking in a long message (the welcome
|
||||
/// of a large file on a slow link) would otherwise have its queue filled by everyone's cursors
|
||||
/// and be dropped, again on every try. Presence is latest-wins, so what's left out is made up
|
||||
/// for by the next update, and this keeps room for what can't be: other people's edits. A client
|
||||
/// that reads nothing at all still ends at the write timeout.
|
||||
pub(crate) fn takes_presence(&self) -> bool {
|
||||
self.waiting.load(Ordering::Relaxed) < self.max_presence
|
||||
}
|
||||
|
||||
/// Queue `b`. One message always fits an empty queue (a welcome as large as the largest file
|
||||
/// gets out). `charges` is what the message counts against, shared by its recipients;
|
||||
/// `over_budget` lets it past the server's budget (the hub just dropped the biggest consumer
|
||||
@@ -118,7 +133,8 @@ impl Line {
|
||||
}
|
||||
};
|
||||
self.queued.fetch_add(n, Ordering::Relaxed);
|
||||
let charge = Charge { n, mine: self.queued.clone(), _all: all, _block: block };
|
||||
self.waiting.fetch_add(1, Ordering::Relaxed);
|
||||
let charge = Charge { n, mine: self.queued.clone(), waiting: self.waiting.clone(), _all: all, _block: block };
|
||||
self.tx.try_send(Out::Bytes(b.clone(), Some(charge))).map_err(|e| match e {
|
||||
TrySendError::Full(_) => Push::Full,
|
||||
TrySendError::Disconnected(_) => Push::Gone,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The hub's send queues (no writers: nothing is ever sent, so what is queued stays queued): a
|
||||
//! broadcast counts once against the server's budget however many copies are queued, a block of
|
||||
//! addresses can't take more than its share, and when the server's budget is used up the
|
||||
//! connection holding the most is shed, not the next client that needs a little.
|
||||
//! connection holding the most is shed, not the next client that needs a little. Presence stays out
|
||||
//! of a busy queue.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, TcpListener, TcpStream};
|
||||
@@ -16,7 +17,20 @@ use super::{BlockUse, Blocks, Charges, Conn, Kind, Line, Out, Push, deliver};
|
||||
/// A line with its own queue counter, the given block counter and the server's.
|
||||
fn line(block: &Arc<AtomicUsize>, all: &Arc<AtomicUsize>, max_block: usize, max_all: usize) -> (Line, Receiver<Out>) {
|
||||
let (tx, rx) = sync_channel(64);
|
||||
(Line { tx, queued: Arc::default(), block: block.clone(), all: all.clone(), max: 1 << 20, max_block, max_all }, rx)
|
||||
(
|
||||
Line {
|
||||
tx,
|
||||
queued: Arc::default(),
|
||||
waiting: Arc::default(),
|
||||
max_presence: 32,
|
||||
block: block.clone(),
|
||||
all: all.clone(),
|
||||
max: 1 << 20,
|
||||
max_block,
|
||||
max_all,
|
||||
},
|
||||
rx,
|
||||
)
|
||||
}
|
||||
|
||||
fn socket() -> TcpStream {
|
||||
@@ -120,3 +134,25 @@ fn blocks_nobody_uses_are_forgotten() {
|
||||
let _c = blocks.get("203.0.113.3".parse().unwrap());
|
||||
assert_eq!(blocks.len(), 2, "203.0.113.1 went");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_busy_queue_leaves_presence_out_and_keeps_room_for_edits() {
|
||||
let (l, rx) = line(&Arc::default(), &Arc::default(), usize::MAX, usize::MAX);
|
||||
let msg = Bytes::from_static(b"x");
|
||||
for _ in 0..32 {
|
||||
assert!(l.takes_presence());
|
||||
l.push(&msg, &mut Charges::default(), false).unwrap();
|
||||
}
|
||||
assert!(!l.takes_presence(), "half full");
|
||||
// Edits still fit in the half kept for them.
|
||||
for _ in 0..32 {
|
||||
l.push(&msg, &mut Charges::default(), false).unwrap();
|
||||
}
|
||||
assert_eq!(l.push(&msg, &mut Charges::default(), false), Err(Push::Full), "a client that takes in nothing still ends");
|
||||
// It catches up: presence goes in again.
|
||||
for _ in 0..33 {
|
||||
drop(rx.recv().unwrap());
|
||||
}
|
||||
assert!(l.takes_presence());
|
||||
assert_eq!(l.waiting.load(Ordering::Relaxed), 31);
|
||||
}
|
||||
|
||||
@@ -201,6 +201,7 @@ impl ToolId {
|
||||
| ToolId::Star
|
||||
| ToolId::Line
|
||||
| ToolId::Arrow
|
||||
| ToolId::BoardShape
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -591,6 +592,13 @@ pub struct ToolState {
|
||||
pub picked: Option<figcraft_color::Color>,
|
||||
/// The widget layers whose hover styles show while the pointer is over them (a live preview).
|
||||
pub(crate) widget_hover: Vec<NodeId>,
|
||||
/// The placement crosshair: where Enter puts the next shape or frame once the arrows have
|
||||
/// shown it (in the middle of the view) and moved it (`tool.crosshair`), in page coordinates
|
||||
/// of the document and page in `place_on` (elsewhere it isn't shown: `Session::crosshair`).
|
||||
/// Placing, Escape and a change of tool take it away.
|
||||
pub place_at: Option<Point>,
|
||||
/// The document (uid) and page the crosshair was shown on.
|
||||
pub(crate) place_on: Option<(u64, NodeId)>,
|
||||
}
|
||||
|
||||
impl ToolState {
|
||||
|
||||
@@ -212,6 +212,8 @@ impl Session {
|
||||
crate::normalize(ds.doc_mut(), &before);
|
||||
}
|
||||
self.commit_interaction()?;
|
||||
// A placement by the pointer takes the placement crosshair with it, as Enter does.
|
||||
self.tool.place_at = None;
|
||||
if !self.prefs.keep_tool {
|
||||
self.set_tool(ToolId::Move);
|
||||
}
|
||||
|
||||
@@ -84,9 +84,14 @@ impl Session {
|
||||
if self.tool.busy() {
|
||||
return refuse("finish the drag first");
|
||||
}
|
||||
// The crosshair the arrows moved goes with the placement, which lands on it when no point
|
||||
// is given.
|
||||
let crosshair = self.crosshair();
|
||||
self.tool.place_at = None;
|
||||
let v = &self.ds()?.view;
|
||||
let (c, zoom) = (at.unwrap_or(Point::new(v.cx, v.cy)), v.zoom);
|
||||
let (w, h) = tool.click_size();
|
||||
let (c, zoom) = (at.or(crosshair).unwrap_or(Point::new(v.cx, v.cy)), v.zoom);
|
||||
// The board Shapes tool centres its own shape on the press; the others get half a click's size.
|
||||
let (w, h) = if matches!(tool, ToolId::BoardShape) { (0.0, 0.0) } else { tool.click_size() };
|
||||
let ev = |kind| PointerEvent::at(kind, c.x - w / 2.0, c.y - h / 2.0, zoom);
|
||||
let r = self.pointer(&ev(PointerKind::Down)).and_then(|()| self.pointer(&ev(PointerKind::Up)));
|
||||
if r.is_err() {
|
||||
@@ -111,6 +116,16 @@ impl Session {
|
||||
self.tool.overlay = Overlay::default();
|
||||
return self.cancel_interaction();
|
||||
}
|
||||
// The placement crosshair goes first; the tool stays. One left on another page or
|
||||
// document goes quietly.
|
||||
let shown = self.crosshair().is_some();
|
||||
self.tool.place_at = None;
|
||||
if shown {
|
||||
if let Ok(ds) = self.ds_mut() {
|
||||
ds.revision += 1;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.end_origin_edit() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -174,6 +189,14 @@ impl Session {
|
||||
/// Snapshot of tool state for the UI / inspect.
|
||||
pub fn tool_info(&self) -> serde_json::Value {
|
||||
let shapes: Vec<serde_json::Value> = self.shape_handles().into_iter().map(|(h, at)| json!({"handle": h, "at": at})).collect();
|
||||
json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles(), "shapeHandles": shapes})
|
||||
let crosshair = self.crosshair().map(|p| [p.x, p.y]);
|
||||
json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles(), "shapeHandles": shapes, "crosshair": crosshair})
|
||||
}
|
||||
|
||||
/// The placement crosshair, while the document and page it was shown on are the ones in
|
||||
/// front (`ToolState::place_at`).
|
||||
pub fn crosshair(&self) -> Option<Point> {
|
||||
let here = self.ds().ok().map(|d| (d.uid, d.page));
|
||||
self.tool.place_at.filter(|_| here.is_some() && here == self.tool.place_on)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@ impl Session {
|
||||
if !matches!(t, ToolId::Move | ToolId::Scale | ToolId::Hand) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) {
|
||||
let _ = crate::vectoredit::end(self);
|
||||
}
|
||||
// The placement crosshair goes with a change of tool, not with the same tool chosen again.
|
||||
if self.tool.tool != t {
|
||||
self.tool.place_at = None;
|
||||
}
|
||||
self.tool.tool = t;
|
||||
self.tool.gesture = Gesture::None;
|
||||
self.tool.pick_only = false;
|
||||
|
||||
@@ -529,6 +529,42 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
tool(s, t)
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"tool.crosshair",
|
||||
"Move the placement crosshair",
|
||||
"{dx?, dy? (arrow-key steps, e.g. 1 or -1), big? (the large nudge, like Shift)} — the arrows with a shape or frame tool chosen and nothing selected: the first shows the crosshair in the middle of the view, the next move it by the nudge amount; Enter (`tool.place`) puts the object there",
|
||||
false,
|
||||
|s, p| {
|
||||
if !s.tool.tool.draws_shape() {
|
||||
return Err(EngineError::Disabled("tool.crosshair".into(), "choose a shape or frame tool first".into()));
|
||||
}
|
||||
let step = if boolean(p, "big").unwrap_or(false) { s.prefs.nudge_big } else { s.prefs.nudge_small };
|
||||
let dir = |k| num(p, k).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-100.0, 100.0) * step;
|
||||
let (dx, dy) = (dir("dx"), dir("dy"));
|
||||
let (uid, page, centre, half) = {
|
||||
let ds = s.ds()?;
|
||||
let z = ds.view.zoom.max(1e-6);
|
||||
(ds.uid, ds.page, figcraft_geom::Point::new(ds.view.cx, ds.view.cy), (ds.view.vw / z / 2.0, ds.view.vh / z / 2.0))
|
||||
};
|
||||
// "It will appear in the middle of the viewport": the first arrow shows it there (a
|
||||
// crosshair left on another page or document starts over).
|
||||
let at = match s.crosshair() {
|
||||
Some(c) => figcraft_geom::Point::new(c.x + dx, c.y + dy),
|
||||
None => centre,
|
||||
};
|
||||
s.tool.place_at = Some(at);
|
||||
s.tool.place_on = Some((uid, page));
|
||||
if let Ok(ds) = s.ds_mut() {
|
||||
// "Move the crosshair around the canvas": nudged past an edge of the view, the
|
||||
// view pans by the overshoot to keep it in sight.
|
||||
let v = &mut ds.view;
|
||||
v.cx += (at.x - (v.cx + half.0)).max(0.0) + (at.x - (v.cx - half.0)).min(0.0);
|
||||
v.cy += (at.y - (v.cy + half.1)).max(0.0) + (at.y - (v.cy - half.1)).min(0.0);
|
||||
ds.revision += 1;
|
||||
}
|
||||
Ok(json!({"x": at.x, "y": at.y}))
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"tool.place",
|
||||
"Place with the tool",
|
||||
|
||||
@@ -354,6 +354,7 @@ pub static GESTURES: &[(Group, &str, &str, &str)] = &[
|
||||
(Tools, "Pan with nothing selected (Shift: farther)", "← ↑ → ↓", "← ↑ → ↓"),
|
||||
(Transform, "Rotate a degree (Shift: 15)", "⌥⌘ ← ↑ → ↓", "Ctrl+Alt+← ↑ → ↓"),
|
||||
(Tools, "Place a shape or frame (its tool chosen)", "↵", "Enter"),
|
||||
(Tools, "Show and move the placement crosshair (its tool chosen, nothing selected)", "← ↑ → ↓", "← ↑ → ↓"),
|
||||
(Zoom, "Zoom tool: click to zoom in, drag an area", "Hold Z", "Hold Z"),
|
||||
(Zoom, "Zoom tool: zoom out", "Hold ⌥Z", "Hold Alt+Z"),
|
||||
(Zoom, "Zoom around the pointer", "⌘ scroll", "Ctrl+scroll"),
|
||||
|
||||
@@ -469,6 +469,8 @@ mod penpot_export_tests;
|
||||
#[cfg(test)]
|
||||
mod pixel_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod place_crosshair_tests;
|
||||
#[cfg(test)]
|
||||
mod placed_image_size_tests;
|
||||
#[cfg(test)]
|
||||
mod playground_tests;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! The placement crosshair: the arrows with a shape or frame tool chosen show it in the middle of
|
||||
//! the view and move it, and Enter places the object there ("Use the arrow keys to activate the
|
||||
//! crosshair cursor. It will appear in the middle of the viewport indicating where the new object
|
||||
//! will be placed." / "Use the arrow keys to move the crosshair around the canvas or board, then
|
||||
//! press Return/Enter to place the object.", Accessibility at Figma, #1839).
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
|
||||
#[test]
|
||||
fn the_arrows_show_and_move_the_crosshair_and_enter_places_there() {
|
||||
let mut s = Session::new();
|
||||
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
|
||||
assert!(s.execute("tool.crosshair", &json!({"dx": 1})).is_err(), "a shape or frame tool first");
|
||||
s.execute("tool.rectangle", &json!({})).unwrap();
|
||||
// The first arrow shows the crosshair in the middle of the view, unmoved; the next move it by
|
||||
// the nudge amounts.
|
||||
let r = s.execute("tool.crosshair", &json!({"dx": 1, "dy": 0})).unwrap();
|
||||
assert_eq!((r["x"].as_f64(), r["y"].as_f64()), (Some(300.0), Some(200.0)));
|
||||
s.execute("tool.crosshair", &json!({"dx": 1, "dy": 0})).unwrap();
|
||||
let r = s.execute("tool.crosshair", &json!({"dx": 0, "dy": -1, "big": true})).unwrap();
|
||||
assert_eq!((r["x"].as_f64(), r["y"].as_f64()), (Some(301.0), Some(190.0)), "a nudge, then a big nudge");
|
||||
assert_eq!(s.tool_info()["crosshair"], json!([301.0, 190.0]));
|
||||
let v = &s.ds().unwrap().view;
|
||||
assert_eq!((v.cx, v.cy), (300.0, 200.0), "the view hasn't panned");
|
||||
// Enter: the rectangle is centred on the crosshair, which goes.
|
||||
let placed = s.execute("tool.place", &json!({})).unwrap();
|
||||
let id = crate::params::id_of(&placed["ids"][0]).unwrap();
|
||||
let b = s.doc().unwrap().world_bounds(id).unwrap();
|
||||
assert_eq!((b.center().x, b.center().y), (301.0, 190.0));
|
||||
assert_eq!(s.tool.place_at, None);
|
||||
assert!(s.tool_info()["crosshair"].is_null());
|
||||
// Escape takes a shown crosshair away before anything else, the tool staying; a tool change
|
||||
// takes it away too.
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
s.execute("tool.rectangle", &json!({})).unwrap();
|
||||
s.execute("tool.crosshair", &json!({})).unwrap();
|
||||
assert!(s.tool.place_at.is_some());
|
||||
s.escape().unwrap();
|
||||
assert_eq!((s.tool.place_at, s.tool.tool), (None, crate::ToolId::Rectangle));
|
||||
s.execute("tool.crosshair", &json!({})).unwrap();
|
||||
s.execute("tool.ellipse", &json!({})).unwrap();
|
||||
assert_eq!(s.tool.place_at, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_crosshair_keeps_its_page_and_goes_with_a_pointer_placement_but_not_the_same_tool_again() {
|
||||
let mut s = Session::new();
|
||||
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
|
||||
s.execute("tool.rectangle", &json!({})).unwrap();
|
||||
s.execute("tool.crosshair", &json!({"dx": 1})).unwrap();
|
||||
s.execute("tool.crosshair", &json!({"dx": 1})).unwrap();
|
||||
// The tool already chosen named again keeps the crosshair: `tool.place {tool}` lands on it.
|
||||
let placed = s.execute("tool.place", &json!({"tool": "rectangle"})).unwrap();
|
||||
let id = crate::params::id_of(&placed["ids"][0]).unwrap();
|
||||
assert_eq!(s.doc().unwrap().world_bounds(id).unwrap().center().x, 301.0);
|
||||
// A click places too (the tool kept by the preference) and takes the crosshair with it.
|
||||
s.prefs.keep_tool = true;
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
s.execute("tool.rectangle", &json!({})).unwrap();
|
||||
s.execute("tool.crosshair", &json!({})).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, 600.0, 600.0, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, 600.0, 600.0, 1.0)).unwrap();
|
||||
assert_eq!((s.tool.tool, s.tool.place_at), (crate::ToolId::Rectangle, None), "placed by the pointer: the crosshair goes, the tool stays");
|
||||
// Another page in front: the crosshair shown on the first isn't there, Escape has nothing to
|
||||
// take, and the next arrow starts over in the middle of this page's view.
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
s.execute("tool.crosshair", &json!({"dx": 5})).unwrap();
|
||||
s.execute("page.add", &json!({})).unwrap();
|
||||
s.execute("page.select", &json!({"index": 1})).unwrap();
|
||||
s.execute("tool.rectangle", &json!({})).unwrap();
|
||||
assert_eq!(s.crosshair(), None);
|
||||
assert!(s.tool_info()["crosshair"].is_null());
|
||||
s.escape().unwrap();
|
||||
assert_eq!(s.tool.tool, crate::ToolId::Move, "Escape went on to the tool");
|
||||
s.execute("tool.rectangle", &json!({})).unwrap();
|
||||
let r = s.execute("tool.crosshair", &json!({"dx": 1})).unwrap();
|
||||
let v = &s.ds().unwrap().view;
|
||||
assert_eq!((r["x"].as_f64(), r["y"].as_f64()), (Some(v.cx), Some(v.cy)), "shown afresh in the middle of this page's view");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_view_pans_to_keep_the_crosshair_in_sight() {
|
||||
// "Use the arrow keys to move the crosshair around the canvas or board": nudged past an edge
|
||||
// of the view, the view follows it by the overshoot; inside it, the view stays put.
|
||||
let mut s = Session::new();
|
||||
s.execute("view.set", &json!({"zoom": 10.0, "cx": 300, "cy": 200})).unwrap();
|
||||
s.execute("tool.rectangle", &json!({})).unwrap();
|
||||
s.execute("tool.crosshair", &json!({})).unwrap();
|
||||
for _ in 0..6 {
|
||||
s.execute("tool.crosshair", &json!({"dx": 1, "big": true})).unwrap();
|
||||
}
|
||||
let v = s.ds().unwrap().view.clone();
|
||||
assert_eq!((s.tool.place_at.map(|p| p.x), v.cx, v.cy), (Some(360.0), 300.0, 200.0), "at the edge of the 120 px wide view: no pan yet");
|
||||
s.execute("tool.crosshair", &json!({"dx": 1, "big": true})).unwrap();
|
||||
let v = s.ds().unwrap().view.clone();
|
||||
assert_eq!((v.cx, v.cy), (310.0, 200.0), "ten past the edge: the view moved ten");
|
||||
for _ in 0..5 {
|
||||
s.execute("tool.crosshair", &json!({"dy": -1, "big": true})).unwrap();
|
||||
}
|
||||
let v = s.ds().unwrap().view.clone();
|
||||
assert_eq!((v.cx, v.cy), (310.0, 190.0), "the view is 80 px tall: 40 up is its edge, 50 moves it ten");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_board_shapes_tool_places_on_the_crosshair_too() {
|
||||
// "...move the crosshair around the canvas or board, then press Return/Enter to place the
|
||||
// object": the board Shapes tool takes the crosshair like the shape tools, its shape centred
|
||||
// on it (the tool centres its own 160 px shape on a press).
|
||||
let mut s = crate::board_tests::board();
|
||||
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
|
||||
s.execute("tool.set", &json!({"tool": "shape"})).unwrap();
|
||||
s.execute("tool.crosshair", &json!({})).unwrap();
|
||||
s.execute("tool.crosshair", &json!({"dx": 2})).unwrap();
|
||||
let placed = s.execute("tool.place", &json!({})).unwrap();
|
||||
let id = crate::params::id_of(&placed["ids"][0]).unwrap();
|
||||
let b = s.doc().unwrap().world_bounds(id).unwrap();
|
||||
assert_eq!((b.center().x, b.center().y, b.width(), b.height()), (302.0, 200.0, 160.0, 160.0));
|
||||
assert_eq!(s.tool.place_at, None);
|
||||
}
|
||||
@@ -403,10 +403,10 @@ fn the_schema_numbers_what_the_format_numbers() {
|
||||
|
||||
#[test]
|
||||
fn schema_text_takes_numbers_and_refuses_bad_ones() {
|
||||
let s = Schema::parse("enum E A=3 B C=9\nmessage M uint a=7, E b, string c=2").unwrap();
|
||||
let s = Schema::parse("enum E A=3 B C=9\nmessage M uint a=7, E b, string c=5").unwrap();
|
||||
let values = |i: usize| s.defs[i].fields.iter().map(|f| (f.name.as_str(), f.value)).collect::<Vec<_>>();
|
||||
assert_eq!(values(0), [("A", 3), ("B", 1), ("C", 9)]);
|
||||
assert_eq!(values(1), [("a", 7), ("b", 2), ("c", 2)]);
|
||||
assert_eq!(values(1), [("a", 7), ("b", 2), ("c", 5)]);
|
||||
for bad in ["enum E A=", "enum E A=x", "enum E A=-1", "message M uint a=1.5", "message M uint a=99999999999", "enum E =", "message M uint ="] {
|
||||
assert!(Schema::parse(bad).is_err() || bad.ends_with('='), "{bad}");
|
||||
}
|
||||
|
||||
@@ -276,6 +276,9 @@ impl Schema {
|
||||
let ty = r.var_int()?;
|
||||
let array = r.byte()? & 1 != 0;
|
||||
let value = r.var_uint()?;
|
||||
if kind == Kind::Message && fields.iter().any(|f: &Field| f.value == value) {
|
||||
return err(format!("schema: duplicate field id {value} in {name}"));
|
||||
}
|
||||
raw_types.push(ty);
|
||||
fields.push(Field { name: fname, ty: None, array, value });
|
||||
}
|
||||
@@ -645,6 +648,9 @@ impl Schema {
|
||||
}
|
||||
.ok_or_else(|| KiwiError(format!("schema text: unknown type {base}")))?;
|
||||
let (name, value) = numbered(n.trim(), id)?;
|
||||
if *kind == "message" && fields.iter().any(|f: &Field| f.value == value) {
|
||||
return err(format!("schema text: duplicate field id {value} in {name}"));
|
||||
}
|
||||
fields.push(Field { name, ty: Some(ty), array: t.ends_with("[]"), value });
|
||||
}
|
||||
(if *kind == "struct" { Kind::Struct } else { Kind::Message }, fields)
|
||||
|
||||
@@ -562,6 +562,11 @@ fn oversized_grid_track_lists_warn_and_use_default_tracks() {
|
||||
assert!(warnings.iter().any(|w| w.contains("grid track list exceeds 1000 entries")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_explicit_field_ids_are_ambiguous() {
|
||||
assert!(Schema::parse("message M uint a=1, uint b=1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kiwi_round_trip() {
|
||||
let sc = schema();
|
||||
|
||||
@@ -1089,6 +1089,7 @@ name = "English"
|
||||
"Pages: {pages}" = "Pages: {pages}"
|
||||
"Paint bucket" = "Paint bucket"
|
||||
"Pan the canvas" = "Pan the canvas"
|
||||
"Show and move the placement crosshair (its tool chosen, nothing selected)" = "Show and move the placement crosshair (its tool chosen, nothing selected)"
|
||||
"Pan with nothing selected (Shift: farther)" = "Pan with nothing selected (Shift: farther)"
|
||||
"Panels" = "Panels"
|
||||
"Paper" = "Paper"
|
||||
|
||||
@@ -2369,6 +2369,7 @@ status = "draft — needs native review"
|
||||
"tool.arrow" = "Flecha"
|
||||
"tool.comment" = "Comentario"
|
||||
"tool.connector" = "Conector"
|
||||
"tool.crosshair" = "Mover la cruz de colocación"
|
||||
"tool.ellipse" = "Elipse"
|
||||
"tool.eraser" = "Borrador"
|
||||
"tool.frame" = "Marco"
|
||||
|
||||
@@ -1952,6 +1952,39 @@ status = "draft — needs native review"
|
||||
"⌘ click" = "⌘ click"
|
||||
"⌘ scroll" = "⌘ scroll"
|
||||
"⌥ drag" = "⌥ drag"
|
||||
"1 year" = "1 vuosi"
|
||||
"30 days" = "30 päivää"
|
||||
"90 days" = "90 päivää"
|
||||
"Wrap style" = "Rivitystyyli"
|
||||
"Balance" = "Tasattu"
|
||||
"Pretty" = "Siisti"
|
||||
"Auto: as many words as fit on each line" = "Automaattinen: jokaiselle riville niin monta sanaa kuin mahtuu"
|
||||
"Balance: every line close to the same length" = "Tasattu: kaikki rivit lähes samanpituisia"
|
||||
"Pretty: full lines, no one word alone on the last" = "Siisti: täydet rivit, viimeiselle riville ei jää yksittäistä sanaa"
|
||||
"Set hug contents" = "Aseta Sovita sisältöön"
|
||||
"Set fill container" = "Aseta Täytä säilö"
|
||||
"Double-click an edge of an auto layout frame" = "Kaksoisnapsauta automaattisen asettelun kehyksen reunaa"
|
||||
"⌥ double-click an edge of an auto layout child" = "⌥-kaksoisnapsauta automaattisen asettelun alikohteen reunaa"
|
||||
"Alt+double-click an edge of an auto layout child" = "Alt+kaksoisnapsauta automaattisen asettelun alikohteen reunaa"
|
||||
"Drop to use this image" = "Käytä tätä kuvaa pudottamalla"
|
||||
"Expand the interface" = "Laajenna käyttöliittymä"
|
||||
"Hide UI" = "Piilota käyttöliittymä"
|
||||
"Past 30 days" = "Viimeiset 30 päivää"
|
||||
"Past 90 days" = "Viimeiset 90 päivää"
|
||||
"Past year" = "Viimeinen vuosi"
|
||||
"Nothing counted yet" = "Mitään ei ole vielä laskettu"
|
||||
"Rotate a degree (Shift: 15)" = "Kierrä asteen verran (Shift: 15)"
|
||||
"⌥⌘ ← ↑ → ↓" = "⌥⌘ ← ↑ → ↓"
|
||||
"Ctrl+Alt+← ↑ → ↓" = "Ctrl+Alt+← ↑ → ↓"
|
||||
"Remove all auto layout" = "Poista kaikki automaattinen asettelu"
|
||||
"This server doesn't count insertions" = "Tämä palvelin ei laske lisäyksiä"
|
||||
"This server doesn't count styles and variables" = "Tämä palvelin ei laske tyylejä ja muuttujia"
|
||||
"Upload from computer" = "Lataa tietokoneelta"
|
||||
"The bars are hidden. {shortcut} shows them again" = "Palkit on piilotettu. {shortcut} näyttää ne uudelleen"
|
||||
"{insertions} · {detachments}" = "{insertions} · {detachments}"
|
||||
"{n} inserted" = { one = "{n} lisätty", other = "{n} lisätty" }
|
||||
"{period}: {counts}" = "{period}: {counts}"
|
||||
"{used} · {inserted}" = "{used} · {inserted}"
|
||||
|
||||
[commands]
|
||||
"align.bottom" = "Tasaa alas"
|
||||
@@ -2585,6 +2618,16 @@ status = "draft — needs native review"
|
||||
"vector.fillRule" = "Aseta täyttösääntö"
|
||||
"vector.reverseLoop" = "Käännä silmukka"
|
||||
"widget.hover" = "Pienoisohjelman kohdistus"
|
||||
"board.reshape" = "Muotoile yhdistin uudelleen"
|
||||
"image.replace" = "Korvaa kuva"
|
||||
"layout.removeAll" = "Poista kaikki automaattinen asettelu"
|
||||
"pref.uiScaleDown" = "Pienempi käyttöliittymä"
|
||||
"pref.uiScaleReset" = "Käyttöliittymän oletuskoko"
|
||||
"pref.uiScaleUp" = "Suurempi käyttöliittymä"
|
||||
"text.openLink" = "Avaa linkki"
|
||||
"text.wrapStyle" = "Rivitystyyli"
|
||||
"variable.extendCollection" = "Laajenna kokoelma"
|
||||
"view.minimize" = "Pienennä käyttöliittymä"
|
||||
|
||||
[menu]
|
||||
"bool.exclude" = "Jätä pois"
|
||||
|
||||
@@ -37,6 +37,7 @@ impl FigApp {
|
||||
field_focus: false,
|
||||
panels_unsaved: false,
|
||||
control_queue: Vec::new(),
|
||||
control_waits: Default::default(),
|
||||
control_input: Vec::new(),
|
||||
frame_stats: frame_stats::FrameStats::default(),
|
||||
windows: windows::Windows::default(),
|
||||
|
||||
+71
-17
@@ -48,25 +48,79 @@ pub fn single_line(ui: &mut Ui, id: Id, text: &mut String, font: FontId, width:
|
||||
.response
|
||||
}
|
||||
|
||||
/// One letter per script the UI's own fonts may lack: Hebrew, Arabic, Ethiopic, Devanagari,
|
||||
/// Gurmukhi, Gujarati, Tamil, Telugu, Kannada, Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian
|
||||
/// and Georgian. egui's shaper selects the joined contextual forms itself. Bengali and Thai are
|
||||
/// not here: [`crate::cjk`] picks their faces, in its own order of preference, after this list.
|
||||
pub(crate) const SCRIPT_SAMPLES: [char; 16] = [
|
||||
'\u{05D0}', '\u{0628}', '\u{1200}', '\u{0915}', '\u{0A15}', '\u{0A95}', '\u{0B95}', '\u{0C15}', '\u{0C95}', '\u{0D15}', '\u{0D9A}', '\u{0E81}',
|
||||
'\u{1000}', '\u{1780}', '\u{0531}', '\u{10D0}',
|
||||
];
|
||||
|
||||
/// System families that draw those scripts, in order of preference: the first installed one that
|
||||
/// has a script's letter is taken, so a family covering several scripts (Segoe UI, Nirmala UI,
|
||||
/// Kohinoor, Noto Sans) joins once. Windows, macOS and the common Linux fonts for each script.
|
||||
pub(crate) const SCRIPT_FAMILIES: [&str; 46] = [
|
||||
// Hebrew and Arabic.
|
||||
"Segoe UI",
|
||||
"Arial",
|
||||
"Tahoma",
|
||||
"Noto Sans Hebrew",
|
||||
"Noto Sans Arabic",
|
||||
"Arial Hebrew",
|
||||
"Geeza Pro",
|
||||
"Lucida Grande",
|
||||
"DejaVu Sans",
|
||||
"FreeSans",
|
||||
"Noto Sans",
|
||||
// Ethiopic (#2014).
|
||||
"Ebrima",
|
||||
"Nyala",
|
||||
"Kefa",
|
||||
"Noto Sans Ethiopic",
|
||||
"Abyssinica SIL",
|
||||
// Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada, Malayalam and Sinhala.
|
||||
"Nirmala UI",
|
||||
"Kohinoor Devanagari",
|
||||
"Kohinoor Gujarati",
|
||||
"Kohinoor Telugu",
|
||||
"Gurmukhi MN",
|
||||
"Tamil Sangam MN",
|
||||
"Kannada Sangam MN",
|
||||
"Malayalam Sangam MN",
|
||||
"Sinhala Sangam MN",
|
||||
"Noto Sans Devanagari",
|
||||
"Noto Sans Gurmukhi",
|
||||
"Noto Sans Gujarati",
|
||||
"Noto Sans Tamil",
|
||||
"Noto Sans Telugu",
|
||||
"Noto Sans Kannada",
|
||||
"Noto Sans Malayalam",
|
||||
"Noto Sans Sinhala",
|
||||
"Lohit Devanagari",
|
||||
// Lao, Myanmar and Khmer.
|
||||
"Leelawadee UI",
|
||||
"Lao Sangam MN",
|
||||
"Myanmar Text",
|
||||
"Myanmar Sangam MN",
|
||||
"Khmer Sangam MN",
|
||||
"Noto Sans Lao",
|
||||
"Noto Sans Myanmar",
|
||||
"Noto Sans Khmer",
|
||||
// Armenian and Georgian.
|
||||
"Sylfaen",
|
||||
"Mshtakan",
|
||||
"Noto Sans Armenian",
|
||||
"Noto Sans Georgian",
|
||||
];
|
||||
|
||||
/// Faces the UI adds for scripts its own fonts lack (desktop: from the system), as egui font data;
|
||||
/// `taken` as for [`crate::theme::system_fallbacks`].
|
||||
/// `taken` as for [`crate::theme::system_fallbacks`]. Hebrew and Arabic names were covered first;
|
||||
/// Ethiopic and the other scripts in [`SCRIPT_SAMPLES`] drew as boxes in the panels (a String
|
||||
/// variable's Amharic value, #2014) until the system face for each was taken too. Bengali and
|
||||
/// Thai keep [`crate::cjk`]'s choice, made after this list.
|
||||
pub fn script_fallbacks(have: &dyn Fn(char) -> bool, taken: &mut Vec<Arc<figcraft_text::Face>>) -> Vec<(String, egui::FontData)> {
|
||||
// Hebrew and Arabic letters; egui's shaper selects the joined contextual forms itself.
|
||||
const SAMPLES: [char; 2] = ['\u{05D0}', '\u{0628}'];
|
||||
const FAMILIES: [&str; 11] = [
|
||||
"Segoe UI",
|
||||
"Arial",
|
||||
"Tahoma",
|
||||
"Noto Sans Hebrew",
|
||||
"Noto Sans Arabic",
|
||||
"Arial Hebrew",
|
||||
"Geeza Pro",
|
||||
"Lucida Grande",
|
||||
"DejaVu Sans",
|
||||
"FreeSans",
|
||||
"Noto Sans",
|
||||
];
|
||||
crate::theme::system_fallbacks(SAMPLES, &FAMILIES, have, taken)
|
||||
crate::theme::system_fallbacks(SCRIPT_SAMPLES, &SCRIPT_FAMILIES, have, taken)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -310,3 +310,52 @@ fn clicks_place_the_logical_caret_by_what_is_shown() {
|
||||
run(vec![Event::Text("א".into())], &mut text, &mut rect);
|
||||
assert_eq!(text, "שלוםא");
|
||||
}
|
||||
|
||||
/// Scripts beyond Hebrew and Arabic (#2014): a String variable's Amharic value drew as boxes
|
||||
/// because the UI took system faces for two scripts only. Now every script in `SCRIPT_SAMPLES`
|
||||
/// with an installed face gets one, and the UI's fonts then have its glyphs. A machine without a
|
||||
/// face for a script skips that script (CI has few fonts); a face the UI's own fonts cover is not
|
||||
/// taken twice.
|
||||
#[test]
|
||||
fn other_scripts_take_an_installed_system_face() {
|
||||
figcraft_text::scan_system_fonts();
|
||||
let installed = |c: char| figcraft_text::face_with_char(c, &crate::bidi::SCRIPT_FAMILIES).is_some();
|
||||
let mut taken = Vec::new();
|
||||
let added = crate::bidi::script_fallbacks(&|_| false, &mut taken);
|
||||
let scripts = [('\u{1200}', "Ethiopic"), ('\u{0915}', "Devanagari"), ('\u{0B95}', "Tamil"), ('\u{1000}', "Myanmar"), ('\u{0531}', "Armenian")];
|
||||
let mut checked = 0;
|
||||
for (c, script) in scripts {
|
||||
if !installed(c) {
|
||||
eprintln!("no {script} face on this system; skipping");
|
||||
continue;
|
||||
}
|
||||
let families: Vec<&str> = taken.iter().map(|f| f.family.as_str()).collect();
|
||||
assert!(taken.iter().any(|f| f.has_char(c)), "a face for {script} is taken: {families:?}");
|
||||
checked += 1;
|
||||
}
|
||||
// A face that several scripts share is added once, under its system key.
|
||||
let keys: Vec<&str> = added.iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert_eq!(keys.len(), taken.len(), "{keys:?}");
|
||||
assert!(keys.iter().all(|k| k.starts_with("system-")), "{keys:?}");
|
||||
let mut seen = keys.clone();
|
||||
seen.sort_unstable();
|
||||
seen.dedup();
|
||||
assert_eq!(seen.len(), keys.len(), "no face twice: {keys:?}");
|
||||
// Nothing is taken for a script the UI's own fonts draw.
|
||||
assert!(crate::bidi::script_fallbacks(&|_| true, &mut Vec::new()).is_empty());
|
||||
if checked == 0 {
|
||||
eprintln!("no face for any of the sampled scripts on this system; the UI font check was not run");
|
||||
return;
|
||||
}
|
||||
// Through the whole font setup: the UI draws the letters of every script it found a face for.
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx, true, 0);
|
||||
ctx.run_ui(RawInput::default(), |_| {}).textures_delta.clear();
|
||||
ctx.fonts_mut(|f| {
|
||||
for (c, script) in scripts {
|
||||
if installed(c) {
|
||||
assert!(f.has_glyph(®ular(11.0), c), "the UI fonts draw {script} ({c:?})");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,6 +25,14 @@ impl HoverKey {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a button that pans the canvas is held: the middle button, or a mouse's side buttons
|
||||
/// (4 and 5), which the incumbent treats the same way (#1758). Space + primary pans too, handled
|
||||
/// with the Space key.
|
||||
fn pan_button_down(i: &egui::InputState) -> bool {
|
||||
use egui::PointerButton::{Extra1, Extra2, Middle};
|
||||
[Middle, Extra1, Extra2].into_iter().any(|b| i.pointer.button_down(b))
|
||||
}
|
||||
|
||||
/// The pointer left the canvas (or something over it took the pointer): nothing is hovered there.
|
||||
fn left(app: &mut FigApp) {
|
||||
app.canvas.last_hover = None;
|
||||
@@ -140,6 +148,31 @@ fn import_dropped(app: &mut FigApp, ctx: &egui::Context, hover_pos: Option<egui:
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame of a pan (the middle or a side button held, or Space + drag): the view follows the
|
||||
/// pointer from where the pan began, and the pan ends once no button that pans is held.
|
||||
fn pan_view(app: &mut FigApp, ctx: &egui::Context, pos: Pos2, mods: &egui::Modifiers, middle_down: bool) {
|
||||
if let Ok(ds) = app.session.ds_mut() {
|
||||
match app.canvas.middle_pan {
|
||||
None => app.canvas.middle_pan = Some((pos, ds.view.cx, ds.view.cy)),
|
||||
Some((p0, cx, cy)) => {
|
||||
let z = ds.view.zoom.max(1e-6);
|
||||
ds.view.cx = cx - f64::from(pos.x - p0.x) / z;
|
||||
ds.view.cy = cy - f64::from(pos.y - p0.y) / z;
|
||||
ds.revision += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A primary drag under way when the pan began ends when its button is let go, pan or
|
||||
// not: the engine gets its release here, as it does over the canvas or off it.
|
||||
if app.canvas.dragging && !ctx.input(|i| i.pointer.primary_down()) {
|
||||
app.canvas.dragging = false;
|
||||
send(app, PointerKind::Up, pos, mods);
|
||||
}
|
||||
if !middle_down && !ctx.input(|i| i.pointer.primary_down()) {
|
||||
app.canvas.middle_pan = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The pointer and keys on the canvas. False when something on it with a cursor of its own has
|
||||
/// the pointer (a comment pin, a ruler guide, a gradient or padding handle): the tool's cursor
|
||||
/// then doesn't show.
|
||||
@@ -180,7 +213,7 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo
|
||||
left(app);
|
||||
}
|
||||
// A pan whose button was released off the canvas is over.
|
||||
if !ctx.input(|i| i.pointer.button_down(egui::PointerButton::Middle) || i.pointer.primary_down()) {
|
||||
if !ctx.input(|i| pan_button_down(i) || i.pointer.primary_down()) {
|
||||
app.canvas.middle_pan = None;
|
||||
}
|
||||
// So is a handle drag.
|
||||
@@ -213,23 +246,10 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo
|
||||
if zoom_tool(app, ui, resp, pos, mods, editing_text || typing_elsewhere) {
|
||||
return true;
|
||||
}
|
||||
// Middle-button / space panning.
|
||||
let middle_down = ctx.input(|i| i.pointer.button_down(egui::PointerButton::Middle));
|
||||
// Middle-button (or side-button) / space panning.
|
||||
let middle_down = ctx.input(pan_button_down);
|
||||
if (middle_down || (app.canvas.space_pan && ctx.input(|i| i.pointer.primary_down()))) && resp.hovered() || app.canvas.middle_pan.is_some() {
|
||||
if let Ok(ds) = app.session.ds_mut() {
|
||||
match app.canvas.middle_pan {
|
||||
None => app.canvas.middle_pan = Some((pos, ds.view.cx, ds.view.cy)),
|
||||
Some((p0, cx, cy)) => {
|
||||
let z = ds.view.zoom.max(1e-6);
|
||||
ds.view.cx = cx - f64::from(pos.x - p0.x) / z;
|
||||
ds.view.cy = cy - f64::from(pos.y - p0.y) / z;
|
||||
ds.revision += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !middle_down && !ctx.input(|i| i.pointer.primary_down()) {
|
||||
app.canvas.middle_pan = None;
|
||||
}
|
||||
pan_view(app, &ctx, pos, &mods, middle_down);
|
||||
return true;
|
||||
}
|
||||
// A handle drag whose button went up ends first, wherever on the canvas the pointer is: over a
|
||||
|
||||
@@ -145,6 +145,7 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
|
||||
}
|
||||
// Pen path.
|
||||
pen_overlay(app, painter, zoom);
|
||||
crosshair_overlay(app, painter);
|
||||
// Eraser brush (vector edit mode).
|
||||
if let Some([x, y, r]) = ov.brush {
|
||||
let c = app.to_screen(x, y);
|
||||
@@ -416,6 +417,18 @@ fn attach_points(app: &FigApp, painter: &egui::Painter, points: &[([f64; 2], boo
|
||||
}
|
||||
}
|
||||
|
||||
/// The placement crosshair the arrows showed and moved: where Enter puts the next shape or frame.
|
||||
fn crosshair_overlay(app: &FigApp, painter: &egui::Painter) {
|
||||
let Some(p) = app.session.crosshair().filter(|_| app.session.tool.tool.draws_shape()) else { return };
|
||||
let c = app.to_screen(p.x, p.y);
|
||||
let col = accent(painter);
|
||||
for d in [vec2(1.0, 0.0), vec2(0.0, 1.0)] {
|
||||
painter.line_segment([c - d * 12.0, c - d * 3.0], stroke(col, 1.5));
|
||||
painter.line_segment([c + d * 3.0, c + d * 12.0], stroke(col, 1.5));
|
||||
}
|
||||
painter.circle_stroke(c, 3.0, stroke(col, 1.5));
|
||||
}
|
||||
|
||||
fn pen_overlay(app: &FigApp, painter: &egui::Painter, zoom: f64) {
|
||||
let ov = &app.session.tool.overlay;
|
||||
if !ov.pen.is_empty() {
|
||||
|
||||
@@ -454,6 +454,24 @@ fn hidden(ctx: &Context, id: u64) -> bool {
|
||||
visibility(ctx, id) == Some(false)
|
||||
}
|
||||
|
||||
/// How long, in seconds, a window may draw no frame while a control request waits for it before
|
||||
/// [`background`] answers the request as it answers one for a hidden window. eframe runs the app's
|
||||
/// logic for a window whose redraw is 0.25 s overdue, so the second such pass answers.
|
||||
pub const NOT_DRAWING_AFTER: f64 = 0.2;
|
||||
|
||||
/// Whether window `id` has drawn no frame for [`NOT_DRAWING_AFTER`] while a request waited for it.
|
||||
/// The platform may still call it visible: under Wayland an app can't tell that its window is
|
||||
/// minimised, and the compositor then stops asking it to draw. The wait starts again whenever the
|
||||
/// window draws.
|
||||
fn stopped_drawing(app: &mut FigApp, ctx: &Context, id: u64, now: f64) -> bool {
|
||||
let pass = ctx.cumulative_pass_nr_for(crate::windows::viewport(id));
|
||||
let wait = app.control_waits.entry(id).or_insert((pass, now));
|
||||
if wait.0 != pass {
|
||||
*wait = (pass, now);
|
||||
}
|
||||
now - wait.1 >= NOT_DRAWING_AFTER
|
||||
}
|
||||
|
||||
/// Send `RequestPaintWhileHidden` for work that needs a real UI pass (a queued capture or
|
||||
/// synthetic input), and schedule a wakeup for the next capture deadline. Native eframe honours
|
||||
/// this command; the shared web runner does not implement it yet, so a hidden browser tab remains
|
||||
@@ -511,7 +529,7 @@ pub fn background(app: &mut FigApp, ctx: &Context) {
|
||||
let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) });
|
||||
continue;
|
||||
}
|
||||
let is_hidden = hidden(ctx, target);
|
||||
let is_hidden = hidden(ctx, target) || stopped_drawing(app, ctx, target, now);
|
||||
if r.method == "ui.screenshot" && visibility(ctx, target) != Some(true) {
|
||||
// Hidden, minimised, or with visibility the platform has not reported: admit it now
|
||||
// so the request gets its deadline and a forced paint. Waiting for a frame that may
|
||||
@@ -564,6 +582,14 @@ pub fn background(app: &mut FigApp, ctx: &Context) {
|
||||
}
|
||||
let _ = r.reply.send(ControlReply { id: r.id, result: res.unwrap_or_else(|| Err(format!("no window {target}"))) });
|
||||
}
|
||||
// A window a request still waits for is checked again soon: if it draws, its frame answers;
|
||||
// if it doesn't (minimised under Wayland), the next logic pass does.
|
||||
let waiting: std::collections::BTreeSet<u64> =
|
||||
app.control_queue.iter().map(|r| r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused)).collect();
|
||||
app.control_waits.retain(|id, _| waiting.contains(id));
|
||||
if !waiting.is_empty() {
|
||||
crate::repaint_in(ctx, NOT_DRAWING_AFTER);
|
||||
}
|
||||
request_paint(app, ctx);
|
||||
}
|
||||
|
||||
@@ -1004,6 +1030,12 @@ fn ui_key(app: &mut FigApp, ctx: &Context, p: &Value) -> Result<Value, String> {
|
||||
"ArrowUp" => (0, -1),
|
||||
_ => (0, 1),
|
||||
};
|
||||
// A shape or frame tool chosen and nothing selected: the arrows show and move the
|
||||
// placement crosshair, as the window's key path does.
|
||||
if app.session.selection().is_empty() && app.session.tool.tool.draws_shape() && !app.session.tool.dev_mode && !(m.cmd || m.ctrl || m.alt)
|
||||
{
|
||||
return app.session.execute("tool.crosshair", &json!({"dx": dx, "dy": dy, "big": m.shift})).map_err(|e| e.to_string());
|
||||
}
|
||||
let (cmd, params) = if (m.cmd || m.ctrl) && m.alt {
|
||||
// Rotate a degree, 15 with Shift: down and right clockwise, up and left back, about
|
||||
// the selection's rotation origin; nothing while points are being edited.
|
||||
|
||||
@@ -434,6 +434,53 @@ fn a_hidden_window_answers_at_once() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #1906: under Wayland a minimised window still reports itself visible (no `minimized`, no
|
||||
/// `occluded`) and draws no frames; eframe runs only its logic. A request waiting for that
|
||||
/// window's frame is answered from the logic passes once the window has drawn nothing for
|
||||
/// [`crate::control::NOT_DRAWING_AFTER`], instead of timing out after 60 s. A window that draws
|
||||
/// keeps answering from its frame.
|
||||
#[test]
|
||||
fn a_window_that_stopped_drawing_answers_from_its_logic_passes() {
|
||||
let (mut h, text) = document();
|
||||
h.settle();
|
||||
h.time += 120.0;
|
||||
h.app.clock.set_test_now(Some(h.time));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let rename = |id: u64, name: &str| ControlRequest {
|
||||
id: json!(id),
|
||||
method: "engine.execute".into(),
|
||||
params: json!({"command": "node.set", "params": {"ids": [text.clone()], "props": {"name": name}}}),
|
||||
reply: tx.clone(),
|
||||
state: Default::default(),
|
||||
};
|
||||
let node = figcraft_doc::NodeId(text.as_u64().unwrap());
|
||||
let name = |h: &Harness| h.app.session.doc().unwrap().node(node).unwrap().name.clone();
|
||||
// What Wayland reports for a minimised window: nothing about its visibility.
|
||||
let unknown = RawInput::default();
|
||||
let ctx = h.ctx.clone();
|
||||
h.app.control_queue.push(rename(1, "while minimised"));
|
||||
let _ = ctx.run_logic(&unknown, |ctx| crate::control::background(&mut h.app, ctx));
|
||||
assert!(rx.try_recv().is_err(), "a window that may still draw answers from its frame");
|
||||
assert_eq!(h.app.control_queue.len(), 1);
|
||||
// No frame comes: the next logic pass answers.
|
||||
h.app.clock.set_test_now(Some(h.time + crate::control::NOT_DRAWING_AFTER + 0.05));
|
||||
let _ = ctx.run_logic(&unknown, |ctx| crate::control::background(&mut h.app, ctx));
|
||||
let reply = rx.try_recv().expect("answered while the window draws nothing");
|
||||
assert_eq!(reply.id, json!(1));
|
||||
assert!(reply.result.is_ok(), "{:?}", reply.result);
|
||||
assert_eq!(name(&h), "while minimised");
|
||||
assert!(h.app.control_queue.is_empty(), "nothing waits for the window");
|
||||
// Back on screen: a frame drawn after the wait began starts it again, and the frame answers.
|
||||
h.time += 1.0;
|
||||
h.app.clock.set_test_now(Some(h.time));
|
||||
h.app.control_queue.push(rename(2, "on screen"));
|
||||
let _ = ctx.run_logic(&unknown, |ctx| crate::control::background(&mut h.app, ctx));
|
||||
h.app.clock.set_test_now(Some(h.time + crate::control::NOT_DRAWING_AFTER + 0.05));
|
||||
h.frame(vec![]);
|
||||
let reply = rx.try_recv().expect("the frame answers");
|
||||
assert_eq!((reply.id, name(&h)), (json!(2), "on screen".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_save_writes_a_changed_file_then_rests() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-auto-save-{}", std::process::id()));
|
||||
|
||||
@@ -411,6 +411,81 @@ fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree()
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
}
|
||||
|
||||
/// A press of `key` in the frame that's next, then a few frames to settle.
|
||||
fn press_page_field_key(u: &mut Ui, key: egui::Key) {
|
||||
u.frame(vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
|
||||
for _ in 0..5 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asserts a click on the name of page `page`, away from page `other`, opens it and starts no
|
||||
/// rename: the pages list is back to plain rows however the last rename ended.
|
||||
fn assert_clicking_page_opens_it(u: &mut Ui, page: NodeId, other: NodeId, row: Pos2) {
|
||||
assert!(u.app.ui.rename_page.is_none(), "the rename is over");
|
||||
u.app.session.execute("page.select", &json!({"id": other})).unwrap();
|
||||
for _ in 0..30 {
|
||||
u.frame(vec![Event::PointerMoved(row)]);
|
||||
}
|
||||
u.button(row, true);
|
||||
u.button(row, false);
|
||||
for _ in 0..5 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
assert_eq!(u.app.session.page().unwrap(), page, "the click opens the page");
|
||||
assert!(!u.ctx.memory(|m| m.has_focus(Id::unique(("pagename", page)).with("te"))), "and doesn't edit its name");
|
||||
}
|
||||
|
||||
/// Asserts a page's row is a plain row after a rename that was cancelled, committed unchanged or
|
||||
/// committed with a new name: a click on its name opens the page rather than editing the name.
|
||||
#[test]
|
||||
fn a_finished_page_rename_leaves_a_row_that_a_click_opens() {
|
||||
for (key, text) in [(egui::Key::Escape, None), (egui::Key::Enter, None), (egui::Key::Enter, Some("Renamed"))] {
|
||||
let mut s = Session::new();
|
||||
let p1 = s.page().unwrap();
|
||||
let p2 = id(s.execute("page.add", &json!({})).unwrap());
|
||||
let mut u = Ui::new(s);
|
||||
let r2 = page_row(&mut u, p2, p1);
|
||||
u.ctx.enable_accesskit();
|
||||
double_click_checking_focus(&mut u, r2);
|
||||
assert_eq!(u.app.ui.rename_page.as_ref().map(|r| r.0), Some(p2), "a double click renames the page");
|
||||
if let Some(text) = text {
|
||||
u.frame(vec![Event::Text(text.into())]);
|
||||
}
|
||||
press_page_field_key(&mut u, key);
|
||||
assert_clicking_page_opens_it(&mut u, p2, p1, r2);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asserts the row's Rename page puts the keyboard in the name, and a name typed there is kept.
|
||||
#[test]
|
||||
fn the_page_menus_rename_starts_with_the_keyboard_in_the_name() {
|
||||
let mut s = Session::new();
|
||||
let p1 = s.page().unwrap();
|
||||
let p2 = id(s.execute("page.add", &json!({})).unwrap());
|
||||
let mut u = Ui::new(s);
|
||||
let r2 = page_row(&mut u, p2, p1);
|
||||
u.frame(vec![
|
||||
Event::PointerMoved(r2),
|
||||
Event::PointerButton { pos: r2, button: egui::PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
u.frame(vec![Event::PointerButton { pos: r2, button: egui::PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }]);
|
||||
let shapes: Vec<egui::Shape> = u.frame_output(vec![]).shapes.into_iter().map(|c| c.shape).collect();
|
||||
let item = crate::live_tests::text_center(&shapes, "Rename page");
|
||||
u.frame(vec![Event::PointerMoved(item)]);
|
||||
u.button(item, true);
|
||||
u.button(item, false);
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
assert_eq!(u.app.ui.rename_page.as_ref().map(|r| r.0), Some(p2), "the menu renames the page");
|
||||
assert!(u.ctx.memory(|m| m.has_focus(Id::unique(("pagename", p2)).with("te"))), "with the keyboard in its name");
|
||||
u.frame(vec![Event::Text("Renamed".into())]);
|
||||
press_page_field_key(&mut u, egui::Key::Enter);
|
||||
assert_eq!(u.app.session.doc().unwrap().node(p2).map(|n| n.name.clone()).as_deref(), Some("Renamed"));
|
||||
assert_clicking_page_opens_it(&mut u, p2, p1, r2);
|
||||
}
|
||||
|
||||
/// The y of the line between the pages and the layers, above the layers' row at `layer` (found by
|
||||
/// the resize cursor it shows when hovered).
|
||||
fn pages_edge_at(u: &mut Ui, layer: f32) -> f32 {
|
||||
|
||||
@@ -756,6 +756,9 @@ pub struct FigApp {
|
||||
panels_unsaved: bool,
|
||||
/// Control-channel requests waiting for the next frame.
|
||||
pub control_queue: Vec<ControlRequest>,
|
||||
/// Windows a control request waits for: the window's pass count when the wait began and the
|
||||
/// app-clock time it began, to tell a window that stopped drawing ([`control::background`]).
|
||||
control_waits: std::collections::BTreeMap<u64, (u64, f64)>,
|
||||
/// Input events from the control channel (`ui.input`) for the next frame's raw input.
|
||||
pub control_input: Vec<egui::Event>,
|
||||
/// Frames drawn and what asked for them (`ui.inspect`).
|
||||
|
||||
@@ -293,8 +293,13 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
|
||||
let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0));
|
||||
let start = ui.data_mut(|d| d.remove_temp::<bool>(page_rename_start_id())).unwrap_or(false);
|
||||
let font = if id == cur { semibold(11.0) } else { regular(11.0) };
|
||||
if let (Some(new), _) = crate::widgets::rename_field(ui, Id::unique(("pagename", id)), &name, tr, font, start) {
|
||||
let (out, open) = crate::widgets::rename_field(ui, Id::unique(("pagename", id)), &name, tr, font, start);
|
||||
if let Some(new) = out {
|
||||
rename_done = Some((id, new));
|
||||
} else if !open {
|
||||
// Cancelled, or committed unchanged: the row goes back to its name, or the idle
|
||||
// field would catch the next click and a click wouldn't open this page.
|
||||
app.ui.rename_page = None;
|
||||
}
|
||||
} else if !divider {
|
||||
let font = if id == cur { semibold(11.0) } else { regular(11.0) };
|
||||
@@ -315,6 +320,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
|
||||
if ui.button(tr("Rename page")).clicked() {
|
||||
if !crate::menus::dev_mode_refuses(app, "page.rename") {
|
||||
app.ui.rename_page = Some((id, name.clone()));
|
||||
ui.data_mut(|d| d.insert_temp(page_rename_start_id(), true));
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
|
||||
@@ -54,6 +54,28 @@ fn arrows_pan_the_view_when_nothing_is_selected() {
|
||||
assert_eq!(u.app.session.doc().unwrap().world_bounds(r).unwrap().x0, 1.0);
|
||||
}
|
||||
|
||||
/// With a shape or frame tool chosen and nothing selected, the arrows show the placement crosshair
|
||||
/// in the middle of the view and move it (Shift: the large nudge) instead of panning, and Enter
|
||||
/// places the object there (Accessibility at Figma, #1839).
|
||||
#[test]
|
||||
fn arrows_show_and_move_the_placement_crosshair_with_a_shape_tool() {
|
||||
let mut u = Ui::new(Session::new());
|
||||
u.app.run("select.set", json!({"ids": []}));
|
||||
u.app.run("view.set", json!({"zoom": 2.0, "cx": 100, "cy": 50}));
|
||||
u.app.run("tool.rectangle", json!({}));
|
||||
press(&mut u, Key::ArrowRight, Modifiers::NONE);
|
||||
assert_eq!(view(&u), (100.0, 50.0, 2.0), "no pan: the crosshair shows instead");
|
||||
assert_eq!(u.app.session.tool.place_at.map(|p| (p.x, p.y)), Some((100.0, 50.0)), "in the middle of the view");
|
||||
press(&mut u, Key::ArrowDown, Modifiers::SHIFT);
|
||||
assert_eq!(u.app.session.tool.place_at.map(|p| (p.x, p.y)), Some((100.0, 60.0)), "moved by the large nudge");
|
||||
press(&mut u, Key::Enter, Modifiers::NONE);
|
||||
let sel = u.app.session.selection();
|
||||
let b = u.app.session.doc().unwrap().world_bounds(sel[0]).unwrap();
|
||||
assert_eq!((b.center().x, b.center().y), (100.0, 60.0), "placed at the crosshair");
|
||||
assert!(u.app.session.tool.place_at.is_none(), "which goes with the placement");
|
||||
assert_eq!(view(&u), (100.0, 50.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyboard_box_selection_key_and_cmd_arrows() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -480,7 +480,11 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, re
|
||||
_ => (0.0, 0.0),
|
||||
};
|
||||
if (dx, dy) != (0.0, 0.0) {
|
||||
if app.session.selection().is_empty() {
|
||||
if app.session.selection().is_empty() && app.session.tool.tool.draws_shape() && !app.session.tool.dev_mode && !m.command && !m.alt {
|
||||
// A shape or frame tool chosen: the arrows show the placement crosshair in the middle
|
||||
// of the view and move it; Enter places there (Accessibility at Figma).
|
||||
app.run("tool.crosshair", json!({"dx": dx, "dy": dy, "big": m.shift}));
|
||||
} else if app.session.selection().is_empty() {
|
||||
app.run("view.pan", json!({"dx": dx, "dy": dy, "big": m.shift}));
|
||||
} else if crate::menus::dev_mode_refuses(app, "transform.nudge") {
|
||||
// Dev Mode: said so, nothing moves.
|
||||
|
||||
@@ -284,7 +284,8 @@ pub fn on_dark<R>(ui: &mut egui::Ui, f: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
|
||||
/// UI fonts: Inter and the Archivo wordmark (from the craft-fonts build input) when present;
|
||||
/// otherwise egui's defaults. With
|
||||
/// `system`, also system faces for scripts those lack (Hebrew, joined Arabic) so names in them show,
|
||||
/// `system`, also system faces for scripts those lack (Hebrew, joined Arabic, Ethiopic, the Indic
|
||||
/// scripts and the others [`crate::bidi::script_fallbacks`] names) so names in them show,
|
||||
/// and faces for the CJK scripts in `cjk` ([`crate::cjk`]).
|
||||
///
|
||||
/// The craft-fonts set embeds Noto Sans Arabic and Noto Sans Hebrew; with `system` off those
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! A canvas pan (middle button or Space + primary) ends when its button is released, even when the
|
||||
//! release happens over a panel: returning to the canvas with no button held must not move the view.
|
||||
//! A canvas pan (middle button, a side button, or Space + primary) ends when its button is
|
||||
//! released, even when the release happens over a panel: returning to the canvas with no button held
|
||||
//! must not move the view.
|
||||
|
||||
use egui::{Event, Key, PointerButton, Pos2, RawInput, Rect, vec2};
|
||||
use figcraft_engine::Session;
|
||||
@@ -45,6 +46,12 @@ fn space(pressed: bool) -> Event {
|
||||
}
|
||||
|
||||
fn check(space_pan: bool, outside: bool) {
|
||||
check_button(if space_pan { PointerButton::Primary } else { PointerButton::Middle }, space_pan, outside);
|
||||
}
|
||||
|
||||
/// A pan with button `b` (with Space held when `space_pan`) moves the view, ends when `b` is
|
||||
/// released (`outside` the canvas or over it), and a new one moves it again.
|
||||
fn check_button(b: PointerButton, space_pan: bool, outside: bool) {
|
||||
let mut u = Frames::new();
|
||||
let rect = u.app.canvas.rect;
|
||||
let start = Pos2::new(rect.min.x + 200.0, rect.center().y);
|
||||
@@ -55,7 +62,6 @@ fn check(space_pan: bool, outside: bool) {
|
||||
if space_pan {
|
||||
u.frame(vec![space(true)]);
|
||||
}
|
||||
let b = if space_pan { PointerButton::Primary } else { PointerButton::Middle };
|
||||
let before = u.cx();
|
||||
u.frame(vec![button(start, b, true)]);
|
||||
u.frame(vec![Event::PointerMoved(dragged)]);
|
||||
@@ -93,3 +99,35 @@ fn space_pan_released_over_panel_does_not_move_view_on_return() {
|
||||
fn middle_pan_released_inside_canvas_keeps_view() {
|
||||
check(false, false);
|
||||
}
|
||||
|
||||
/// #1758: a mouse's side buttons (4 and 5) pan the canvas as the middle button does, and the pan
|
||||
/// ends on release, over the canvas or off it.
|
||||
#[test]
|
||||
fn side_buttons_pan_like_the_middle_button() {
|
||||
for b in [PointerButton::Extra1, PointerButton::Extra2] {
|
||||
check_button(b, false, false);
|
||||
check_button(b, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// A primary drag under way when a pan starts (a side or the middle button pressed mid-drag) ends
|
||||
/// when the primary button is let go during the pan: the engine gets the release, so its gesture
|
||||
/// isn't left open once the pan is over.
|
||||
#[test]
|
||||
fn a_primary_drag_released_during_a_pan_ends() {
|
||||
for pan in [PointerButton::Middle, PointerButton::Extra1] {
|
||||
let mut u = Frames::new();
|
||||
let rect = u.app.canvas.rect;
|
||||
let start = Pos2::new(rect.min.x + 200.0, rect.center().y);
|
||||
let moved = Pos2::new(rect.min.x + 400.0, rect.center().y);
|
||||
u.frame(vec![Event::PointerMoved(start)]);
|
||||
u.frame(vec![button(start, PointerButton::Primary, true)]);
|
||||
u.frame(vec![Event::PointerMoved(moved)]);
|
||||
assert!(u.app.session.tool.busy(), "{pan:?}: a primary drag is under way");
|
||||
u.frame(vec![button(moved, pan, true)]);
|
||||
u.frame(vec![button(moved, PointerButton::Primary, false)]);
|
||||
u.frame(vec![button(moved, pan, false)]);
|
||||
u.frame(vec![]);
|
||||
assert!(!u.app.session.tool.busy(), "{pan:?}: the primary release reached the engine");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# FigCraft architecture
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (the `vectordrawable` crate, L4: Android Vector Drawable export; earlier: the shared hub in `collab-net` and the `figcraft-server` app) · **Target:** Figma Design
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (corrected the file I/O overview to include the existing best-effort `.fig` import and experimental export; earlier: the `vectordrawable` crate, L4: Android Vector Drawable export; the shared hub in `collab-net` and the `figcraft-server` app) · **Target:** Figma Design
|
||||
|
||||
How FigCraft is built today. Engine first, a thin swappable UI, everything a command; a Figma-shaped
|
||||
data model (a scene graph of frames with auto layout, components and variables). ~108,800 lines of
|
||||
@@ -23,7 +23,7 @@ replaced.
|
||||
| L2 | `figcraft-plugin` | ~1.0k | Plugin manifest and the WebAssembly sandbox (wasmi, metered, memory cap, no file system, network or clock) |
|
||||
| L2 | `figcraft-pathops` | ~1.1k | Booleans (linesweeper), outline stroke with alignment, arrowheads |
|
||||
| L3 | `figcraft-render` | ~5.4k | Scene → pixels on tiny-skia (CPU): isolated layers for opacity/blend/effects, clips, masks, shadows, blurs (uniform, progressive, background), noise, texture, glass, text; banded in parallel; retained rendering of only what changed; levels of detail when zoomed out |
|
||||
| L3 | `figcraft-format` | ~5.2k | Native `.figcraft` (zip of `document.json` + images; streamed JSON), local library files, versions, REST API file JSON import (`rest/`) |
|
||||
| L3 | `figcraft-format` | ~5.2k | Native `.figcraft` (zip of `document.json` + images; streamed JSON), local library files, versions, REST API file JSON import (`rest/`), best-effort `.fig` import and experimental export (`fig` module) |
|
||||
| L4 | `figcraft-svg` | ~3.7k | SVG export and import (usvg) |
|
||||
| L4 | `figcraft-pdf` | ~2.3k | PDF export (krilla) with print options |
|
||||
| L4 | `figcraft-vectordrawable` | ~1.2k | Android Vector Drawable (XML) export: baked path data, gradients through `aapt:attr`, clip groups, compatibility modes and warnings |
|
||||
@@ -67,9 +67,11 @@ express an effect.
|
||||
|
||||
`.figcraft` is the native format (atomic saves, autosave checkpoints; the desktop app keeps
|
||||
recovery copies of unsaved documents that reopen after a crash or a forced quit). Imports: REST API
|
||||
file JSON with variables and images, SVG, raster images, clipboard
|
||||
content. Exports: PNG, JPG, WebP, SVG, PDF, code. There is no `.fig` reader yet
|
||||
([`file-format-parity.md`](file-format-parity.md)).
|
||||
file JSON with variables and images, SVG, raster images, clipboard content and `.fig` files (best
|
||||
effort). Exports: PNG, JPG, WebP, SVG, PDF, code and `.fig` (experimental). The `.fig`
|
||||
[reader](../crates/format/src/fig.rs) and [writer](../crates/format/src/fig/export.rs) belong to
|
||||
`figcraft-format`; fidelity limits and validation are documented in
|
||||
[`file-format-parity.md`](file-format-parity.md).
|
||||
|
||||
## Robustness
|
||||
|
||||
@@ -83,6 +85,7 @@ input.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-04 | minor | Corrected the file I/O overview and format-crate row: best-effort `.fig` import and experimental export already exist in `figcraft-format`. No capability or readiness change. |
|
||||
| 2026-10-18 | minor | Added `vectordrawable` (L4), the Android Vector Drawable (XML) writer |
|
||||
| 2026-10-11 | minor | `collab-net` hosts through one hub for the app's sessions and the standalone server; added the `figcraft-server` app |
|
||||
| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) |
|
||||
|
||||
@@ -59,8 +59,10 @@ queue. A screenshot settles on three completed, presented UI frames (a discarded
|
||||
not count), then is issued, and expires once at its 60 s deadline if no paint arrives. `window.new`
|
||||
remains an error at once (`window 0 is minimised or hidden: …`) instead of waiting for the window
|
||||
to show; `ui.render` draws the document without it. Under Wayland the app can't tell that its
|
||||
window is minimised: where the compositor then stops drawing it, a request waits for the window
|
||||
and gets the 60 s timeout error. The browser's shared eframe runner does not implement
|
||||
window is minimised, and the compositor stops asking it to draw: a window that draws no frame for
|
||||
0.2 s while a request waits for it is treated as hidden, so the requests that act on the app's
|
||||
state are answered without a frame (about half a second each), while `ui.screenshot` and
|
||||
`ui.input` keep waiting for one. The browser's shared eframe runner does not implement
|
||||
`RequestPaintWhileHidden` yet, so hidden-tab screenshots and injected input are unqualified there.
|
||||
|
||||
Coordinates are page units, y down. `x`/`y` in `node.set`/`node.create` are relative to the parent
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hardware and platform parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** patch (under Wayland a minimised window answers control and MCP requests in about half a second instead of timing out after 60 s (#1906); images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
|
||||
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
|
||||
@@ -41,7 +41,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
|
||||
|---|---|---|---|
|
||||
| macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens. Quitting ends the process once the preferences, recovery copies and live sessions are taken care of: the window's teardown crashed on Macs with a Touch Bar (#781); the Quit menu and ⌘Q ask about unsaved changes like the main window's close button (#857), and like it close the main window only: with several windows open, each quit closes one; a quit that never asked — killed or crashed — still leaves recovery copies for the next start |
|
||||
| Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR. The portable zip keeps its settings in `config\` beside `figcraft.exe` (a `portable` file there), taking an installed copy's preferences on its first start (#1320). Draws with Direct3D 12 (FXC shaders) or OpenGL; when the graphics fail before the first frame it starts again on Vulkan, then OpenGL, then the power-saving GPU with lower limits (#965) |
|
||||
| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) |
|
||||
| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised: a window that draws nothing for 0.2 s while a control request waits for it answers the request as a hidden window does (about half a second each, #1906), and only requests that need a drawn frame (screenshots, input) wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) |
|
||||
| FreeBSD | none | tarball | |
|
||||
| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event, its text or an image copied in another app); an internal error stops the page (a panic can't be caught there), but its unsaved files are kept in the page's local storage (about 5 MB) and open again on reload (#1123) |
|
||||
| iOS / Android | apps: view, comment, mirror prototypes | none (the web build is not touch-first) | 20–40 h for a viewer |
|
||||
@@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | patch | M8, #1906: under Wayland (seen on GNOME and KDE Plasma) a minimised FigCraft window still reports itself visible and the compositor stops asking it to draw, so every control or attached-MCP request waited for a frame and timed out after 60 s ("it was not applied"), though eframe kept running the app's logic. `control::background` now treats a window that drew no frame for `NOT_DRAWING_AFTER` (0.2 s) while a request waited for it as hidden: state-only requests are answered from the logic passes (0.54 s each, measured on GNOME Wayland; they took 13.7 s or timed out before), the wait starts again when the window draws, and screenshots and `ui.input` still wait for a frame. Test: `idle_tests::a_window_that_stopped_drawing_answers_from_its_logic_passes` |
|
||||
| 2026-10-31 | minor | M1, #492: Web row: the browser pastes images copied in other apps, read from the paste event under the same limits as the desktop. Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M8: text reads only the fallback fonts it draws with. Each fallback family reads the face nearest a run's style, not all of them (Noto Sans CJK is seven files of 16–20 MB); families naming the faces of one file share its bytes; and the faces are worked out once per style until fonts change, not for every text laid out. First text laid out on a Linux machine with Noto Sans CJK, DejaVu Sans and Inter: 41 files (265 MiB, kept in memory) and 41 faces parsed → 3 files (20 MiB) and 4 faces; fallback lookups per text laid out ~45 µs → 0.2 µs. Tests: `fontdb_fallback_tests`. Numbers unchanged |
|
||||
| 2026-10-30 | minor | M8, #1117: AI agents and plugins no longer learn where the open files are on disk from `doc.list`. Numbers unchanged. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (documents: a paragraph's base direction can be forced with Text ▸ Text direction, as Add right-to-left text says (#1830); Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (; documents: a paragraph's base direction can be forced with Text ▸ Text direction, as Add right-to-left text says (#1830); Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed
|
||||
by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian
|
||||
@@ -60,8 +60,12 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
|
||||
layers; a paragraph's base direction forced with Text ▸ Text direction (#1830); right-to-left
|
||||
list markers and indents; right-to-left names in the layers panel, tabs
|
||||
and canvas labels (visual order, joined Arabic); inline IME composition with the candidate window
|
||||
at the caret; system-font fallback for scripts the UI fonts lack. Devanagari shapes through
|
||||
harfrust. No vertical text (Figma has none either).
|
||||
at the caret; system-font fallback for scripts the UI fonts lack: Hebrew and Arabic, Bengali
|
||||
and Thai (with the CJK faces), and since #2014 Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and
|
||||
Georgian, so a layer name or a String variable's value in them
|
||||
draws in the panels when the system has a face (`crates/ui-egui/src/bidi.rs`, `SCRIPT_SAMPLES`
|
||||
and `SCRIPT_FAMILIES`). Devanagari shapes through harfrust. No vertical text (Figma has none
|
||||
either).
|
||||
- **Script support in the UI:** menus widen to their longest label; dropdowns, the two-column
|
||||
labels of the right panel and the home screen's cards keep a long translation on one line with an
|
||||
ellipsis and show it in full on hover (checked in German, the longest); no mirrored
|
||||
@@ -91,7 +95,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
|
||||
| English | en | 2,352 / 2,352 (100%, source) | yes | Latin | yes (source) | full | 0 |
|
||||
| Simplified Chinese (Mandarin) | zh-Hans | 2,216 / 2,352 (94.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) |
|
||||
| Spanish | es, es-419 | 2,352 / 2,352 (100%, draft); es-419 overlays 104 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) |
|
||||
| Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 |
|
||||
| Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; the desktop UI takes an installed Devanagari face (Nirmala UI, Kohinoor Devanagari, Noto Sans Devanagari, Lohit) for names and values (#2014); no UI catalog | no | none | 4–7 |
|
||||
| Arabic | ar | 1,857 / 1,857 UI strings, 620 command labels, 31 menu wordings (draft, counted 2026-10-28) | catalog complete; native review pending | Existing bidi/shaping and Arabic system fallback; web not offered (font budget excludes Arabic); panels do not mirror | no | draft | review + panel mirroring (not re-estimated) |
|
||||
| French | fr | 2,328 / 2,352 (99.0%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
|
||||
| Portuguese | pt-BR | 2,331 / 2,352 (99.1%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
|
||||
@@ -126,6 +130,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-05 | patch | #2014: the panels drew a String variable's Amharic value as boxes: the UI took a system face for Hebrew and Arabic only. `bidi::script_fallbacks` now samples one letter of each of 16 scripts (Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as well as Hebrew and Arabic; Bengali and Thai keep `crate::cjk`'s faces) against the system's families for each (Windows, macOS, the common Linux fonts), the first installed one that draws the letter taken once. Desktop only: the web build has no system fonts. Test: `bidi::tests::other_scripts_take_an_installed_system_face` (skips scripts the machine has no face for). |
|
||||
| 2026-11-05 | minor | Documents: a paragraph's base direction can be forced left to right or right to left (Text ▸ Text direction, `text.direction`, `node.set textDirection`, the Type settings control, the `.fig` lines' `sourceDirectionality`), as Add right-to-left text says (#1830); detection stays the default. Four new UI strings (English only; other catalogs fall back). |
|
||||
| 2026-10-30 | minor | Bosnian (`bs`, Bosanski) draft catalog: every UI string (1,906), command label (629) and menu wording (31), machine-drafted in Latin script with ijekavian forms and marked as a draft awaiting native review. Bosnian plurals: one for 1, 21, 31… (not 11), few for 2–4, 22–24… (not 12–14), other for the rest. Grade and hours unchanged (Bosnian is not one of the twelve key languages) |
|
||||
| 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged |
|
||||
|
||||
+2
-1
@@ -42,7 +42,8 @@ replaced then, so a pause between calls is not an error.
|
||||
|
||||
The app keeps answering while its window is minimised or covered by other windows (see
|
||||
`docs/control-protocol.md`): edits, inspection (`inspect_ui` too), pointer, key and text input and
|
||||
renders work as usual (minimised, each request to the app takes up to about 0.1 s). Only a capture
|
||||
renders work as usual (minimised, each request to the app takes up to about 0.1 s; under Wayland,
|
||||
which can't report a minimised window, about half a second). Only a capture
|
||||
of the window itself (`screenshot` with `window`) needs it on screen: it is then an error at once;
|
||||
`screenshot` without `window` renders the document instead.
|
||||
|
||||
|
||||
@@ -643,8 +643,9 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
||||
`figcraft-cli mcp` and the desktop app, #715). A minimised or covered window answers every
|
||||
request that acts on the app's state without drawing (about 100 ms each on Windows), and the
|
||||
ones that need a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) fail at once instead of
|
||||
waiting 60 s (#720; not tested on macOS, and under Wayland a minimised window can't be told
|
||||
apart).
|
||||
waiting 60 s (#720; not tested on macOS). Under Wayland a minimised window can't be told apart,
|
||||
so a window that draws nothing for 0.2 s while a request waits is answered as a hidden one
|
||||
(about half a second a request, #1906).
|
||||
- **Tests:** 963 `#[test]` functions (2026-10-10) including junk-parameter sweeps over every command (and every vector-edit command while editing), importer fuzzing and render regressions.
|
||||
|
||||
## Revision history
|
||||
|
||||
+5
-3
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user