mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-08 19:42:05 +00:00
Merge main into #2048
This commit is contained in:
commit
e47c91e131
29 files changed
+607
-54
No files matched your search
+3
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not eachLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (Line truncated
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -63,7 +63,7 @@ native speakers' reviews of the nine language drafts need people.
|
||||
| Stability | 55% (damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, #1125) | 20–40 | [gaps](docs/gaps.md) |
|
||||
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts); Arabic draft: 2,508 entries, native review and panel mirroring pending | 29–51 | [localization-parity](docs/localization-parity.md) |
|
||||
| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts; the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian, #2014); Arabic draft: 2,508 entries, native review and panel mirroring pending | 29–51 | [localization-parity](docs/localization-parity.md) |
|
||||
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) |
|
||||
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
|
||||
@@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-11-05** · M10, #2014: the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic (`bidi::script_fallbacks`: one sample letter per script, the system's families for each; Bengali and Thai keep `crate::cjk`'s choice), so a layer name or a String variable's Amharic value draws in the panels instead of boxes. Test: `bidi::tests::other_scripts_take_an_installed_system_face`.
|
||||
- **2026-11-05** · M4, #1830: a paragraph's base direction can be forced (Text ▸ Text direction: Auto, Left to right, Right to left) as Add right-to-left text says ("controls are available to override text direction per paragraph"). `ParaList::direction`, `text.direction {direction, ids?}` and the three menu commands (the edited paragraphs while editing, else the layer's), `node.set textDirection`, the Type settings control once a right-to-left script is in the text, `textDirection` / `lineDirections` in inspect, the CSS `direction`, and the `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` per paragraph). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests`.
|
||||
- **2026-11-05** · M1, #2003: the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code from rectangles and polygons (two offset squares, the result filled at reduced alpha); each entry still runs its command.
|
||||
- **2026-10-30**: M8, #1125: damaged `trak` size-table offsets are checked instead of wrapping on 32-bit targets, and an incomplete normal track is dropped entirely, never returned in part. Font indexing makes its existing bounds explicit, preserving later faces and sparse name-table reads. Synthetic regression tests in `fontdb_tests.rs`. Numbers unchanged.
|
||||
|
||||
@@ -112,3 +112,6 @@ display_name = "Denis Gomes"
|
||||
[people.Hamza0101]
|
||||
real_name = "Hamza Naveed"
|
||||
display_name = "Hamza0101"
|
||||
|
||||
[people.ashokram-si]
|
||||
real_name = "Ashok Ram"
|
||||
@@ -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"
|
||||
|
||||
@@ -2366,6 +2366,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"
|
||||
|
||||
@@ -1949,6 +1949,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"
|
||||
@@ -2582,6 +2615,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"
|
||||
|
||||
+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() {
|
||||
|
||||
@@ -1030,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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -271,7 +271,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 @@
|
||||
# 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 |
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (; text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shorLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widtLine truncated
|
||||
|
||||
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
|
||||
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
|
||||
@@ -61,7 +61,7 @@ interaction feel not yet compared.
|
||||
| Rotate just outside a corner; Shift in 15° steps | done | rotated handle box when layers share a rotation |
|
||||
| Flip, rotate 90° | done | |
|
||||
| Rotation origin (Alt+R) | done | a handle at the selection's centre to drag (snaps to the box's corners, edge middles and centre), double-click puts it back, Escape or Alt+R hides it; the corner handles, the rotation field, Rotate 90°/180° and `transform.rotate` turn about it; it follows the layers and lasts as long as the selection, not saved (the documentation doesn't say; pinned) |
|
||||
| Enter with a shape or frame tool chosen | done | places what a click makes (100×100, lines 100 long, sections 400×300) in the middle of the view, selected (`tool.place`); from the toolbar, Enter on the chosen tool. No crosshair to move with the arrows first |
|
||||
| Enter with a shape or frame tool chosen | done | places what a click makes (100×100, lines 100 long, sections 400×300) in the middle of the view, selected (`tool.place`); from the toolbar, Enter on the chosen tool. With nothing selected, the arrows first show a crosshair in the middle of the view and then move it by the nudge amounts (`tool.crosshair`; Shift the large one), and Enter places there; Escape or a tool change takes it away ("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", Accessibility at Figma; #1839) |
|
||||
| Alt-drag duplicate | done | the copy snaps to the layer it came from too (edges, centres, equal spacing); Duplicate (Cmd+D) then repeats the copy's offset, also after a Duplicate whose copy was moved, as long as the copies stay selected (the documentation doesn't say; pinned from community answers) |
|
||||
| Drag threshold; double-click into groups and text | done | a press moves or resizes only once the pointer has travelled 3 px on screen, so a click or double-click made with an unsteady hand drags nothing; a double-click goes one level into a group and the next one edits the text under it, wherever on the text it lands, with all of it selected (a double-click in the text being edited selects the word) (`double_click_tests`); Enter on a selected text does the same (all of it selected, typing replaces it) and Escape leaves editing with the layer still selected (`text_enter_tests`) |
|
||||
| Scale tool (K): handles about an anchor, live factor, panel | done | |
|
||||
@@ -81,7 +81,7 @@ interaction feel not yet compared.
|
||||
|
||||
| Tool | Status | Notes |
|
||||
|---|---|---|
|
||||
| Move, hand (Space), zoom (hold Z, Alt out, drag area) | done | |
|
||||
| Move, hand (Space), zoom (hold Z, Alt out, drag area) | done | the middle mouse button and a mouse's side buttons (4 and 5) pan as Space does (#1758) |
|
||||
| Frame (presets panel, swap orientation), section, slice | done | |
|
||||
| Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) |
|
||||
| Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only |
|
||||
@@ -180,8 +180,10 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | patch | M7, #2032: closing a tab whose file has unsaved changes, by its X, a middle click, File ▸ Close tab or its shortcut, only showed a toast ("save first, or close again with Alt to discard") and left the tab open, so closing seemed not to work. It now asks the unsaved-changes question the window asks (`windows::TabClose`): Don't Save closes the tab, Cancel or Escape keeps it, Save saves the file and then closes it (a file without a path, or saved as .fig, asks where with Save as first and stays open if that is cancelled); Alt+close still drops the changes at once, and `file.close`'s `dialog.unsaved` request asks too. The three toasts left the catalogs. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Reordering tabs by dragging, the issue's other half, landed on 2026-10-19 (`dragging_a_tab_along_the_bar_reorders_it_without_detaching`) |
|
||||
| 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. |
|
||||
| 2026-11-05 | minor | M4, #1830: a paragraph's base direction can be forced (`TextDirection`: Auto, Ltr, Rtl on `ParaList`): Text ▸ Text direction ▸ Auto / Left to right / Right to left (`text.directionAuto` / `Ltr` / `Rtl`) and `text.direction {direction, ids?}` set the edited paragraphs while editing, else the layer's; `node.set textDirection`; the Type settings control once a right-to-left script is in the text; inspect `textDirection` / `lineDirections`; CSS `direction`; `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` runs the algorithm per paragraph, as it starts over at each). Before, direction came from the letters only ("controls are available to override text direction per paragraph", Add right-to-left text). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests::a_lines_source_directionality_is_its_paragraphs_direction_both_ways`. |
|
||||
| 2026-11-05 | patch | M1, #2003: the Design panel's boolean operation menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code (two offset squares, the result filled at reduced alpha). Numbers unchanged. |
|
||||
| 2026-11-05 | patch | M1, #1758: a mouse's side buttons (4 and 5) pan the canvas as the middle button does, the pan ending on release over the canvas or off it; the grab cursor shows while they're held. |
|
||||
| 2026-11-04 | patch | M1, #1852: a press on one of the open gradient's handles or stops on the canvas is the colour picker's own, not a press outside it (Figma keeps the picker and the handles up while a handle or stop is dragged; the stops are made to be dragged: "You can click and drag a stop to change its placement on the gradient", Use gradients as a fill or stroke). The canvas took the press and began the drag, then the picker, drawn later in the same frame, saw a press outside its card and closed, committing the drag's interaction at once; the handles vanished with it and the drag went on without an undo step of its own. Now the picker stays while a gradient handle or stop drags, and the drag is one undo step; a press elsewhere on the canvas still closes it. A handle drag whose button goes up off the canvas (over a panel or the picker) ends there too, instead of waiting, uncommitted, to move the handle to the next press on the canvas. Tests: `colorpicker_selection_tests::dragging_a_gradient_handle_on_the_canvas_keeps_the_picker_open`, `colorpicker_selection_tests::a_gradient_drag_released_off_the_canvas_ends_there`. |
|
||||
| 2026-11-02 | patch | M8, #1875: a click with the Bend tool on a sharp point between two segments gives it handles along the line between its neighbours, a third of the way to each ("Click on a point or path where you want to add a curve", Edit vector layers); a click on a curved point still makes it sharp, a drag still sets the handles by hand, an endpoint is left alone. Between a handle's end and a point both within reach of the pointer, the nearer one on the page is hit (the handle on a tie), so a short path's point (its handles a few pixels long) can be clicked again. Before, only a drag curved a point, and a short one left two tiny handles in its direction. Tests: `vectoredit::tests::short_handles_leave_their_point_clickable`, `vectoredit::tests::a_bend_click_curves_a_sharp_point_between_its_neighbours`, `bend_tool_smooths_and_sharpens_points`. Numbers unchanged. |
|
||||
| 2026-11-02 | minor | M4, #1827: wrap style on a text layer (`WrapStyle`: Auto, Balance, Pretty): Balance re-breaks each paragraph at the narrowest width that keeps its line count (bisection over the greedy breaker, as browsers balance), Pretty narrows one of the last four lines just enough so the last line isn't one word; `text.wrapStyle`, `node.set wrapStyle` (`textWrap` too), inspect `wrapStyle`, Dev Mode CSS `text-wrap` (Tailwind `text-balance` / `text-pretty`), the Type settings control under Vertical trim. Per-paragraph settings, text-style support and the `.fig` import/export mapping stay open. Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented`. |
|
||||
|
||||
Reference in new issue
Block a user