mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 07:39:20 +00:00
Merge main into #1594
This commit is contained in:
commit
525efacc65
20 files changed
+416
-57
No files matched your search
Generated
+1
@@ -1848,6 +1848,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+2
-1
@@ -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-10-30 · **Change:** minor (`.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instaLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (`.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); 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); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zLine 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
|
||||
@@ -356,6 +356,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M8, #1077: two quadratic costs in the `.fig` import's instance expansion. Variable and collection nodes sat in the layer tree, so every instance of a main walked the ones under it again, and as they never become layers the layer cap never stopped them; they are now left out of the tree (variables are read from the node list as before), so everything an expansion visits counts against the cap. A nested instance copied all of its outer instance's override rows, and its property values and slot contents; it now takes only the rows addressed below it (they sort together, so the rest aren't visited) and shares the outer values, keeping only its own. Normal files map as before. Crafted files: 20 000 variables under a main with 2 000 instances 13.8 s → 21 ms; 50 000 override rows over 2 000 nested instances 40.5 s → 98 ms (native release, the same output). Numbers unchanged. Tests: `expansion_cost_tests`. |
|
||||
| 2026-10-30 | minor | M8, #1574: the Actions palette (Ctrl+K) offered engine commands that need parameters it can't give, such as `image.place` ("Place image", needing the image's `data` or `path`), which failed with "Invalid parameters" and sat above the menu's Place image… row with the same label. A search now leaves out a command no menu lists when its params doc names a parameter without `?` (`figcraft_engine::cmd::needs_params`), and a row no menu item gives whose label reads like a menu item's (any case, without the ellipsis). Place image runs File ▸ Place image…, which opens the file dialog. Tests: `palette_tests.rs` (no row needs parameters unless a menu item runs it, no duplicate label across several searches), `unknown_params_tests.rs`. |
|
||||
| 2026-10-30 | minor | M9, #1525: share links can comment. A third access level, `comment`, sits between `edit` and `view`: someone who joins with a comment link (or its join code) sees the file live, as with a view link, and adds comment threads, replies, reactions and resolves them; any other edit is refused, by their app and by the server, which accepts a comment link's batch only when every change in it touches comment threads. Sharing online makes a comment link and code beside the edit and view ones, and the Share popover offers Can edit, Can comment and Can view; the toolbar shows a "Can comment" badge with Move, Hand and Comment. `collab.share {access: "comment"}`, `collab.manageFile {action: createLink, access: "comment"}` and `figcraft-server links create FILE --access comment` make and pick it. Older apps are unaffected: one that joins with a comment link is welcomed as a viewer, links listed to it read as view links, and a level an app doesn't know reads as view; an older server makes no comment link and sharing goes on without one. Comment authorship is the name in Preferences, as before (no accounts), so a commenter can edit or remove other people's threads. Tests: `comment_access_tests.rs` (collab), `comment_link_tests.rs` (engine, server end to end), the Share popover and toolbar in `live_share_tests.rs` and `live_server_tests.rs`. |
|
||||
| 2026-10-30 | minor | M1, #1390: Preferences ▸ Scroll wheel zoom (`pref.scrollWheelZoom`, off by default, kept with the preferences). On, a mouse wheel zooms the canvas about the pointer at the rate Cmd/Ctrl+wheel zooms otherwise, and Cmd/Ctrl+wheel scrolls; Shift+wheel still scrolls sideways, and trackpad scrolls, pinches and touch screens are unchanged. A wheel is told from a trackpad by what the platform reports: macOS names the device; elsewhere a wheel scrolls by lines and a trackpad by pixels, so a mouse whose wheel reports pixels (Chromium-based browsers) keeps scrolling, and a trackpad that reports lines (some X11 setups) zooms. Numbers unchanged. Tests: `wheel_zoom_tests`. |
|
||||
| 2026-10-30 | minor | M9, part of #1415: `comment.list` takes `mentions` (a name, any case, a leading `@` allowed) and lists only the threads where a message, the first or a reply, @mentions it, combined with the other filters (`{"mentions": "Claude", "filter": "unread"}`). It matches the names each message's `mentions` reports, by the same parser the thread view highlights with (`figcraft_doc::mentions`), so `@Ada Lovelace` is Ada Lovelace, not Ada, and an email address mentions nobody; the name counts as one of the file's people, so an unknown name can be several words. Numbers unchanged. Tests: `comment_mentions_tests`. |
|
||||
|
||||
@@ -15,3 +15,4 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
imbl = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
@@ -78,3 +78,33 @@ fn spread_reaches_further_at_sharp_corners_only() {
|
||||
assert_eq!(d.node(r).unwrap().paint_reach(), 10.0);
|
||||
assert_eq!(d.node(s).unwrap().paint_reach(), 10.0 * SPREAD_MITER_LIMIT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_layer_effects_reach_together_and_shadows_from_there() {
|
||||
let mut n = Node::new(NodeId(1), NodeKind::Rectangle, "R", 10.0, 10.0);
|
||||
let blur = |r: f64| Effect { kind: EffectKind::LayerBlur(Blur::uniform(r)), visible: true };
|
||||
// One blur reaches as before; four of 10 as one of 20 (their variances add).
|
||||
n.effects = vec![blur(10.0)];
|
||||
assert_eq!(n.layer_effects_reach(), 15.0);
|
||||
n.effects = vec![blur(10.0), blur(10.0), blur(10.0), blur(10.0)];
|
||||
assert!((n.layer_effects_reach() - 30.0).abs() < 1e-9, "{}", n.layer_effects_reach());
|
||||
// A sharp shadow below is cast from the blurred layer: it reaches the blur's distance further.
|
||||
let shadow =
|
||||
Effect { kind: EffectKind::DropShadow(Shadow { radius: 0.0, offset: (0.0, 50.0), spread: 0.0, ..Shadow::default() }), visible: true };
|
||||
n.effects = vec![blur(10.0), shadow];
|
||||
let b = n.grow_by_effects(figcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
assert_eq!((b.y0, b.y1), (-15.0, 75.0), "{b:?}");
|
||||
// Hidden effects reach nowhere.
|
||||
n.effects = vec![Effect { visible: false, ..blur(10.0) }];
|
||||
assert_eq!(n.layer_effects_reach(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reaches_too_large_to_add_stay_finite() {
|
||||
let mut n = Node::new(NodeId(1), NodeKind::Rectangle, "R", 10.0, 10.0);
|
||||
let t = Texture { radius: 1e308, ..Default::default() };
|
||||
n.effects = vec![Effect { kind: EffectKind::Texture(t.clone()), visible: true }, Effect { kind: EffectKind::Texture(t), visible: true }];
|
||||
let (reach, b) = (n.paint_reach(), n.grow_by_effects(figcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)));
|
||||
assert!(reach.is_finite() && reach > 1e6, "{reach}");
|
||||
assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.width() > 1e6, "{b:?}");
|
||||
}
|
||||
+34
-8
@@ -828,8 +828,20 @@ impl Node {
|
||||
|
||||
/// Maximum distance (in local units) painting can reach outside the node box.
|
||||
pub fn paint_reach(&self) -> f64 {
|
||||
let fx = self.effects.iter().map(Effect::reach).fold(0.0, f64::max);
|
||||
self.outline_reach() + fx + self.spread_miter_reach()
|
||||
let layer = self.layer_effects_reach();
|
||||
let fx = self.effects.iter().filter(|e| !e.moves_the_layer()).map(|e| effect_reach(e.reach())).fold(0.0, f64::max);
|
||||
self.outline_reach() + layer + fx + self.spread_miter_reach()
|
||||
}
|
||||
|
||||
/// How far the layer blurs and texture move the layer's pixels together: they apply one after
|
||||
/// another, each moving what the one before it moved. Blurs one after another spread as one
|
||||
/// blur whose radius squared is the sum of theirs (Gaussians add their variances); texture
|
||||
/// scatters by up to its radius, added on. Drop shadows are cast from what they leave.
|
||||
pub fn layer_effects_reach(&self) -> f64 {
|
||||
let moving = || self.effects.iter().filter(|e| e.moves_the_layer());
|
||||
let blurs: f64 = moving().filter(|e| matches!(e.kind, EffectKind::LayerBlur(_))).map(|e| effect_reach(e.reach()).powi(2)).sum();
|
||||
let texture: f64 = moving().filter(|e| matches!(e.kind, EffectKind::Texture(_))).map(|e| effect_reach(e.reach())).sum();
|
||||
effect_reach(blurs.sqrt()) + texture
|
||||
}
|
||||
|
||||
/// The page-space area this node paints itself under `world` (its children aside): its box
|
||||
@@ -842,18 +854,23 @@ impl Node {
|
||||
/// Page-space area `b` grown by what this node's visible effects paint around it: blurs all
|
||||
/// round, drop shadows towards their offset (in page units, as the renderer draws them), so a
|
||||
/// shadow falling down adds room below and none above.
|
||||
/// Layer blurs and texture grow it by what they reach together, and drop shadows are cast from
|
||||
/// that (see [`Node::layer_effects_reach`]).
|
||||
pub fn grow_by_effects(&self, b: figcraft_geom::Rect) -> figcraft_geom::Rect {
|
||||
let miter = self.spread_miter_reach();
|
||||
self.effects.iter().filter(|e| e.visible).fold(b, |r, e| match &e.kind {
|
||||
let layer = self.layer_effects_reach();
|
||||
let moved = if layer > 0.0 { b.inflate(layer, layer) } else { b };
|
||||
self.effects.iter().filter(|e| e.visible).fold(moved, |r, e| match &e.kind {
|
||||
EffectKind::DropShadow(s) => {
|
||||
let g = s.radius.abs() * 1.5 + s.spread.max(0.0) + miter;
|
||||
let (dx, dy) = s.offset;
|
||||
if g.is_finite() && dx.is_finite() && dy.is_finite() { r.union(b.inflate(g, g) + figcraft_geom::kurbo::Vec2::new(dx, dy)) } else { r }
|
||||
}
|
||||
_ => {
|
||||
let g = e.reach();
|
||||
if g.is_finite() && g > 0.0 { r.union(b.inflate(g, g)) } else { r }
|
||||
if g.is_finite() && dx.is_finite() && dy.is_finite() {
|
||||
r.union(moved.inflate(g, g) + figcraft_geom::kurbo::Vec2::new(dx, dy))
|
||||
} else {
|
||||
r
|
||||
}
|
||||
}
|
||||
_ => r,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -899,3 +916,12 @@ impl Node {
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
}
|
||||
|
||||
/// The most one effect's reach counts for (document units) when reaches are added up: far past any
|
||||
/// canvas, and a sum of any number of them (or of their squares) stays finite.
|
||||
const MAX_EFFECT_REACH: f64 = 1e100;
|
||||
|
||||
/// An effect's reach made safe to add: finite, in 0..[`MAX_EFFECT_REACH`].
|
||||
fn effect_reach(r: f64) -> f64 {
|
||||
if r.is_finite() { r.clamp(0.0, MAX_EFFECT_REACH) } else { 0.0 }
|
||||
}
|
||||
@@ -869,6 +869,11 @@ impl Effect {
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
/// Whether this is a visible layer blur or texture: what moves the layer's own pixels, one
|
||||
/// after another in list order.
|
||||
pub fn moves_the_layer(&self) -> bool {
|
||||
self.visible && matches!(self.kind, EffectKind::LayerBlur(_) | EffectKind::Texture(_))
|
||||
}
|
||||
/// A layer or background blur's settings.
|
||||
pub fn blur(&self) -> Option<&Blur> {
|
||||
match &self.kind {
|
||||
|
||||
+30
-4
@@ -150,8 +150,12 @@ impl TextCase {
|
||||
} else {
|
||||
out.push(ch);
|
||||
// An apostrophe only continues a word right after a letter or digit.
|
||||
let opening_apostrophe = matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(char::is_alphanumeric);
|
||||
start = !is_word_char(ch) || opening_apostrophe;
|
||||
let opening_apostrophe =
|
||||
matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(|c| c.is_alphanumeric() || is_combining_mark(c));
|
||||
// A combining mark attaches to the char before it and never changes word state.
|
||||
if !is_combining_mark(ch) {
|
||||
start = !is_word_char(ch) || opening_apostrophe;
|
||||
}
|
||||
}
|
||||
last = Some(ch);
|
||||
}
|
||||
@@ -166,9 +170,23 @@ impl TextCase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Letters, digits and apostrophes continue a word (so "don't" stays "Don't").
|
||||
/// Letters, digits, combining marks and apostrophes continue a word (so "don't" stays "Don't"
|
||||
/// and decomposed accents stay inside their word).
|
||||
fn is_word_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || matches!(c, '\'' | '\u{2019}')
|
||||
c.is_alphanumeric() || is_combining_mark(c) || matches!(c, '\'' | '\u{2019}')
|
||||
}
|
||||
|
||||
/// Combining marks such as the U+0301 accent in decomposed text: non-alphanumeric chars that
|
||||
/// extend the grapheme of the char before them.
|
||||
fn is_combining_mark(c: char) -> bool {
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
if c.is_ascii() || c.is_alphanumeric() {
|
||||
return false;
|
||||
}
|
||||
let mut buf = [0u8; 8];
|
||||
buf[0] = b'a';
|
||||
let end = 1 + c.encode_utf8(&mut buf[1..]).len();
|
||||
std::str::from_utf8(&buf[..end]).is_ok_and(|s| s.graphemes(true).count() == 1)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
@@ -969,4 +987,12 @@ mod title_case_tests {
|
||||
assert_eq!(TextCase::Title.apply_between("t", Some('n'), Some('\'')), "t");
|
||||
assert_eq!(TextCase::Title.apply_between("t", Some(' '), Some('\u{2019}')), "T");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_case_combining_marks_do_not_break_words() {
|
||||
assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301}"), "Re\u{301}sume\u{301}");
|
||||
assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301} bon"), "Re\u{301}sume\u{301} Bon");
|
||||
assert_eq!(TextCase::Title.apply("a \u{301}b"), "A \u{301}B");
|
||||
assert_eq!(TextCase::Title.apply("cafe\u{301}'s"), "Cafe\u{301}'s");
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,9 @@ struct Registry {
|
||||
index: HashMap<&'static str, usize>,
|
||||
/// The parameter names each command's doc lists (`list` order; see [`doc_param_names`]).
|
||||
names: Vec<Option<Vec<&'static str>>>,
|
||||
/// Whether each command's doc lists a parameter it can't run without (`list` order; see
|
||||
/// [`doc_needs_params`]).
|
||||
needs: Vec<bool>,
|
||||
}
|
||||
|
||||
fn registry() -> &'static Registry {
|
||||
@@ -127,7 +130,8 @@ fn registry() -> &'static Registry {
|
||||
}
|
||||
}
|
||||
let names = list.iter().map(|s| doc_param_names(s.params)).collect();
|
||||
Registry { list, index, names }
|
||||
let needs = list.iter().map(|s| doc_needs_params(s.params)).collect();
|
||||
Registry { list, index, names, needs }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -147,6 +151,25 @@ pub fn param_names(id: &str) -> Option<&'static [&'static str]> {
|
||||
r.index.get(id).and_then(|i| r.names.get(*i)).and_then(|n| n.as_deref())
|
||||
}
|
||||
|
||||
/// Whether command `id` can't run without parameters: its doc lists one that isn't optional (no
|
||||
/// `?`), so a call with none is refused (`image.place` needs `data` or `path`). A UI that runs
|
||||
/// commands without parameters (the Actions palette) leaves these out. `false` for an unknown id
|
||||
/// or a doc that isn't a `{…}` list.
|
||||
pub fn needs_params(id: &str) -> bool {
|
||||
let r = registry();
|
||||
r.index.get(id).and_then(|i| r.needs.get(*i)).is_some_and(|n| *n)
|
||||
}
|
||||
|
||||
/// Whether a `{…}` params doc lists an item whose name has no `?` (`{data (base64) | path, x?}`:
|
||||
/// yes; `{ids?, direction? (h|v)}`, `{}` or a doc of another form: no).
|
||||
fn doc_needs_params(doc: &'static str) -> bool {
|
||||
let required = |item: &str| {
|
||||
let first = item.trim().split(|c: char| c.is_whitespace() || c == '(').next().unwrap_or_default();
|
||||
first.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') && !first.contains('?')
|
||||
};
|
||||
doc_items(doc).is_some_and(|items| items.into_iter().any(required))
|
||||
}
|
||||
|
||||
/// Refuse params naming something the command doesn't take: a misspelt name would otherwise be
|
||||
/// ignored and the command answer as if it hadn't been given. `ids` and `id` are always taken:
|
||||
/// the layers a command acts on (`params::targets` reads either).
|
||||
|
||||
@@ -89,3 +89,21 @@ fn batch_steps_refuse_unknown_keys_and_parameters() {
|
||||
err(&mut s, "command.batch", json!({"commands": [{"command": "select.all"}, {"command": "comment.list", "params": {"filterr": "open"}}]}));
|
||||
assert!(e.contains("comment.list") && e.contains("step 1: unknown parameter `filterr`"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_that_cant_run_without_parameters_say_so() {
|
||||
// A parameter without `?` (or one of alternatives) is needed; `?` ones and none aren't.
|
||||
// (`file.placeImage` too: its menu item asks for the file, a call has to name it.)
|
||||
for id in ["image.place", "file.placeImage", "comment.add", "comment.reply", "tool.set"] {
|
||||
assert!(crate::cmd::needs_params(id), "{id}");
|
||||
}
|
||||
for id in ["view.zoomIn", "edit.delete", "edit.undo", "no.such.command"] {
|
||||
assert!(!crate::cmd::needs_params(id), "{id}");
|
||||
}
|
||||
// What it says holds: such a command refuses no parameters.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
for id in ["image.place", "comment.add", "tool.set"] {
|
||||
assert!(s.execute(id, &json!({})).is_err(), "{id} ran without its parameters");
|
||||
}
|
||||
}
|
||||
@@ -473,17 +473,32 @@ pub(crate) fn bounds_within<'a>(
|
||||
(r, levels)
|
||||
}
|
||||
|
||||
/// The most one effect counts for in [`effect_margin`] (document units): far past any view, and
|
||||
/// sums of any number of them stay finite.
|
||||
pub(crate) const MAX_REACH: f64 = 1e12;
|
||||
|
||||
/// How far (document units) effects that read outside a node's paint bounds reach: blurs, glass,
|
||||
/// drop shadows and texture. Culling and partial renders keep this much margin.
|
||||
///
|
||||
/// Layer blurs and texture apply one after another, each moving what the one before it moved, so
|
||||
/// their reaches add up. What the other effects paint goes through them or is cast from their
|
||||
/// result (drop shadows from the blurred layer, a backdrop blurred with it), so the largest of
|
||||
/// those reaches comes on top. Each reach counts up to [`MAX_REACH`], so the sum stays finite.
|
||||
pub fn effect_margin(n: &Node) -> f64 {
|
||||
n.effects
|
||||
.iter()
|
||||
.filter(|e| e.visible)
|
||||
let visible = || n.effects.iter().filter(|e| e.visible);
|
||||
let finite = |m: f64| if m.is_finite() { m.clamp(0.0, MAX_REACH) } else { 0.0 };
|
||||
let layer: f64 = visible()
|
||||
.map(|e| match &e.kind {
|
||||
EffectKind::LayerBlur(b) => b.max_radius() * 2.0,
|
||||
EffectKind::Texture(t) => t.radius.abs(),
|
||||
_ => 0.0,
|
||||
})
|
||||
.map(finite)
|
||||
.sum();
|
||||
let others = visible()
|
||||
.map(|e| match &e.kind {
|
||||
EffectKind::BackgroundBlur { .. } | EffectKind::Glass(_) => e.backdrop_radius() * 2.0,
|
||||
EffectKind::DropShadow(s) => s.radius * 2.0 + s.spread.abs() * figcraft_doc::SPREAD_MITER_LIMIT + s.offset.0.abs() + s.offset.1.abs(),
|
||||
EffectKind::Texture(t) => t.radius.abs(),
|
||||
// Inner shadows cast from around a layer's pixels (groups, image fills with alpha; any
|
||||
// image counts here): a layer cut off at the view would cast one from the cut.
|
||||
EffectKind::InnerShadow(s)
|
||||
@@ -493,8 +508,9 @@ pub fn effect_margin(n: &Node) -> f64 {
|
||||
}
|
||||
_ => 0.0,
|
||||
})
|
||||
.filter(|m| m.is_finite())
|
||||
.fold(0.0, f64::max)
|
||||
.map(finite)
|
||||
.fold(0.0, f64::max);
|
||||
layer + others
|
||||
}
|
||||
|
||||
/// Everything node `id` can paint, in page coordinates: its paint bounds (glyphs of overflowing
|
||||
|
||||
@@ -378,6 +378,29 @@ fn exports_include_what_effects_paint_outside_the_layer() {
|
||||
assert!(render_node(&d, f, 1.0).unwrap().height() >= 50, "frame shadow exported");
|
||||
}
|
||||
|
||||
/// Layer blurs and texture apply one after another and drop shadows are cast from what they leave:
|
||||
/// the export holds what they reach together, so nothing is cut at its edges.
|
||||
#[test]
|
||||
fn exports_hold_what_stacked_effects_reach_together() {
|
||||
let blur = |r: f64| fx(EffectKind::LayerBlur(Blur::uniform(r)));
|
||||
let rows = [
|
||||
("four blurs of 25", vec![blur(25.0), blur(25.0), blur(25.0), blur(25.0)]),
|
||||
("eight blurs of 10", (0..8).map(|_| blur(10.0)).collect()),
|
||||
("sixteen blurs of 3", (0..16).map(|_| blur(3.0)).collect()),
|
||||
("texture over blurs", vec![blur(15.0), blur(15.0), fx(EffectKind::Texture(figcraft_doc::Texture { radius: 12.0, ..Default::default() }))]),
|
||||
("a blur under a shadow", vec![blur(20.0), fx(EffectKind::DropShadow(sharp((0.0, 30.0), 0.0)))]),
|
||||
("a shadow then a blur", vec![fx(EffectKind::DropShadow(sharp((0.0, 30.0), 0.0))), blur(20.0)]),
|
||||
];
|
||||
for (why, effects) in rows {
|
||||
let (d, r) = square(vec![Paint::solid(hex("FF0000"))], effects);
|
||||
let p = render_node(&d, r, 1.0).unwrap();
|
||||
let (w, h) = (p.width(), p.height());
|
||||
let alpha = |x: u32, y: u32| p.pixel(x, y).map_or(0, |c| c.alpha());
|
||||
let edge = (0..w).flat_map(|x| [alpha(x, 0), alpha(x, h - 1)]).chain((0..h).flat_map(|y| [alpha(0, y), alpha(w - 1, y)])).max().unwrap_or(0);
|
||||
assert!(edge <= 1, "{why}: the export's edge has alpha {edge} ({w} × {h})");
|
||||
}
|
||||
}
|
||||
|
||||
/// A black 200 × 200 frame with a uniform layer blur of `radius`, drawn on a 512 × 512 view at 1×.
|
||||
fn blurred_frame(radius: f64) -> Pixmap {
|
||||
let mut d = Document::new("Blur range");
|
||||
|
||||
@@ -373,3 +373,85 @@ fn stacked_backdrop_blurs_draw_the_same_in_bands() {
|
||||
assert_same(&pix, &render_view(&doc, page, &shown, &Options::default()).unwrap(), &format!("bands of {band_h} rows"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer blurs stacked on one layer apply one after another, so they reach as far as all of them
|
||||
/// together: a layer cut at a band's edge keeps that much margin, and the bands (the browser's,
|
||||
/// the desktop's) match a render in one piece, as does a view cut across the blur.
|
||||
#[test]
|
||||
fn sequential_layer_blurs_draw_the_same_in_bands() {
|
||||
fonts();
|
||||
for (count, radius) in [(4, 25.0), (4, 100.0), (8, 10.0)] {
|
||||
let mut d = Document::new("sequential");
|
||||
let page = d.first_page().unwrap();
|
||||
for i in 0..40 {
|
||||
let r = d.create(page, NodeKind::Rectangle, "S", 0.0, f64::from(i) * 10.0, 400.0, 5.0).unwrap();
|
||||
solid(d.get_mut(r).unwrap(), if i % 2 == 0 { "E03030" } else { "3070E0" });
|
||||
}
|
||||
let b = d.create(page, NodeKind::Rectangle, "B", 100.0, 150.0, 200.0, 100.0).unwrap();
|
||||
solid(d.get_mut(b).unwrap(), "20C040");
|
||||
for _ in 0..count {
|
||||
d.get_mut(b).unwrap().effects.push(Effect { kind: EffectKind::LayerBlur(figcraft_doc::Blur::uniform(radius)), visible: true });
|
||||
}
|
||||
let doc = Arc::new(d);
|
||||
let what = format!("{count} blurs of {radius}");
|
||||
let v = View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 400 };
|
||||
for band_h in [64, 100] {
|
||||
let mut r = Retained::default();
|
||||
let (shown, pix, n) = stepped(&mut r, &doc, page, &v, band_h);
|
||||
assert!(n >= 4, "{what}: {n} bands of {band_h} rows");
|
||||
let one = crate::render_view_single(&doc, page, &shown, &Options::default()).unwrap();
|
||||
assert_within(&pix, &one, 0, 2, &format!("{what}, bands of {band_h} rows"));
|
||||
}
|
||||
// The desktop splits a view this large into parallel bands.
|
||||
let big = View { origin: (0.0, 0.0), scale: 1.0, width: 800, height: 800 };
|
||||
let one = crate::render_view_single(&doc, page, &big, &Options::default()).unwrap();
|
||||
assert_within(&render_view(&doc, page, &big, &Options::default()).unwrap(), &one, 0, 2, &format!("{what}, parallel bands"));
|
||||
// A view starting halfway down the blur: its rows as in the whole view.
|
||||
let cut = View { origin: (0.0, 200.0), scale: 1.0, width: 800, height: 600 };
|
||||
let part = crate::render_view_single(&doc, page, &cut, &Options::default()).unwrap();
|
||||
assert_within(&part, &one, 200, 2, &format!("{what}, view cut across the blur"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A drop shadow is cast from the blurred layer: a layer cut at the view keeps the blur's reach on
|
||||
/// top of the shadow's, or a rectangle above the view casts a shadow into it from a cut blur.
|
||||
#[test]
|
||||
fn a_shadow_cast_from_a_blurred_layer_draws_the_same_in_a_cut_view() {
|
||||
fonts();
|
||||
let mut d = Document::new("shadowed blur");
|
||||
let page = d.first_page().unwrap();
|
||||
let b = d.create(page, NodeKind::Rectangle, "B", 100.0, 0.0, 200.0, 100.0).unwrap();
|
||||
solid(d.get_mut(b).unwrap(), "20C040");
|
||||
let shadow = Shadow { color: Color::new(0.0, 0.0, 0.0, 1.0), offset: (0.0, 300.0), radius: 0.0, ..Shadow::default() };
|
||||
d.get_mut(b).unwrap().effects = vec![
|
||||
Effect { kind: EffectKind::LayerBlur(figcraft_doc::Blur::uniform(50.0)), visible: true },
|
||||
Effect { kind: EffectKind::DropShadow(shadow), visible: true },
|
||||
];
|
||||
let whole = View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 600 };
|
||||
let one = crate::render_view_single(&d, page, &whole, &Options::default()).unwrap();
|
||||
// The view starts below the rectangle, near the bottom of its shadow: a margin of the shadow's
|
||||
// reach alone would cut the layer across the rectangle.
|
||||
let cut = View { origin: (0.0, 390.0), scale: 1.0, width: 400, height: 210 };
|
||||
let part = crate::render_view_single(&d, page, &cut, &Options::default()).unwrap();
|
||||
assert_within(&part, &one, 390, 2, "the shadow of a blurred layer above the view");
|
||||
}
|
||||
|
||||
/// Reaches too large to add (texture of 1e308, twice) each count up to the cap and add up: a margin
|
||||
/// that stays finite, not none, and not the largest one alone.
|
||||
#[test]
|
||||
fn effect_margins_too_large_to_add_stay_large() {
|
||||
let mut n = Node::new(NodeId(1), NodeKind::Rectangle, "T", 10.0, 10.0);
|
||||
let t = figcraft_doc::Texture { radius: 1e308, ..Default::default() };
|
||||
n.effects = vec![Effect { kind: EffectKind::Texture(t.clone()), visible: true }, Effect { kind: EffectKind::Texture(t), visible: true }];
|
||||
let m = crate::draw::effect_margin(&n);
|
||||
assert_eq!(m, 2.0 * crate::draw::MAX_REACH);
|
||||
}
|
||||
|
||||
/// Every channel of `a` within `tol` of the same channel of `b` from row `rows_down` on.
|
||||
fn assert_within(a: &Pixmap, b: &Pixmap, rows_down: u32, tol: u8, what: &str) {
|
||||
assert_eq!(a.width(), b.width(), "{what}: widths");
|
||||
let row = a.width() as usize * 4;
|
||||
let below = b.data().get(rows_down as usize * row..).unwrap();
|
||||
let worst = a.data().iter().zip(below).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0);
|
||||
assert!(worst <= tol, "{what}: differs by {worst} levels");
|
||||
}
|
||||
@@ -30,6 +30,7 @@ pub use draw::{paint, paint_outlines};
|
||||
icons! {
|
||||
Move,
|
||||
Scale,
|
||||
FitContents,
|
||||
Hand,
|
||||
Frame,
|
||||
Section,
|
||||
|
||||
@@ -285,6 +285,14 @@ fn draw(p: &P, icon: Icon) {
|
||||
p.line(6.0, 10.0, 13.0, 3.0);
|
||||
p.poly(&[(9.5, 2.5), (13.5, 2.5), (13.5, 6.5)], false);
|
||||
}
|
||||
FitContents => {
|
||||
// Four frame corners around the content: a compact mark for fitting the frame.
|
||||
p.poly(&[(2.5, 6.0), (2.5, 2.5), (6.0, 2.5)], false);
|
||||
p.poly(&[(10.0, 2.5), (13.5, 2.5), (13.5, 6.0)], false);
|
||||
p.poly(&[(2.5, 10.0), (2.5, 13.5), (6.0, 13.5)], false);
|
||||
p.poly(&[(10.0, 13.5), (13.5, 13.5), (13.5, 10.0)], false);
|
||||
p.rect(5.5, 5.5, 10.5, 10.5, 0.0);
|
||||
}
|
||||
Hand => {
|
||||
hand(p);
|
||||
}
|
||||
|
||||
@@ -256,9 +256,10 @@ const MAX_HITS: usize = 60;
|
||||
|
||||
/// Commands grouped by top-level menu, filtered by the query. Without one, the menus' items in
|
||||
/// catalog order; a search covers every command (the engine's and the UI's own, "Other" for those
|
||||
/// no menu lists), best matches first (labels starting with the query, then the shortest), each
|
||||
/// group placed by its best row. Labels match in the UI language and in English. The Plugins
|
||||
/// menu's items come with the plugins ([`plugin_rows`]).
|
||||
/// no menu lists, except those that need parameters or read like a menu item), best matches first
|
||||
/// (labels starting with the query, then the shortest), each group placed by its best row. Labels
|
||||
/// match in the UI language and in English. The Plugins menu's items come with the plugins
|
||||
/// ([`plugin_rows`]).
|
||||
pub(crate) fn command_sections(q: &str) -> Vec<(String, Vec<ActionRow>)> {
|
||||
let ql = q.trim().to_lowercase();
|
||||
let mut menu_of = std::collections::HashMap::new();
|
||||
@@ -266,9 +267,13 @@ pub(crate) fn command_sections(q: &str) -> Vec<(String, Vec<ActionRow>)> {
|
||||
menu_of.entry(m.id).or_insert(m);
|
||||
}
|
||||
let menu_items = MENU.iter().filter(|m| m.label != SEP).map(|m| (m.id, m.label));
|
||||
let engine = figcraft_engine::all_commands().iter().map(|c| (c.id, c.label));
|
||||
// A row runs its command without parameters (as its menu item does, dialogs included): one
|
||||
// that needs them is offered only through a menu item.
|
||||
let engine = figcraft_engine::all_commands().iter().filter(|c| !figcraft_engine::cmd::needs_params(c.id)).map(|c| (c.id, c.label));
|
||||
let candidates: Box<dyn Iterator<Item = (&str, &str)>> = if ql.is_empty() { Box::new(menu_items) } else { Box::new(menu_items.chain(engine)) };
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
// The labels menu items show: a command no menu lists that reads the same is left out.
|
||||
let mut menu_labels = std::collections::HashSet::new();
|
||||
// By top-level menu; `None`: no menu lists it.
|
||||
let mut hits: Vec<(Option<&str>, ActionRow)> = Vec::new();
|
||||
for (id, english) in candidates {
|
||||
@@ -284,6 +289,12 @@ pub(crate) fn command_sections(q: &str) -> Vec<(String, Vec<ActionRow>)> {
|
||||
Some(m) => i18n::menu_item(id, m.label),
|
||||
None => i18n::command(id, english),
|
||||
};
|
||||
let same = same_label(&label);
|
||||
if menu.is_some() {
|
||||
menu_labels.insert(same);
|
||||
} else if menu_labels.contains(&same) {
|
||||
continue;
|
||||
}
|
||||
if matches(&ql, &label, &format!("{english} {id}")) {
|
||||
hits.push((group, command_row(id, label.into_owned())));
|
||||
}
|
||||
@@ -303,6 +314,11 @@ pub(crate) fn command_sections(q: &str) -> Vec<(String, Vec<ActionRow>)> {
|
||||
groups
|
||||
}
|
||||
|
||||
/// `label` as two rows that read the same compare: any case, without a trailing ellipsis.
|
||||
fn same_label(label: &str) -> String {
|
||||
label.trim().trim_end_matches(['…', '.']).trim_end().to_lowercase()
|
||||
}
|
||||
|
||||
/// Installed plugins' commands, filtered by the query.
|
||||
fn plugin_rows(app: &FigApp, q: &str) -> Vec<ActionRow> {
|
||||
let ql = q.to_lowercase();
|
||||
|
||||
@@ -113,7 +113,10 @@ fn rows(groups: &[(String, Vec<crate::menus::ActionRow>)]) -> Vec<&crate::menus:
|
||||
#[test]
|
||||
fn search_covers_every_command() {
|
||||
let menu = figcraft_engine::catalog::MENU;
|
||||
let spec = figcraft_engine::all_commands().iter().find(|c| !menu.iter().any(|m| m.id == c.id)).expect("a command no menu lists");
|
||||
let spec = figcraft_engine::all_commands()
|
||||
.iter()
|
||||
.find(|c| !menu.iter().any(|m| m.id == c.id) && !figcraft_engine::cmd::needs_params(c.id))
|
||||
.expect("a command no menu lists that runs without parameters");
|
||||
let groups = crate::menus::command_sections(spec.id);
|
||||
let key = format!("command:{}", spec.id);
|
||||
let group = groups.iter().find(|(_, rows)| rows.iter().any(|r| r.key == key)).map(|(g, _)| g.as_str());
|
||||
@@ -184,3 +187,32 @@ fn context_menu_survives_a_same_frame_tap() {
|
||||
u.button(far, false);
|
||||
assert!(u.app.ui.context_menu.is_none(), "a later outside press closes the menu");
|
||||
}
|
||||
|
||||
/// Every row a search for `q` offers: (key, label).
|
||||
fn offered(q: &str) -> Vec<(String, String)> {
|
||||
crate::menus::command_sections(q).into_iter().flat_map(|(_, rows)| rows.into_iter().map(|r| (r.key, r.label))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_search_offers_only_what_runs_and_one_row_per_label() {
|
||||
// Place image runs what the menu item does (the file dialog); the command that needs the
|
||||
// image's data or path isn't offered on its own (#1574).
|
||||
let found = offered("place image");
|
||||
assert!(found.iter().any(|(k, _)| k == "command:file.placeImage"), "{found:?}");
|
||||
assert!(!found.iter().any(|(k, _)| k == "command:image.place"), "{found:?}");
|
||||
// Generally: no row needs parameters a palette run can't give unless a menu item runs it, and
|
||||
// a row no menu item gives doesn't read like another row.
|
||||
for q in ["place", "image", "comment", "tool", "export", "page", "a"] {
|
||||
let found = offered(q);
|
||||
let same = |l: &str| l.trim().trim_end_matches(['…', '.']).trim_end().to_lowercase();
|
||||
for (key, label) in &found {
|
||||
let id = key.trim_start_matches("command:");
|
||||
if figcraft_engine::catalog::MENU.iter().any(|m| m.id == id) {
|
||||
continue;
|
||||
}
|
||||
assert!(!figcraft_engine::cmd::needs_params(id), "{q:?} offers {id}, which needs parameters");
|
||||
let twins = found.iter().filter(|(_, l)| same(l) == same(label)).count();
|
||||
assert_eq!(twins, 1, "{q:?} offers {label:?} twice: {found:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,16 +384,23 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
};
|
||||
let w = ui.available_width();
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover());
|
||||
let lr = Rect::from_min_size(pos2(r.min.x + 8.0, r.center().y - 12.0), vec2(120.0, 24.0));
|
||||
// Keep the type menu and frame actions together. A short label must not leave
|
||||
// the orientation icon floating in the middle of the header.
|
||||
// The handoff icon also fits beside the frame controls at the panel minimum.
|
||||
let max_label_width = if is_frame { (w - 202.0).clamp(40.0, 120.0) } else { 92.0 };
|
||||
let shown = if is_frame || is_group { widgets::elide_font(ui, &label, max_label_width, semibold(13.0)) } else { label.clone() };
|
||||
let label_width = if is_frame || is_group { ui.painter().layout_no_wrap(shown.clone(), semibold(13.0), t.text).size().x + 28.0 } else { 120.0 };
|
||||
let lr = Rect::from_min_size(pos2(r.min.x + 8.0, r.center().y - 12.0), vec2(label_width, 24.0));
|
||||
let resp = ui.interact(lr, ui.scope_id().with("typedd"), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(lr, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
ui.painter().text(pos2(lr.min.x + 8.0, lr.center().y), Align2::LEFT_CENTER, &label, semibold(13.0), t.text);
|
||||
let resp = if shown == label { resp } else { resp.on_hover_text(&label) };
|
||||
let label_rect = ui.painter().text(pos2(lr.min.x + 8.0, lr.center().y), Align2::LEFT_CENTER, &shown, semibold(13.0), t.text);
|
||||
if is_frame || is_group {
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(lr.min.x + 18.0 + label.len() as f32 * 6.5, lr.center().y), vec2(10.0, 10.0)),
|
||||
Rect::from_center_size(pos2(label_rect.right() + 8.0, lr.center().y), vec2(10.0, 10.0)),
|
||||
Icon::Chevron,
|
||||
t.icon_secondary,
|
||||
);
|
||||
@@ -402,7 +409,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
menu_popup(&resp).width(180.0).show(|ui| kind_items(app, ui, false));
|
||||
}
|
||||
if is_frame {
|
||||
let sr = Rect::from_min_size(pos2(lr.max.x + 4.0, r.center().y - 12.0), vec2(24.0, 24.0));
|
||||
let sr = Rect::from_min_size(pos2(lr.max.x + 2.0, r.center().y - 12.0), vec2(24.0, 24.0));
|
||||
let sresp = ui.interact(sr, ui.scope_id().with("swap_orient"), Sense::click()).tip(tr("Swap orientation"));
|
||||
if sresp.hovered() {
|
||||
ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover);
|
||||
@@ -411,6 +418,14 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
if sresp.clicked() {
|
||||
app.run("frame.swapOrientation", json!({"ids": sel}));
|
||||
}
|
||||
let fit_r = sr.translate(vec2(26.0, 0.0));
|
||||
let fit_tip = crate::shortcuts::tip(tr("Fit to contents"), "object.resizeToFit");
|
||||
if widgets::icon_button_at(ui, ui.scope_id().with("frame_fit_contents"), fit_r, Icon::FitContents, fit_tip) {
|
||||
app.run("object.resizeToFit", json!({"ids": sel}));
|
||||
}
|
||||
if figcraft_engine::devinspect::has_status(doc, sel[0]) {
|
||||
crate::section_panel::status_icon(app, ui, doc, sel, fit_r.translate(vec2(26.0, 0.0)));
|
||||
}
|
||||
let ids: Vec<NodeId> = sel.to_vec();
|
||||
menu_popup(&resp).width(260.0).show(|ui| {
|
||||
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
//! Design panel extras for frames and sections: section colours, hiding a section's contents,
|
||||
//! handoff status (with resolving a change to a ready-for-dev layer) and resize to fit.
|
||||
|
||||
use egui::{CornerRadius, Sense, Stroke, Ui, vec2};
|
||||
use egui::{CornerRadius, Rect, Sense, Stroke, Ui, vec2};
|
||||
use figcraft_doc::{DevStatus, Document, NodeId, NodeKind};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::i18n::{tr, tr_noop, trf};
|
||||
use crate::icons::Icon;
|
||||
use crate::right::{common, menu_item, menu_popup, row};
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::right::{common, menu_check, menu_popup, row};
|
||||
use crate::widgets;
|
||||
|
||||
/// Section fill presets: light tints that keep dark text readable.
|
||||
@@ -29,25 +29,52 @@ fn is_section(doc: &Document, id: NodeId) -> bool {
|
||||
|
||||
pub use figcraft_engine::devinspect::has_status;
|
||||
|
||||
/// The handoff status of `ids` as a dropdown `width` wide ("Changed" when edited since marked ready,
|
||||
/// "Mixed" when they differ).
|
||||
pub fn status_dropdown(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], width: f32) {
|
||||
let label = match common(doc, ids, |n| (n.dev_status, n.dev_changed)) {
|
||||
/// The handoff status shown by either control ("Changed" when edited since marked ready,
|
||||
/// "Mixed" when the selection differs).
|
||||
fn status_label(doc: &Document, ids: &[NodeId]) -> String {
|
||||
match common(doc, ids, |n| (n.dev_status, n.dev_changed)) {
|
||||
Some((DevStatus::ReadyForDev, true)) => tr("Changed"),
|
||||
Some((d, _)) => tr(d.label()),
|
||||
None => tr("Mixed"),
|
||||
};
|
||||
let resp = widgets::dropdown(ui, label, width, Some(Icon::Code));
|
||||
menu_popup(&resp).show(|ui| {
|
||||
for d in DevStatus::ALL {
|
||||
if menu_item(ui, tr(d.label()), "") {
|
||||
app.set_props(ids, json!({"devStatus": d.id()}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn status_menu(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], doc: &Document) {
|
||||
let current = common(doc, ids, |n| n.dev_status);
|
||||
for d in DevStatus::ALL {
|
||||
if menu_check(ui, current == Some(d), tr(d.label()), "") {
|
||||
app.set_props(ids, json!({"devStatus": d.id()}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status_dropdown(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], width: f32) {
|
||||
let resp = widgets::dropdown(ui, &status_label(doc, ids), width, Some(Icon::Code));
|
||||
menu_popup(&resp).show(|ui| {
|
||||
status_menu(app, ui, ids, doc);
|
||||
});
|
||||
}
|
||||
|
||||
/// Compact handoff control in a single frame's selection header.
|
||||
pub fn status_icon(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], rect: Rect) {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let tip = format!("{}: {}", tr("Status"), status_label(doc, ids));
|
||||
let resp = ui.interact(rect, ui.scope_id().with("frame_dev_status"), Sense::click()).on_hover_text(tip.clone());
|
||||
crate::a11y::menu_button(&resp, &tip, crate::a11y::menu_open(&resp));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
let color = match common(doc, ids, |n| (n.dev_status, n.dev_changed)) {
|
||||
Some((DevStatus::ReadyForDev, true)) => t.warning,
|
||||
Some((DevStatus::None, _)) | None => t.icon,
|
||||
Some(_) => t.accent_text,
|
||||
};
|
||||
icons::paint(ui.painter(), rect.shrink(5.0), Icon::Code, color);
|
||||
menu_popup(&resp).width(160.0).show(|ui| status_menu(app, ui, ids, doc));
|
||||
}
|
||||
|
||||
/// Draw section colour and hide-contents controls, Dev status, changed-reason Resolve, the
|
||||
/// last-change note and Resize to fit; returns for an empty selection or one without a Dev
|
||||
/// status.
|
||||
@@ -58,6 +85,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
}
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let ids = sel.to_vec();
|
||||
let single_frame = sel.len() == 1 && sel.first().and_then(|id| doc.node(*id)).is_some_and(|n| matches!(n.kind, NodeKind::Frame));
|
||||
let col = widgets::col_width(ui.available_width());
|
||||
if sections {
|
||||
widgets::sublabels(ui, tr("Section color"), None);
|
||||
@@ -85,9 +113,11 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
}
|
||||
});
|
||||
}
|
||||
widgets::sublabels(ui, tr("Status"), None);
|
||||
if !single_frame {
|
||||
widgets::sublabels(ui, tr("Status"), None);
|
||||
row(ui, 32.0, |ui| status_dropdown(app, ui, doc, &ids, col * 2.0 + 8.0));
|
||||
}
|
||||
let changed = sel.iter().any(|i| doc.node(*i).is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed));
|
||||
row(ui, 32.0, |ui| status_dropdown(app, ui, doc, &ids, col * 2.0 + 8.0));
|
||||
if changed {
|
||||
// Changed since marked ready: say why to resolve it (back to Ready for dev).
|
||||
row(ui, 32.0, |ui| {
|
||||
@@ -117,14 +147,17 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
widgets::label(ui, &text, t.text_secondary);
|
||||
});
|
||||
}
|
||||
row(ui, 32.0, |ui| {
|
||||
let fit = ui
|
||||
.add_sized(vec2(col * 2.0 + 8.0, widgets::FIELD_H), egui::Button::new(tr("Resize to fit")))
|
||||
.on_hover_text(crate::shortcuts::tip(tr("Fit to contents"), "object.resizeToFit"));
|
||||
if fit.clicked() {
|
||||
app.run("object.resizeToFit", json!({"ids": ids}));
|
||||
}
|
||||
});
|
||||
// A single frame has the same action beside its type in the selection header.
|
||||
if !single_frame {
|
||||
row(ui, 32.0, |ui| {
|
||||
let fit = ui
|
||||
.add_sized(vec2(col * 2.0 + 8.0, widgets::FIELD_H), egui::Button::new(tr("Resize to fit")))
|
||||
.on_hover_text(crate::shortcuts::tip(tr("Fit to contents"), "object.resizeToFit"));
|
||||
if fit.clicked() {
|
||||
app.run("object.resizeToFit", json!({"ids": ids}));
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
widgets::separator(ui);
|
||||
}
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and aLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (`.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); 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); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector DrLine truncated
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
@@ -76,6 +76,7 @@ blockers.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M8, #1077: `.fig` instance expansion near linear on crafted files: variable nodes are out of the layer tree, nested instances take only the override rows below them and share property values; 13.8 s → 21 ms and 40.5 s → 98 ms on crafted files. Tests: `expansion_cost_tests` |
|
||||
| 2026-10-30 | minor | M8, #1574: the Actions palette (Ctrl+K) offered engine commands that need parameters it can't give, such as `image.place` ("Place image", needing the image's `data` or `path`), which failed with "Invalid parameters" and sat above the menu's Place image… row with the same label. A search now leaves out a command no menu lists when its params doc names a parameter without `?` (`figcraft_engine::cmd::needs_params`), and a row no menu item gives whose label reads like a menu item's (any case, without the ellipsis). Place image runs File ▸ Place image…, which opens the file dialog. Tests: `palette_tests.rs` (no row needs parameters unless a menu item runs it, no duplicate label across several searches), `unknown_params_tests.rs`. |
|
||||
| 2026-10-30 | minor | M9, #1525: share links can comment. A third access level, `comment`, sits between `edit` and `view`: someone who joins with a comment link (or its join code) sees the file live, as with a view link, and adds comment threads, replies, reactions and resolves them; any other edit is refused, by their app and by the server, which accepts a comment link's batch only when every change in it touches comment threads. Sharing online makes a comment link and code beside the edit and view ones, and the Share popover offers Can edit, Can comment and Can view; the toolbar shows a "Can comment" badge with Move, Hand and Comment. `collab.share {access: "comment"}`, `collab.manageFile {action: createLink, access: "comment"}` and `figcraft-server links create FILE --access comment` make and pick it. Older apps are unaffected: one that joins with a comment link is welcomed as a viewer, links listed to it read as view links, and a level an app doesn't know reads as view; an older server makes no comment link and sharing goes on without one. Comment authorship is the name in Preferences, as before (no accounts), so a commenter can edit or remove other people's threads. Tests: `comment_access_tests.rs` (collab), `comment_link_tests.rs` (engine, server end to end), the Share popover and toolbar in `live_share_tests.rs` and `live_server_tests.rs`. |
|
||||
| 2026-10-30 | minor | M1, #1390: Preferences ▸ Scroll wheel zoom: a mouse wheel zooms about the pointer and Cmd/Ctrl+wheel scrolls; Shift+wheel, trackpads and pinches unchanged (wheels told from trackpads by the device macOS reports, elsewhere by line versus pixel steps). Tests: `wheel_zoom_tests` |
|
||||
| 2026-10-30 | minor | M9, part of #1415: `comment.list {mentions}` lists only the threads where a message @mentions a name (any case, by the same parser as the highlighting), combined with the other filters. Tests: `comment_mentions_tests` |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (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 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; 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; a rename selects the whole name, so typing replaces it; 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 naLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (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 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; 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; a rename selects the whole name, so typing replaces it; 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 vLine 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:
|
||||
@@ -20,7 +20,7 @@ interaction feel not yet compared.
|
||||
| Minimize UI (Cmd+\\) | done | |
|
||||
| Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points |
|
||||
| Design panel's sections on one grid | done | every section lays its rows out on the same grid: the panel is 240 pt, its content 16 pt in from the left and 8 pt from the right, two 88 pt columns at that width with 8 pt gutters and a 24 pt column for the buttons at a row's end (the columns are flex, so they share the panel when it is dragged wider); rows 24 pt in a 32 pt track, 10 pt captions over them; section titles 11 pt semibold and a row's values 11 pt, their buttons 24 pt and 4 pt apart. The panel's edge is drawn inside it: a frame stroke counts as margin and took 1 pt off each side, putting every right margin 1–2 pt out (checked in the app on Windows over the control channel) |
|
||||
| Actions palette (Cmd+K, Cmd+/) | done | tabs: All (every command, grouped by menu, or Other), Assets (components and styles) and Plugins & widgets; results ranked prefix first, then shortest, at most 60 rows; Up/Down move a highlight and Enter runs it; Recents list what was run from the palette (matching translated labels); one open/toggle path from every entry point (`palette_tests`). Ranking and the Assets results not compared with Figma's |
|
||||
| Actions palette (Cmd+K, Cmd+/) | done | tabs: All (every command, grouped by menu, or Other; a command that needs parameters only through its menu item, and none twice under one label, #1574), Assets (components and styles) and Plugins & widgets; results ranked prefix first, then shortest, at most 60 rows; Up/Down move a highlight and Enter runs it; Recents list what was run from the palette (matching translated labels); one open/toggle path from every entry point (`palette_tests`). Ranking and the Assets results not compared with Figma's |
|
||||
| Context menus | partial | canvas and layers; submenus as in the main menu below, and a right-click in a new spot starts a fresh menu (Select layer closed); Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key reaches egui as `Key::Menu` (egui-winit 0.37 maps the native key), but FigCraft's keymap has no binding for `Menu`, so it does nothing yet |
|
||||
| Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder |
|
||||
| Main menu from the logo | done | generated from `catalog.rs`; popups sized to content. Submenus open after 0.15 s on their row (a click or the keyboard opens one at once) and close at once when another row is hovered; while the pointer heads for an open submenu inside the triangle to its near edge, the rows it crosses don't take over, and resting 0.3 s on one hands over; closing the menu, however it closes, clears which submenus were open, so it reopens with none (`menu_aim_tests`, 10 tests; timings tuned by feel, not measured against Figma). Menu and palette items that need a value ask for it first: Create link, Offset path and Move to page in a dialog of the shared controls, the language, user name, server and nudge items in Preferences, Open recent on the home screen's Recents; File ▸ Rename edits the file name in place |
|
||||
@@ -175,6 +175,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M8, #1574: the Actions palette (Ctrl+K) offered engine commands that need parameters it can't give, such as `image.place` ("Place image", needing the image's `data` or `path`), which failed with "Invalid parameters" and sat above the menu's Place image… row with the same label. A search now leaves out a command no menu lists when its params doc names a parameter without `?` (`figcraft_engine::cmd::needs_params`), and a row no menu item gives whose label reads like a menu item's (any case, without the ellipsis). Place image runs File ▸ Place image…, which opens the file dialog. Tests: `palette_tests.rs` (no row needs parameters unless a menu item runs it, no duplicate label across several searches), `unknown_params_tests.rs`. |
|
||||
| 2026-10-30 | minor | Preferences ▸ Scroll wheel zoom (#1390): a mouse wheel zooms about the pointer and Cmd/Ctrl+wheel scrolls; Shift+wheel, trackpads and pinches unchanged. Numbers unchanged. |
|
||||
| 2026-10-30 | minor | Boolean groups past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold; #1104) draw without their result, with a warning, and Flatten refuses them. Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M7, #1506: the hover and selection outline of a rectangle, frame, component, instance or section with rounded corners follows its geometry: each corner's own radius and the corner smoothing, as the fill draws them, whatever the stroke alignment (inside, centre or outside); square corners keep the box. It used to be the square box, so it matched only the square corners of a layer with mixed radii. Tests: `canvas::outline_tests`. Numbers unchanged |
|
||||
|
||||
Reference in new issue
Block a user