diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2b09c5c3..82d495b4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1686,6 +1686,8 @@ mod panels_behaviour_tests; #[cfg(test)] mod pdf_print_tests; #[cfg(test)] +mod pen_end_tests; +#[cfg(test)] mod pen_overlay_tests; #[cfg(test)] mod pixel_preview_tests; diff --git a/crates/engine/src/pen_end_tests.rs b/crates/engine/src/pen_end_tests.rs new file mode 100644 index 00000000..a5d24f61 --- /dev/null +++ b/crates/engine/src/pen_end_tests.rs @@ -0,0 +1,72 @@ +//! Ending a pen path with Escape or Enter (#760): the path's vector stays in edit mode with the pen, +//! so the next path is drawn into the same layer; another Escape leaves edit mode. + +use crate::Session; +use crate::tools::{PointerEvent, PointerKind, ToolId}; +use crate::vectoredit::VTool; +use figcraft_doc::NodeKind; + +fn click(s: &mut Session, x: f64, y: f64) { + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap(); + } +} + +fn layers(s: &Session) -> usize { + s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len() +} + +/// (points, segments) of vector `id`. +fn shape(s: &Session, id: figcraft_doc::NodeId) -> (usize, usize) { + match &s.doc().unwrap().node(id).unwrap().kind { + NodeKind::Vector { network } => (network.vertices.len(), network.segments.len()), + k => panic!("not a vector: {k:?}"), + } +} + +/// A two-point pen path, ended with `end`. +fn drawn(end: fn(&mut Session)) -> (Session, figcraft_doc::NodeId) { + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + click(&mut s, 100.0, 100.0); + click(&mut s, 200.0, 100.0); + let id = s.tool.pen.as_ref().and_then(|p| p.id).unwrap(); + let undo = s.undo_depth().0; + end(&mut s); + assert!(s.tool.pen.is_none(), "the path ended"); + assert_eq!(s.undo_depth().0, undo + 1, "the path is one undo step"); + let ve = s.ds().unwrap().vector_edit.clone().expect("still editing the vector"); + assert_eq!((ve.id, ve.tool, ve.anchor), (id, VTool::Pen, None), "the pen, with no open end"); + (s, id) +} + +#[test] +fn escape_and_enter_keep_the_vector_in_edit_mode_with_the_pen() { + // Escape, and Enter (its key runs `pen_end`). + let ends: [fn(&mut Session); 2] = [|s| s.escape().unwrap(), |s| s.pen_end().unwrap()]; + for end in ends { + let (mut s, id) = drawn(end); + // The next path goes into the same layer, unconnected to the first. + click(&mut s, 100.0, 300.0); + click(&mut s, 200.0, 300.0); + assert_eq!(layers(&s), 1, "no new layer"); + assert_eq!(shape(&s, id), (4, 2), "two separate paths in one vector network"); + // Escape ends that path, the next one leaves edit mode. + s.escape().unwrap(); + assert!(s.ds().unwrap().vector_edit.is_some()); + s.escape().unwrap(); + assert!(s.ds().unwrap().vector_edit.is_none(), "the second Escape leaves edit mode"); + assert_eq!(s.ds().unwrap().selection, vec![id]); + } +} + +#[test] +fn escape_before_a_segment_leaves_nothing_to_edit() { + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + click(&mut s, 100.0, 100.0); + s.escape().unwrap(); + assert!(s.tool.pen.is_none()); + assert!(s.ds().unwrap().vector_edit.is_none()); + assert_eq!(layers(&s), 0); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index dbda1894..a5b61ca2 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -2051,13 +2051,7 @@ impl Session { if self.tool.pen.is_none() && let Some((id, v)) = selected_end_at(&doc, &self.selection(), ev.p(), 8.0 / ev.zoom.max(1e-6)) { - self.set_tool(ToolId::Move); - crate::vectoredit::begin(self, id)?; - crate::vectoredit::set_tool(self, crate::vectoredit::VTool::Pen)?; - return crate::vectoredit::set_state(self, |e| { - e.anchor = Some(v); - e.vertices = vec![v]; - }); + return self.pen_edit(id, Some(v)); } let pen = self.tool.pen.get_or_insert_with(PenPath::default); // Clicking the first anchor closes the path. @@ -2175,7 +2169,30 @@ impl Session { Ok(()) } - /// Finish the pen path (Enter/Escape/double-click). + /// Edit `id` as a vector with the pen, going on from point `anchor` (or, without one, starting + /// a new path in the same layer at the next click). + fn pen_edit(&mut self, id: NodeId, anchor: Option) -> Result<()> { + self.set_tool(ToolId::Move); + crate::vectoredit::begin(self, id)?; + crate::vectoredit::set_tool(self, crate::vectoredit::VTool::Pen)?; + crate::vectoredit::set_state(self, |e| { + e.anchor = anchor; + e.vertices = anchor.into_iter().collect(); + }) + } + + /// Enter/Escape while the pen draws: the path ends but its layer stays in edit mode with the + /// pen, so the next path joins the same vector (another Escape leaves edit mode). + pub fn pen_end(&mut self) -> Result<()> { + let id = self.tool.pen.as_ref().and_then(|p| p.id); + self.pen_finish()?; + match id { + Some(id) if self.doc()?.contains(id) => self.pen_edit(id, None), + _ => Ok(()), + } + } + + /// Finish the pen path (and keep the pen tool: double-click, Delete, a tool switch). pub fn pen_finish(&mut self) -> Result<()> { let pen = self.tool.pen.take(); self.tool.overlay.pen.clear(); @@ -2320,7 +2337,7 @@ impl Session { return Ok(()); } if self.tool.pen.is_some() { - return self.pen_finish(); + return self.pen_end(); } if self.tool.busy() { self.tool.gesture = Gesture::None; diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/vectoredit.rs index f923de80..a3fbf803 100644 --- a/crates/engine/src/vectoredit.rs +++ b/crates/engine/src/vectoredit.rs @@ -508,16 +508,15 @@ pub fn begin_many(s: &mut Session, ids: &[NodeId]) -> Result<()> { let before = s.ds()?.doc.clone(); let mut edited = Vec::new(); let mut first_err = None; - { - let doc = s.doc_mut()?; - for id in ids.iter().take(MAX_EDITED) { - if edited.contains(id) { - continue; - } - match ensure_vector(doc, *id) { - Ok(()) => edited.push(*id), - Err(e) => first_err = first_err.or(Some(e)), - } + for id in ids.iter().take(MAX_EDITED) { + if edited.contains(id) { + continue; + } + // Vectors are left untouched, so editing only them records no undo step. + let is_vector = before.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Vector { .. })); + match if is_vector { Ok(()) } else { ensure_vector(s.doc_mut()?, *id) } { + Ok(()) => edited.push(*id), + Err(e) => first_err = first_err.or(Some(e)), } } let Some((&id, rest)) = edited.split_first() else { diff --git a/crates/format/src/fig/auto_layout_sizing_tests.rs b/crates/format/src/fig/auto_layout_sizing_tests.rs index 99730f70..29580afb 100644 --- a/crates/format/src/fig/auto_layout_sizing_tests.rs +++ b/crates/format/src/fig/auto_layout_sizing_tests.rs @@ -1,8 +1,8 @@ //! How auto layout frames and text size on import (hug or fixed), on encoded `.fig` files built in //! code and REST file JSON: the sizing a file records, an auto layout frame that records none, and -//! text that hugs by its resize mode. +//! text that hugs by its resize mode. Also how distributed main-axis alignment reads. -use figcraft_doc::{Document, Sizing}; +use figcraft_doc::{Document, MainAlign, Sizing}; use serde_json::{Value, json}; use super::kiwi::{Kv, Schema}; @@ -11,6 +11,7 @@ const SCHEMA: &str = " enum NodeType NONE DOCUMENT CANVAS FRAME TEXT SYMBOL INSTANCE enum StackMode NONE HORIZONTAL VERTICAL enum StackSize FIXED RESIZE_TO_FIT RESIZE_TO_FIT_WITH_IMPLICIT_SIZE +enum StackJustify MIN CENTER MAX SPACE_EVENLY SPACE_BETWEEN SPACE_AROUND SPACE_EVENLY_CSS enum TextAutoResize NONE WIDTH_AND_HEIGHT HEIGHT struct GUID uint sessionID, uint localID struct Vector float x, float y @@ -18,7 +19,7 @@ struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 message ParentIndex GUID guid, string position message TextData string characters message SymbolData GUID symbolID -message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, StackMode stackMode, StackSize stackPrimarySizing, StackSize stackCounterSizing, TextAutoResize textAutoResize, float fontSize, TextData textData, SymbolData symbolData +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, StackMode stackMode, StackSize stackPrimarySizing, StackSize stackCounterSizing, StackJustify stackPrimaryAlignItems, TextAutoResize textAutoResize, float fontSize, TextData textData, SymbolData symbolData message Message NodeChange[] nodeChanges "; @@ -191,3 +192,27 @@ fn rest_files_read_left_out_sizing_modes_as_hug_and_text_by_its_resize_mode() { assert_eq!(sizing(&doc, name), want, "{name}"); } } + +#[test] +fn distributed_alignment_reads_the_older_evenly_name_as_space_between() { + let rows = [ + ("Between", "SPACE_BETWEEN", MainAlign::SpaceBetween), + ("Older evenly", "SPACE_EVENLY", MainAlign::SpaceBetween), + ("Around", "SPACE_AROUND", MainAlign::SpaceAround), + ("Evenly", "SPACE_EVENLY_CSS", MainAlign::SpaceEvenly), + ]; + let nodes = rows + .iter() + .enumerate() + .flat_map(|(i, (name, align, _))| { + let mut fields = stack("HORIZONTAL", None, None); + fields.push(("stackPrimaryAlignItems", Kv::Enum(align))); + component(10 + 2 * i as u64, 50.0 * i as f64, name, fields, None) + }) + .collect(); + let doc = import(nodes); + for (name, _, want) in rows { + let n = doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}")); + assert_eq!(n.layout.as_ref().map(|l| l.main_align), Some(want), "{name}"); + } +} diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index ca9e2682..cac29be3 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -73,7 +73,7 @@ enum StrokeJoin MITER=0 BEVEL=1 ROUND=2 enum ConstraintType MIN=0 CENTER=1 MAX=2 STRETCH=3 SCALE=4 enum StackMode NONE=0 HORIZONTAL=1 VERTICAL=2 GRID=3 enum StackAlign MIN=0 CENTER=1 MAX=2 BASELINE=3 -enum StackJustify MIN=0 CENTER=1 MAX=2 SPACE_EVENLY=3 SPACE_BETWEEN=4 SPACE_AROUND=5 +enum StackJustify MIN=0 CENTER=1 MAX=2 SPACE_EVENLY=3 SPACE_BETWEEN=4 SPACE_AROUND=5 SPACE_EVENLY_CSS=6 enum StackCounterAlign MIN=0 CENTER=1 MAX=2 STRETCH=3 AUTO=4 BASELINE=5 enum StackSize FIXED=0 RESIZE_TO_FIT=1 enum StackWrap NO_WRAP=0 WRAP=1 @@ -1371,7 +1371,7 @@ impl<'d> Exporter<'d> { MainAlign::Max => "MAX", MainAlign::SpaceBetween => "SPACE_BETWEEN", MainAlign::SpaceAround => "SPACE_AROUND", - MainAlign::SpaceEvenly => "SPACE_EVENLY", + MainAlign::SpaceEvenly => "SPACE_EVENLY_CSS", }; let cross = match l.cross_align { CrossAlign::Min => "MIN", diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 56e9fcbf..0eac9571 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -630,9 +630,6 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) { } } if let Some(l) = &mut n.layout { - if matches!(l.main_align, MainAlign::SpaceAround | MainAlign::SpaceEvenly) { - l.main_align = MainAlign::SpaceBetween; - } // Track counts only matter (and are only written) for grids. if l.mode != LayoutMode::Grid { let d = AutoLayout::default(); diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 25540ff6..2ebe1ee9 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1404,7 +1404,10 @@ impl<'a> Mapper<'a> { let main = match v.str("stackPrimaryAlignItems").or_else(|| v.str("stackJustify")) { Some("CENTER") => "CENTER", Some("MAX") => "MAX", - Some("SPACE_BETWEEN" | "SPACE_EVENLY" | "SPACE_AROUND" | "SPACE_EVENLY_CSS") => "SPACE_BETWEEN", + Some("SPACE_AROUND") => "SPACE_AROUND", + // `SPACE_EVENLY` is the format's older name for equal gaps without edge space. + Some("SPACE_EVENLY_CSS") => "SPACE_EVENLY", + Some("SPACE_BETWEEN" | "SPACE_EVENLY") => "SPACE_BETWEEN", _ => "MIN", }; o.insert("primaryAxisAlignItems".into(), json!(main)); diff --git a/crates/format/src/rest/grid_track_tests.rs b/crates/format/src/rest/grid_track_tests.rs new file mode 100644 index 00000000..a11780ca --- /dev/null +++ b/crates/format/src/rest/grid_track_tests.rs @@ -0,0 +1,42 @@ +//! REST import of grid track sizes (`gridColumnsSizing`, `gridRowsSizing`: CSS +//! `grid-template-columns`/`-rows` strings). Fixtures are synthetic, written by hand. + +use super::*; +use figcraft_doc::GridTrack; +use serde_json::json; + +fn tracks(columns: Value, rows: Value) -> (Vec, Vec) { + let file = json!({"name": "Fixture", "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": [ + {"id": "0:1", "name": "Page", "type": "CANVAS", "children": [ + {"id": "1", "name": "Grid", "type": "FRAME", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 400, "y": 200}, + "layoutMode": "GRID", "gridColumnCount": 7, "gridRowCount": 2, "gridColumnsSizing": columns, "gridRowsSizing": rows} + ]} + ]}}); + let (doc, _) = import(&file, &no_images).unwrap(); + let layout = doc.nodes().find(|n| n.name == "Grid").unwrap().layout.clone().unwrap(); + (layout.column_tracks, layout.row_tracks) +} + +#[test] +fn css_track_strings_set_the_track_sizes() { + let (cols, rows) = tracks(json!("120px 2fr auto"), json!("repeat(2, minmax(0, 1fr))")); + assert_eq!(cols, [GridTrack::fixed(120.0), GridTrack::fill(2.0), GridTrack::hug()]); + assert_eq!(rows, [GridTrack::fill(1.0); 2]); + let (cols, rows) = tracks(json!(" repeat(2, 40px 1.5fr) minmax(64px, 64px) "), json!("min-content max-content")); + assert_eq!(cols, [GridTrack::fixed(40.0), GridTrack::fill(1.5), GridTrack::fixed(40.0), GridTrack::fill(1.5), GridTrack::fixed(64.0)]); + assert_eq!(rows, [GridTrack::hug(); 2]); +} + +#[test] +fn unsupported_or_junk_tracks_are_equal_tracks() { + // Ranges the grid can't express, units it doesn't have and junk keep their place as 1fr. + let (cols, rows) = tracks(json!("minmax(10px, 80px) 5em -3px 0fr NaNpx repeat(x, 1px) 10px"), json!(7)); + assert_eq!(cols, [[GridTrack::default(); 6].as_slice(), &[GridTrack::fixed(10.0)]].concat()); + assert!(rows.is_empty(), "a non-string is ignored"); + // Tracks past the grid's own count are left out; huge repeats and deep nesting stay bounded. + let (cols, rows) = tracks(json!(format!("repeat(999999999999, 1px) {}", "minmax(".repeat(10_000))), json!("1px 2px 3px")); + assert_eq!(cols, [GridTrack::fixed(1.0); 7]); + assert_eq!(rows, [GridTrack::fixed(1.0), GridTrack::fixed(2.0)]); + let (cols, rows) = tracks(json!("1e9px"), json!("( ) )) repeat(2,)")); + assert_eq!((cols, rows.len()), (vec![GridTrack::default()], 2)); +} diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 953522e1..be5c7157 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -29,6 +29,8 @@ mod fig_sources; #[cfg(test)] mod gamepad_tests; #[cfg(test)] +mod grid_track_tests; +#[cfg(test)] mod import_regression_tests; pub(crate) mod props; pub(crate) mod proto; diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index 1003682b..efb7710d 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -424,6 +424,7 @@ pub(super) fn auto_layout(v: &Value, fig_tracks: bool) -> Option { let pad = |k: &str| len(v, k).unwrap_or(0.0); let count = |k: &str, d: u32| num(v, k).filter(|n| *n >= 1.0).map_or(d, |n| n.min(1000.0) as u32); let grid = mode == LayoutMode::Grid; + let (columns, rows) = (count("gridColumnCount", 2), count("gridRowCount", 2)); Some(AutoLayout { mode, wrap: text(v, "layoutWrap") == Some("WRAP"), @@ -448,15 +449,84 @@ pub(super) fn auto_layout(v: &Value, fig_tracks: bool) -> Option { Some("SPACE_BETWEEN") => ContentAlign::SpaceBetween, _ => ContentAlign::Auto, }, - grid_columns: count("gridColumnCount", 2), - grid_rows: count("gridRowCount", 2), + grid_columns: columns, + grid_rows: rows, strokes_included: flag(v, "strokesIncludedInLayout").unwrap_or(false), reverse_z: flag(v, "itemReverseZIndex").unwrap_or(false), - column_tracks: if fig_tracks && grid { fig_grid_tracks(v, "columnTracks") } else { Vec::new() }, - row_tracks: if fig_tracks && grid { fig_grid_tracks(v, "rowTracks") } else { Vec::new() }, + column_tracks: grid_tracks(v, grid, fig_tracks, "columnTracks", "gridColumnsSizing", columns), + row_tracks: grid_tracks(v, grid, fig_tracks, "rowTracks", "gridRowsSizing", rows), }) } +/// A grid axis's track sizes: from the `.fig` source when there is one, else the API's CSS +/// `grid-template-*` string (at most `count` tracks, the axis's own). +fn grid_tracks(v: &Value, grid: bool, fig_tracks: bool, fig_axis: &str, css_key: &str, count: u32) -> Vec { + match (grid, fig_tracks) { + (false, _) => Vec::new(), + (true, true) => fig_grid_tracks(v, fig_axis), + (true, false) => text(v, css_key).map(|css| css_grid_tracks(css, count as usize)).unwrap_or_default(), + } +} + +/// The first `max` tracks of a CSS `grid-template-columns`/`-rows` value: `120px`, `2fr`, `auto` +/// (hug), `minmax(0, 1fr)` and `repeat(3, …)`; any other track is an equal (1fr) one. +fn css_grid_tracks(css: &str, max: usize) -> Vec { + let mut out = Vec::new(); + for item in css_split(css, char::is_whitespace) { + let repeated = item.strip_prefix("repeat(").and_then(|r| r.strip_suffix(')')).and_then(|r| { + let (count, tracks) = r.split_once(',')?; + Some((count.trim().parse::().ok()?, css_split(tracks, char::is_whitespace))) + }); + let (count, tracks) = repeated.unwrap_or((1, vec![item])); + for t in tracks.iter().cycle().take(count.saturating_mul(tracks.len())) { + if out.len() >= max { + return out; + } + out.push(css_track(t)); + } + } + out +} + +fn css_track(t: &str) -> GridTrack { + let Some(args) = t.strip_prefix("minmax(").and_then(|r| r.strip_suffix(')')) else { return css_size(t).unwrap_or_default() }; + // A flexible maximum is the track's share whatever its minimum; other ranges only when both ends agree. + match css_split(args, |c| c == ',').iter().map(|a| css_size(a.trim())).collect::>()[..] { + [_, Some(max)] if max.sizing == Sizing::Fill => max, + [Some(min), Some(max)] if min == max => min, + _ => GridTrack::default(), + } +} + +fn css_size(t: &str) -> Option { + let n = |s: &str| s.parse::().ok().filter(|n| n.is_finite() && (0.0..=MAX_SIZE).contains(n)); + if let Some(px) = t.strip_suffix("px").and_then(n) { + Some(GridTrack::fixed(px)) + } else if let Some(fr) = t.strip_suffix("fr").and_then(n) { + (fr > 0.0).then(|| GridTrack::fill(fr)) + } else { + matches!(t, "auto" | "min-content" | "max-content").then(GridTrack::hug) + } +} + +/// `s` split at the `sep` characters outside parentheses, empty parts dropped. +fn css_split(s: &str, sep: fn(char) -> bool) -> Vec<&str> { + let (mut parts, mut depth, mut start) = (Vec::new(), 0usize, 0); + for (i, c) in s.char_indices() { + match c { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + c if depth == 0 && sep(c) => { + parts.extend(s.get(start..i).filter(|p| !p.trim().is_empty())); + start = i + c.len_utf8(); + } + _ => {} + } + } + parts.extend(s.get(start..).filter(|p| !p.trim().is_empty())); + parts +} + fn fig_grid_tracks(v: &Value, axis: &str) -> Vec { v.get(crate::rest::FIG_SOURCE_FIELD) .and_then(|source| source.get("gridTracks")) diff --git a/crates/format/tests/distributed_alignment.rs b/crates/format/tests/distributed_alignment.rs new file mode 100644 index 00000000..bcf8a52d --- /dev/null +++ b/crates/format/tests/distributed_alignment.rs @@ -0,0 +1,24 @@ +//! FIG export + import keeps all three distributed main-axis alignments distinct. + +use figcraft_doc::{AutoLayout, Document, MainAlign, NodeKind}; +use figcraft_format::fig; + +#[test] +fn distributed_alignment_round_trip() { + let mut results = Vec::new(); + for expected in [MainAlign::SpaceBetween, MainAlign::SpaceAround, MainAlign::SpaceEvenly] { + let mut doc = Document::new("Alignment"); + let page = doc.first_page().unwrap(); + let frame = doc.create(page, NodeKind::Frame, "Frame", 0.0, 0.0, 200.0, 40.0).unwrap(); + doc.node_mut(frame).unwrap().layout = Some(AutoLayout { main_align: expected, ..Default::default() }); + let (native, _) = figcraft_format::load_bytes(&figcraft_format::save_bytes(&doc, None).unwrap()).unwrap(); + assert_eq!(native.node(frame).unwrap().layout.as_ref().unwrap().main_align, expected); + let (bytes, export_warnings) = fig::export(&doc, None).unwrap(); + let (imported, import_warnings) = fig::import(&bytes, "Alignment").unwrap(); + let actual = imported.nodes().find(|n| n.name == "Frame").unwrap().layout.as_ref().unwrap().main_align; + assert!(export_warnings.is_empty()); + assert!(import_warnings.is_empty()); + results.push((expected, actual)); + } + assert!(results.iter().all(|(expected, actual)| expected == actual), "{results:?}"); +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index f3610c86..fcdb21a3 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -160,7 +160,7 @@ impl Headless { self.execute("text.edit.begin", &json!({"id": self.single_text()}))?; } "select.children" if self.session.tool.pen.is_some() => { - let _ = self.session.pen_finish(); + let _ = self.session.pen_end(); } // Enter on shapes edits them as vectors (several at once). "select.children" if self.vector_targets().is_some() => { diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index d5ff4190..a6d52d67 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -406,7 +406,7 @@ fn names(f: &skrifa::FontRef) -> (Option, Option) { } /// Families tried, in order, for characters a text's own font lacks. -const FALLBACK_FAMILIES: [&str; 18] = [ +const FALLBACK_FAMILIES: [&str; 20] = [ "Inter", "Helvetica Neue", "Arial", @@ -416,6 +416,8 @@ const FALLBACK_FAMILIES: [&str; 18] = [ "BIZ UDPGothic", "Hiragino Sans", "PingFang SC", + "Hiragino Sans GB", + "Heiti SC", "Noto Sans CJK SC", "Microsoft YaHei", "Apple SD Gothic Neo", @@ -691,11 +693,17 @@ fn system_font_dirs() -> Vec { let mut v: Vec = Vec::new(); #[cfg(target_os = "macos")] { + // Subfolders (`Supplemental`) are walked too. v.push("/System/Library/Fonts".into()); v.push("/Library/Fonts".into()); if let Some(h) = std::env::var_os("HOME") { v.push(std::path::PathBuf::from(h).join("Library/Fonts")); } + // Fonts the system downloads (PingFang and other CJK faces on current versions) live in + // its asset store; `Assets` is the older versions' store. + for store in ["/System/Library/AssetsV2", "/System/Library/Assets"] { + v.extend(font_asset_dirs(std::path::Path::new(store))); + } } #[cfg(target_os = "windows")] { @@ -714,6 +722,15 @@ fn system_font_dirs() -> Vec { v } +/// The font folders (`com_apple_MobileAsset_Font*`, each holding `.asset/AssetData/*.ttc`) in +/// the macOS asset store `store`: only these are walked, not the store's voices, dictionaries and +/// models. +#[cfg(any(target_os = "macos", test))] +fn font_asset_dirs(store: &std::path::Path) -> Vec { + let Ok(rd) = std::fs::read_dir(store) else { return Vec::new() }; + rd.flatten().filter(|e| e.file_name().to_str().is_some_and(|n| n.starts_with("com_apple_MobileAsset_Font"))).map(|e| e.path()).collect() +} + // Fontconfig's default configuration uses $XDG_DATA_HOME/fonts for user fonts. // Ignore relative XDG paths, as required by the XDG Base Directory Specification. #[cfg(all(unix, not(target_os = "macos")))] diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index ea762be6..96ce7842 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -70,6 +70,44 @@ fn the_scan_reads_names_like_a_whole_file_read() { } } +#[test] +fn macos_font_assets_are_found_in_the_asset_store() { + // A store laid out like macOS's: fonts downloaded on demand sit in `.asset/AssetData` + // folders under `com_apple_MobileAsset_Font*`; other assets are never walked. Untested on a Mac. + struct TempDir(std::path::PathBuf); + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let tmp = TempDir(std::env::temp_dir().join(format!("figcraft-font-assets-{}", std::process::id()))); + let store = tmp.0.join("AssetsV2"); + let fonts = store.join("com_apple_MobileAsset_Font7"); + let data = |kind: &std::path::Path| kind.join("0f1e2d.asset").join("AssetData"); + for dir in [data(&fonts), data(&store.join("com_apple_MobileAsset_VoiceServices"))] { + std::fs::create_dir_all(dir).unwrap(); + } + // A file under a font-like name is listed but walks to nothing. + std::fs::write(store.join("com_apple_MobileAsset_Font7.plist"), b"not a folder").unwrap(); + let found = font_asset_dirs(&store); + assert!(found.contains(&fonts) && !found.iter().any(|d| d.ends_with("com_apple_MobileAsset_VoiceServices")), "{found:?}"); + assert!(font_asset_dirs(&tmp.0.join("missing")).is_empty()); + // With an installed font to copy in (none on CI), the scan finds it only in the font folder. + let installed = system_font_dirs() + .into_iter() + .find_map(|d| std::fs::read_dir(d).into_iter().flatten().flatten().map(|e| e.path()).find(|p| p.extension().is_some_and(|x| x == "ttf"))); + let Some(font) = installed else { return }; + for kind in ["com_apple_MobileAsset_Font7", "com_apple_MobileAsset_VoiceServices"] { + std::fs::copy(&font, data(&store.join(kind)).join("Font.ttf")).unwrap(); + } + let mut pending = BTreeMap::new(); + for dir in found { + scan_dir(&dir, 0, &mut pending); + } + let paths: Vec<_> = pending.values().flat_map(|(_, p)| p.iter().map(|p| p.path.clone())).collect(); + assert!(!paths.is_empty() && paths.iter().all(|p| p.starts_with(&fonts)), "{paths:?}"); +} + #[cfg(unix)] #[test] fn the_scan_skips_named_pipes() { diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs index c7fb1f98..fd3ecf8c 100644 --- a/crates/ui-egui/src/cjk.rs +++ b/crates/ui-egui/src/cjk.rs @@ -37,6 +37,8 @@ const CHINESE: &[&str] = &[ "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", + "Hiragino Sans GB", + "Heiti SC", "Noto Sans SC", "Source Han Sans SC", "WenQuanYi Micro Hei", diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f68a24f3..2200c9e8 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -289,9 +289,9 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re if m.is_none() && !repeat && opacity_digit(app, key, now) { return true; } - // Enter while the pen is drawing finishes it. + // Enter while the pen is drawing ends the path as Escape does. if key == Key::Enter && app.session.tool.pen.is_some() { - let _ = app.session.pen_finish(); + let _ = app.session.pen_end(); return true; } false diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 96293b34..b65f7729 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (`.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -19,7 +19,7 @@ and prototype interactions are still unchecked there. |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | -| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | +| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | | Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) | @@ -165,6 +165,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged | | 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged | | 2026-10-17 | minor | Hug on import (#732): auto layout frames a `.fig` records no primary-axis sizing for hug on that axis (auto layout sized that axis by its content before it could be fixed) instead of turning fixed, instances that record no auto layout take their main's (they came in without auto layout and fixed, so longer text overflowed them), REST frames whose sizing modes are left out hug (the documented default), and text hugs by its resize mode (auto width both axes, auto height its height; it came in fixed). Numbers unchanged | | 2026-10-17 | minor | Instance layers' own size and resizing (#731): a `.fig` import keeps the sizes an instance gives its layers and their hug/fill/fixed and min/max as overrides, a REST import reads the resizing fields of `overrides`, and a `.fig` export writes a layer's own size as an override (it went with the derived layout, which a relayout replaces). Numbers unchanged | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 15d49be9..50fcd4ec 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (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; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (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; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **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 @@ -68,7 +68,9 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v - **CJK UI fonts:** egui's fonts and Inter have no CJK glyphs. Once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app adds one installed face per script to the UI's fallbacks (Windows: Yu Gothic UI, - Meiryo, Malgun Gothic, Microsoft YaHei; macOS: Hiragino Sans, Apple SD Gothic Neo, PingFang SC; + Meiryo, Malgun Gothic, Microsoft YaHei; macOS: Hiragino Sans, Apple SD Gothic Neo, PingFang SC, + Hiragino Sans GB, Heiti SC, also from the system's downloaded font assets + (`/System/Library/AssetsV2/com_apple_MobileAsset_Font*`, where current versions keep PingFang); Linux and BSD: Noto Sans CJK, Source Han Sans, Nanum, WenQuanYi; the craft-fonts build's BIZ UDPGothic first), the UI language's script first; Han without kana outside a Japanese or Korean UI reads as Chinese, and a Chinese UI puts its Chinese face (Microsoft YaHei, PingFang SC, Noto Sans @@ -118,6 +120,7 @@ Hindi, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; it | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Chinese showed as boxes on macOS (#406): the font scan also walks the system's downloaded font assets (`/System/Library/AssetsV2` and the older `/System/Library/Assets`, only their `com_apple_MobileAsset_Font*` folders), where current versions keep PingFang SC, and Hiragino Sans GB and Heiti SC join the Chinese UI faces and the text fallbacks. Tested with a folder laid out the same way; not yet run on a Mac | | 2026-10-17 | minor | String counts re-measured from the catalogs: 1,680 UI keys, 561 command labels and 31 menu wordings (2,272 entries); Spanish complete, the other drafts at 95.3–99.2% (strings added since 2026-10-12 show English), es-419 101 entries. Indonesian has a draft (98.8%; it was listed as none); Russian (98.8%, reviewed), Swedish (99.6%, command labels and menu wordings from #599), Turkish (98.7%) and Czech (98.8%) listed with counts; the language picker's list updated; a paragraph repeated by a merge removed. Grade and hours unchanged (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-14 | minor | Windows UI language (#291): the interface language comes from Windows' first preferred language alone; that list is also the input-language list (a German keyboard on a Polish system adds Deutsch), and taking the first entry with a catalog turned the interface German there. A first preferred language without a catalog reads English; macOS AppleLanguages and gettext's `LANGUAGE` still take the first language with a catalog | | 2026-10-12 | minor | Drafts catch up again, and the counts with them: French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese translate the 71 UI strings and 5 command labels they lacked since sharing with a join code and open hosting (the Share popover, join codes, owner tokens, reports, the .fig export notes), and with Spanish the 26 strings and 3 command labels of your online files (Home ▸ Online, its menu and dialogs, the marks on tabs); machine-drafted in each file's conventions, still drafts. The Latin American overlay adds computadora and agrega where the new Spanish says ordenador and añade (98 entries). 2,076 entries per catalog (1,503 UI strings, 544 command labels, 29 menu wordings) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 64bda29f..dcda2c4e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; 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 shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; 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 shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) 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: @@ -81,7 +81,7 @@ interaction feel not yet compared. | 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 | -| Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`) | +| Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape or Enter ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`) | | Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: the transform box around points of several layers; areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker |