diff --git a/ROADMAP.md b/ROADMAP.md
index 4e0d088d..f43cd015 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -316,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged |
| 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. |
| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |
| 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged |
diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml
index fac4842b..6196d08d 100644
--- a/apps/figcraft/Cargo.toml
+++ b/apps/figcraft/Cargo.toml
@@ -16,6 +16,10 @@ figcraft-ui-egui = { workspace = true }
figcraft-collab-net = { workspace = true }
figcraft-format = { workspace = true }
figcraft-text = { workspace = true }
+# Shared decision of who draws the window frame on Linux (compositor vs. app), replacing the
+# bespoke Wayland xdg-decoration probe. See `[workspace.dependencies]`: pinned to the unmerged
+# craft-libs#217 branch and must be repointed to a merged `rev` before this PR is mergeable.
+craft-window = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
serde_json = { workspace = true }
@@ -45,7 +49,6 @@ wat = { workspace = true }
winresource = "0.1"
[target.'cfg(target_os = "linux")'.dependencies]
-# Use vector window controls without a title-font renderer or embedded Cantarell.
+# Use vector window controls without a title-font renderer or embedded Cantarell. The compositor
+# probe that used to need raw-window-handle and wayland-client here now lives in craft-window.
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
-raw-window-handle = "0.6"
-wayland-client = "0.31"
diff --git a/apps/figcraft/src/linux_window.rs b/apps/figcraft/src/linux_window.rs
deleted file mode 100644
index f47b61e0..00000000
--- a/apps/figcraft/src/linux_window.rs
+++ /dev/null
@@ -1,112 +0,0 @@
-//! Request winit's frame only on Wayland compositors without xdg-decoration.
-//! Desktop/session names cannot identify the backend of an individual window.
-
-use std::os::unix::net::UnixStream;
-use std::path::PathBuf;
-use std::sync::mpsc;
-use std::time::Duration;
-
-use raw_window_handle::{HasDisplayHandle, RawDisplayHandle};
-use wayland_client::globals::{GlobalListContents, registry_queue_init};
-use wayland_client::protocol::wl_registry;
-use wayland_client::{Connection, Dispatch, QueueHandle};
-
-/// Capture the socket path before winit consumes an inherited WAYLAND_SOCKET.
-/// Never consume, duplicate or query that inherited connection: there may be no
-/// independently addressable display, or WAYLAND_DISPLAY may name another server.
-pub fn display_socket() -> Option {
- if std::env::var_os("WAYLAND_SOCKET").is_some_and(|s| !s.is_empty()) {
- return None;
- }
- let display = std::env::var_os("WAYLAND_DISPLAY").filter(|s| !s.is_empty())?;
- let path = PathBuf::from(display);
- if path.is_absolute() {
- Some(path)
- } else {
- std::env::var_os("XDG_RUNTIME_DIR").filter(|s| !s.is_empty()).map(|dir| PathBuf::from(dir).join(path))
- }
-}
-
-pub fn needs_native_frame(cc: &eframe::CreationContext<'_>, socket: Option) -> bool {
- let wayland = cc.display_handle().is_ok_and(|h| matches!(h.as_raw(), RawDisplayHandle::Wayland(_)));
- if !wayland {
- log::debug!("window controls: non-Wayland window, retaining custom controls");
- return false;
- }
- let server_decorations = socket.and_then(probe);
- log::debug!("window controls: Wayland xdg-decoration available: {server_decorations:?}");
- native_frame(wayland, server_decorations)
-}
-
-fn native_frame(wayland: bool, server_decorations: Option) -> bool {
- wayland && server_decorations == Some(false)
-}
-
-fn probe(socket: PathBuf) -> Option {
- // The compositor must not be able to block startup on a registry roundtrip.
- // On failure/timeout retain FigCraft's own working title bar and buttons.
- let (send, receive) = mpsc::sync_channel(1);
- std::thread::Builder::new()
- .name("wayland-decorations".into())
- .spawn(move || {
- let _ = send.send(registry_has_decorations(socket));
- })
- .ok()?;
- receive.recv_timeout(Duration::from_millis(500)).ok().flatten()
-}
-
-fn registry_has_decorations(socket: PathBuf) -> Option {
- // A fresh connection leaves winit's event queue and inherited descriptors alone.
- let stream = UnixStream::connect(socket).ok()?;
- let connection = Connection::from_socket(stream).ok()?;
- let (globals, _queue) = registry_queue_init::(&connection).ok()?;
- Some(globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1")))
-}
-
-struct Registry;
-
-impl Dispatch for Registry {
- fn event(
- _state: &mut Self,
- _registry: &wl_registry::WlRegistry,
- _event: wl_registry::Event,
- _data: &GlobalListContents,
- _connection: &Connection,
- _queue: &QueueHandle,
- ) {
- }
-}
-
-#[cfg(test)]
-mod tests {
- #[test]
- fn only_wayland_without_server_decorations_uses_the_frame() {
- for (wayland, server_decorations, expected) in [
- (false, None, false),
- (false, Some(false), false),
- (false, Some(true), false),
- (true, Some(true), false),
- (true, Some(false), true),
- (true, None, false),
- ] {
- assert_eq!(super::native_frame(wayland, server_decorations), expected);
- }
- }
-
- #[test]
- fn an_unavailable_display_keeps_the_custom_controls() {
- assert_eq!(super::probe("/nonexistent/figcraft-wayland-test".into()), None);
- }
-
- #[test]
- fn an_unresponsive_display_cannot_block_window_startup() -> std::io::Result<()> {
- let socket = std::env::temp_dir().join(format!("figcraft-decoration-timeout-{}", std::process::id()));
- let listener = std::os::unix::net::UnixListener::bind(&socket)?;
- let start = std::time::Instant::now();
- assert_eq!(super::probe(socket.clone()), None);
- assert!(start.elapsed() < std::time::Duration::from_secs(2));
- drop(listener);
- let _ = std::fs::remove_file(socket);
- Ok(())
- }
-}
diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs
index 00856c5d..0b71149a 100644
--- a/apps/figcraft/src/main.rs
+++ b/apps/figcraft/src/main.rs
@@ -21,8 +21,6 @@ mod args;
mod control_server;
mod gamepad;
mod graphics;
-#[cfg(target_os = "linux")]
-mod linux_window;
mod recovery;
mod services;
mod startup;
@@ -222,8 +220,13 @@ fn main() -> eframe::Result {
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([800.0, 500.0])
.with_drag_and_drop(true);
- // Start frameless on Linux; the app creator checks the actual display backend
- // before enabling a client-side frame. Other windows inherit that decision.
+ // Who draws the window frame is decided once, before the viewport is built, by the shared
+ // craft-window crate (the rest of the Craft suite uses it too). On macOS the system frame is
+ // kept with FigCraft's inset traffic lights; on Windows it is the system frame; on Linux the
+ // decision follows the desktop: a Wayland compositor with server-side decorations (KDE, Sway,
+ // Hyprland, COSMIC) or an X11 window manager keeps the native frame, while GNOME's Mutter —
+ // which draws no server-side frame — leaves it to the app's own custom title bar. This replaces
+ // the old bespoke xdg-decoration probe that always drew FigCraft's custom title bar on KDE.
fig.ui.title_bar = desktop_title_bar();
viewport = window_chrome(viewport, fig.ui.title_bar);
if let Some(icon) = icon {
@@ -232,8 +235,6 @@ fn main() -> eframe::Result {
let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
graphics::configure(&mut options.wgpu_options);
figcraft_ui_egui::windows::install(fig);
- #[cfg(target_os = "linux")]
- let linux_display = linux_window::display_socket();
// Registered before the event loop starts, so it catches the Finder event that launched the app
// as well as later ones. Lives until the event loop returns; the app creator only borrows it.
#[cfg(target_os = "macos")]
@@ -244,11 +245,6 @@ fn main() -> eframe::Result {
"FigCraft",
options,
Box::new(move |_cc| {
- #[cfg(target_os = "linux")]
- if linux_window::needs_native_frame(_cc, linux_display) {
- let _ = with_app(|fig| fig.ui.title_bar = figcraft_ui_egui::TitleBar::Native);
- _cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(true));
- }
Ok(Box::new(App {
queue,
ctx_slot,
@@ -274,9 +270,35 @@ fn opens_new_file(first_run: bool, start_on_home: bool, control: bool) -> bool {
!first_run && (!start_on_home || control)
}
+/// The title bar FigCraft starts with, decided by the shared craft-window crate so every Craft app
+/// agrees on who draws the frame per desktop. The override variable is `FIGCRAFT_TITLEBAR`
+/// (`app` / `system`). macOS keeps its inset traffic lights unchanged (craft-window returns the
+/// desktop frame there); Windows keeps the system frame; on Linux the compositor/window manager
+/// decides (KDE/Sway/Hyprland/COSMIC and X11 keep the native frame, GNOME gets the custom one).
fn desktop_title_bar() -> figcraft_ui_egui::TitleBar {
+ let choice = craft_window::Choice::probe(craft_window::Prefix::new("FIGCRAFT"));
+ title_bar_for(choice.into_frame())
+}
+
+/// Map craft-window's [`craft_window::Frame`] onto FigCraft's own [`figcraft_ui_egui::TitleBar`].
+///
+/// `Frame::App` means the app draws its own frame — FigCraft's custom title bar. `Frame::Desktop`
+/// means the desktop draws it, but FigCraft keeps each platform's existing look there:
+/// - macOS: the inset title bar with its traffic lights (`TitleBar::Inset`), unchanged.
+/// - Windows: FigCraft's own custom title bar (`TitleBar::Custom`), unchanged — Windows never used
+/// the plain native frame, and craft-window returning `Desktop` on Windows must not change that.
+/// - Linux and the BSDs: the compositor's native frame (`TitleBar::Native`). This is the only
+/// behaviour change: KDE and the other server-side-decoration compositors now keep their frame.
+fn title_bar_for(frame: craft_window::Frame) -> figcraft_ui_egui::TitleBar {
use figcraft_ui_egui::TitleBar;
- if cfg!(target_os = "macos") { TitleBar::Inset } else { TitleBar::Custom }
+ match frame {
+ craft_window::Frame::Desktop if cfg!(target_os = "macos") => TitleBar::Inset,
+ // Windows kept FigCraft's own title bar before this change; keep it.
+ craft_window::Frame::Desktop if cfg!(target_os = "windows") => TitleBar::Custom,
+ craft_window::Frame::Desktop => TitleBar::Native,
+ // The app draws the frame (GNOME's Wayland session, or a `FIGCRAFT_TITLEBAR=app` override).
+ craft_window::Frame::App(_) => TitleBar::Custom,
+ }
}
fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::TitleBar) -> egui::ViewportBuilder {
@@ -288,15 +310,55 @@ fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::T
}
}
-#[cfg(all(test, target_os = "linux"))]
-mod linux_window_tests {
+#[cfg(test)]
+mod title_bar_tests {
+ use figcraft_ui_egui::TitleBar;
+
+ /// craft-window's app frame (GNOME's Wayland session, or a `FIGCRAFT_TITLEBAR=app` override)
+ /// becomes FigCraft's custom title bar on every platform, and `window_chrome` then drops the
+ /// decorations so FigCraft draws its own.
#[test]
- fn linux_starts_with_the_existing_frameless_title_bar() {
- let bar = super::desktop_title_bar();
- assert_eq!(bar, figcraft_ui_egui::TitleBar::Custom);
+ fn the_app_frame_maps_to_the_custom_title_bar() {
+ let layout = craft_window::Layout::default();
+ let bar = super::title_bar_for(craft_window::Frame::App(layout));
+ assert_eq!(bar, TitleBar::Custom);
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
assert_eq!(viewport.decorations, Some(false));
}
+
+ /// craft-window's desktop frame keeps each platform's existing look: macOS its inset traffic
+ /// lights, Windows FigCraft's own custom title bar (unchanged), and Linux/BSD the compositor's
+ /// native frame. The Linux case is the only behaviour change: KDE and the other
+ /// server-side-decoration compositors now keep their native frame.
+ #[test]
+ fn the_desktop_frame_is_mapped_per_platform() {
+ let bar = super::title_bar_for(craft_window::Frame::Desktop);
+ if cfg!(target_os = "macos") {
+ assert_eq!(bar, TitleBar::Inset);
+ } else if cfg!(target_os = "windows") {
+ // Windows kept its own title bar before and must keep it now.
+ assert_eq!(bar, TitleBar::Custom);
+ let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
+ assert_eq!(viewport.decorations, Some(false));
+ } else {
+ assert_eq!(bar, TitleBar::Native);
+ let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
+ assert_eq!(viewport.decorations, Some(true));
+ }
+ }
+
+ /// macOS is untouched by this change: its inset title bar with the system traffic lights stays,
+ /// and `window_chrome` still asks for the full-size content view rather than toggling the
+ /// window decorations.
+ #[test]
+ #[cfg(target_os = "macos")]
+ fn macos_keeps_its_inset_title_bar() {
+ let bar = super::desktop_title_bar();
+ assert_eq!(bar, TitleBar::Inset);
+ let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
+ assert_eq!(viewport.fullsize_content_view, Some(true));
+ assert_eq!(viewport.title_shown, Some(false));
+ }
}
#[cfg(test)]
diff --git a/contributors/people.toml b/contributors/people.toml
index b9172891..27307935 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -24,6 +24,10 @@ display_name = "VanTan"
real_name = "Tyler Jennings"
display_name = "tylerjenningsw"
+[people.SiadeLucas]
+real_name = "Siade Lucas"
+sync_github_name = true
+
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
diff --git a/crates/collab/src/code.rs b/crates/collab/src/code.rs
index c046682a..6eebe9f7 100644
--- a/crates/collab/src/code.rs
+++ b/crates/collab/src/code.rs
@@ -135,7 +135,7 @@ impl Local {
let query = link.strip_prefix("figcraft://join?")?;
let get = |key: &str| query.split('&').find_map(|kv| kv.split_once('=').filter(|(k, _)| *k == key).map(|(_, v)| v));
let addr: std::net::SocketAddrV4 = get("host")?.parse().ok()?;
- let hex = get("token")?.strip_prefix(TOKEN_PREFIX).filter(|h| h.len() == 11)?;
+ let hex = get("token")?.strip_prefix(TOKEN_PREFIX).filter(|h| h.len() == 11 && h.bytes().all(|b| b.is_ascii_hexdigit()))?;
let local = Local { ip: *addr.ip(), port: addr.port(), secret: u64::from_str_radix(hex, 16).ok()? };
(get("file") == Some(LOCAL_FILE) && local.port != 0).then_some(local)
}
diff --git a/crates/collab/src/code_tests.rs b/crates/collab/src/code_tests.rs
index 2dd902b3..31bd3488 100644
--- a/crates/collab/src/code_tests.rs
+++ b/crates/collab/src/code_tests.rs
@@ -104,6 +104,7 @@ fn only_local_session_links_have_a_code() {
"figcraft://join?host=[::1]:47900&file=local&token=local-0123456789a",
"figcraft://join?host=192.168.1.20:0&file=local&token=local-0123456789a",
"figcraft://join?host=192.168.1.20:47900&file=local&token=local-0123456789az",
+ "figcraft://join?host=10.0.0.1:47900&file=local&token=local-+0000000001",
"figcraft://join?server=wss://example.org/&file=local&token=local-0123456789a",
"ws://192.168.1.20:47900/?file=local&token=local-0123456789a",
"",
diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs
index 8e9f98d9..fdad0fa7 100644
--- a/crates/color/src/lib.rs
+++ b/crates/color/src/lib.rs
@@ -162,7 +162,13 @@ impl Color {
p.parse::().ok().map(|v| v / 255.0)
}
};
- let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0);
+ let alpha = match parts.get(3) {
+ Some(a) => parse_alpha(a)?,
+ None => 1.0,
+ };
+ if parts.len() > 4 {
+ return None;
+ }
return Some(Self::new(num(0)?, num(1)?, num(2)?, alpha));
}
if let Some(inner) = t.strip_prefix("hsla(").or_else(|| t.strip_prefix("hsl(")).and_then(|r| r.strip_suffix(')')) {
@@ -170,7 +176,13 @@ impl Color {
let h = parts.first()?.trim_end_matches("deg").parse::().ok()?;
let s = parts.get(1)?.trim_end_matches('%').parse::().ok()? / 100.0;
let l = parts.get(2)?.trim_end_matches('%').parse::().ok()? / 100.0;
- let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0);
+ let alpha = match parts.get(3) {
+ Some(a) => parse_alpha(a)?,
+ None => 1.0,
+ };
+ if parts.len() > 4 {
+ return None;
+ }
return Some(Hsl { h, s, l }.to_color().with_alpha(alpha));
}
if t == "transparent" {
diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs
index 4cf875bb..2216b6fa 100644
--- a/crates/color/src/tests.rs
+++ b/crates/color/src/tests.rs
@@ -114,3 +114,13 @@ fn infinite_channels_clamp() {
let c = Color::new(f32::INFINITY, f32::NEG_INFINITY, f32::NAN, f32::INFINITY);
assert_eq!((c.r, c.g, c.b, c.a), (1.0, 0.0, 0.0, 1.0));
}
+
+#[test]
+fn malformed_alpha_or_extra_args_rejected() {
+ assert_eq!(Color::parse_css("rgba(0,0,0,abc)"), None);
+ assert_eq!(Color::parse_css("hsla(0,0%,0%,abc)"), None);
+ assert_eq!(Color::parse_css("rgb(1,2,3,4,5)"), None);
+ assert_eq!(Color::parse_css("hsl(0,0%,0%,0.5,1)"), None);
+ assert_eq!(Color::parse_css("rgb(1,2,3)").unwrap().a, 1.0);
+ assert_eq!(Color::parse_css("rgba(1,2,3,0.5)").unwrap().a, 0.5);
+}
diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs
index 097dd189..458426ad 100644
--- a/crates/doc/src/text.rs
+++ b/crates/doc/src/text.rs
@@ -60,6 +60,9 @@ impl LetterSpacing {
}
pub fn trim_num(v: f64) -> String {
+ if v.abs() >= 9.0e18 {
+ return format!("{v}");
+ }
let r = (v * 100.0).round() / 100.0;
if r.fract().abs() < 1e-9 { format!("{}", r as i64) } else { format!("{r}") }
}
@@ -760,6 +763,15 @@ impl TextData {
mod case_tests {
use super::*;
+ #[test]
+ fn trim_num_prints_huge_whole_numbers() {
+ assert_eq!(trim_num(1e19), "10000000000000000000");
+ assert_eq!(trim_num(-1e19), "-10000000000000000000");
+ assert_eq!(trim_num(12.0), "12");
+ let v = 12345678901234567890.0_f64;
+ assert_eq!(trim_num(v).parse::().unwrap(), v);
+ }
+
#[test]
fn title_case_respects_context() {
assert_eq!(TextCase::Title.apply("hello wORLD don't 3d x-ray"), "Hello WORLD Don't 3d X-Ray");
diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs
index bf99c592..6a5b1012 100644
--- a/crates/engine/src/cmd/component.rs
+++ b/crates/engine/src/cmd/component.rs
@@ -56,11 +56,17 @@ pub fn specs() -> Vec {
s.set_selection(out.iter().filter_map(id_of).collect());
Ok(json!({"ids": out}))
}),
- spec!("component.combineVariants", "Combine as variants", "{ids? (components)}", true, combine),
+ spec!(
+ "component.combineVariants",
+ "Combine as variants",
+ "{ids? (main components; none already a variant, a library copy or inside an instance)} (wraps them in a new component set; names without `=` become \"Property 1=Default\", \"Property 1=VariantN\")",
+ true,
+ combine
+ ),
spec!(
"component.addVariant",
"Add variant",
- "{id? (component set or variant), property?, value?} (duplicates the variant; `property`=`value` names the new one)",
+ "{id? (component set, variant, or a main component not in a set yet: it becomes a set of itself and the new variant), property?, value?} (duplicates the variant; `property`=`value` names the new one)",
true,
add_variant
),
@@ -330,23 +336,25 @@ fn create(s: &mut Session, p: &Value) -> Result {
Ok(json!({"id": target}))
}
+/// The name a new component set takes from its first variant: the path before the last '/'
+/// ("Button/State=Hover" → "Button"), or a plain name as is ("Button"). A bare "prop=value" name
+/// ("State=Default, Size=Small") names no set.
+fn set_name_from(variant: &str) -> Option {
+ let base = variant.rsplit_once('/').map_or(variant, |(path, _)| path).trim();
+ (!base.is_empty() && !base.contains('=')).then(|| base.to_string())
+}
+
/// Wrap components in a new component set (named after the first) placed around them.
-fn wrap_in_set(doc: &mut Document, ids: &[NodeId]) -> Result {
- let cmd = "component.combineVariants";
+fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result {
let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?;
let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "page"))?;
if inside_instance(doc, top) || owning_instance(doc, parent).is_some() {
- return Err(EngineError::Disabled(cmd.into(), "components inside an instance can't be combined".into()));
+ return Err(EngineError::Disabled(cmd.into(), INSIDE_INSTANCE.into()));
}
let bounds = doc.bounds_of(ids).unwrap_or_default();
let pw = doc.world_transform(parent);
let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds.inflate(20.0, 20.0));
- let base = ids
- .first()
- .and_then(|i| doc.node(*i))
- .map(|n| n.name.split(['/', '=']).next().unwrap_or("Component").trim().to_string())
- .filter(|n| !n.is_empty() && !n.contains(','))
- .unwrap_or_else(|| "Component".into());
+ let base = ids.first().and_then(|i| doc.node(*i)).and_then(|n| set_name_from(&n.name)).unwrap_or_else(|| doc.next_name("Component"));
let set = doc.alloc_id();
let mut n = Node::new(set, NodeKind::ComponentSet { description: String::new() }, base, local.width(), local.height());
n.transform = figcraft_geom::Affine::translate((local.x0, local.y0));
@@ -358,22 +366,71 @@ fn wrap_in_set(doc: &mut Document, ids: &[NodeId]) -> Result {
Ok(set)
}
-fn combine(s: &mut Session, p: &Value) -> Result {
- let cmd = "component.combineVariants";
- let ids = req_targets(cmd, s, p)?;
- let doc = s.doc_mut()?;
+const INSIDE_INSTANCE: &str = "components inside an instance can't become variants";
+
+/// Why `ids` (distinct) can't become the variants of a new component set, or `None` when they
+/// can: anything but main components, components that are variants already (combining them would
+/// put a set in a set), library copies, components inside an instance, and a component inside
+/// another one of `ids`.
+fn combine_refusal(doc: &Document, ids: &[NodeId]) -> Option<&'static str> {
if !ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) {
- return Err(bad(cmd, "select components only"));
+ Some("select components only")
+ } else if ids.iter().any(|i| variant_set(doc, *i).is_some()) {
+ Some("components that are variants already can't be combined again")
+ } else if ids.iter().any(|i| doc.is_remote(*i)) {
+ Some(crate::library::READ_ONLY)
+ } else if ids.iter().any(|i| inside_instance(doc, *i)) {
+ Some(INSIDE_INSTANCE)
+ } else if roots(doc, ids).len() != ids.len() {
+ Some("a component can't be combined with one inside it")
+ } else {
+ None
}
- let ids = z_sorted(doc, &ids);
- let set = wrap_in_set(doc, &ids)?;
+}
+
+/// Whether "Combine as variants" is on offer for `ids` (menus and the Design panel): two or more
+/// main components that [`combine_refusal`] lets through.
+pub fn can_combine_variants(doc: &Document, ids: &[NodeId]) -> bool {
+ ids.len() >= 2 && combine_refusal(doc, ids).is_none() && ids.iter().collect::>().len() == ids.len()
+}
+
+/// Whether "Add variant" is on offer for `id`: a component set with variants, a variant, or a main
+/// component not in a set yet (it becomes one); never a library copy or inside an instance.
+pub fn can_add_variant(doc: &Document, id: NodeId) -> bool {
+ let Some(n) = doc.node(id) else { return false };
+ let local = !doc.is_remote(id) && !inside_instance(doc, id);
+ match n.kind {
+ NodeKind::ComponentSet { .. } => {
+ local && n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. })))
+ }
+ NodeKind::Component { .. } => local,
+ _ => false,
+ }
+}
+
+/// Wrap `ids` (z-sorted main components) in a new component set, naming them "Property 1=Default",
+/// "Property 1=VariantN" unless already in prop=value form. Returns the set.
+fn make_variant_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result {
+ let set = wrap_in_set(cmd, doc, ids)?;
for (k, i) in ids.iter().enumerate() {
- // Name variants "Property 1=Variant N" unless already in prop=value form.
let node = doc.get_mut(*i)?;
if !node.name.contains('=') {
node.name = format!("Property 1={}", if k == 0 { "Default".to_string() } else { format!("Variant{}", k + 1) });
}
}
+ Ok(set)
+}
+
+fn combine(s: &mut Session, p: &Value) -> Result {
+ let cmd = "component.combineVariants";
+ let mut seen = BTreeSet::new();
+ let ids: Vec = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect();
+ let doc = s.doc_mut()?;
+ if let Some(why) = combine_refusal(doc, &ids) {
+ return Err(EngineError::Disabled(cmd.into(), why.into()));
+ }
+ let ids = z_sorted(doc, &ids);
+ let set = make_variant_set(cmd, doc, &ids)?;
s.set_selection(vec![set]);
Ok(json!({"id": set}))
}
@@ -397,15 +454,19 @@ fn fit_set(doc: &mut Document, set: NodeId) {
fn add_variant(s: &mut Session, p: &Value) -> Result {
let cmd = "component.addVariant";
- let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or variant"))?;
+ let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or component"))?;
let doc = s.doc_mut()?;
let (set, base) = match doc.get(t)?.kind {
NodeKind::ComponentSet { .. } => {
let last = doc.get(t)?.children.last().copied().ok_or_else(|| bad(cmd, "the component set is empty"))?;
(t, last)
}
- NodeKind::Component { .. } => (variant_set(doc, t).ok_or_else(|| bad(cmd, "the component isn't a variant (combine as variants first)"))?, t),
- _ => return Err(bad(cmd, "select a component set or variant")),
+ NodeKind::Component { .. } => match variant_set(doc, t) {
+ Some(set) => (set, t),
+ // A main component on its own becomes a set of itself, then gains the new variant.
+ None => (make_variant_set(cmd, doc, &[t])?, t),
+ },
+ _ => return Err(bad(cmd, "select a component set or component")),
};
let options = variant_options(doc, set);
let mut pairs = variant_props(&doc.get(base)?.name);
@@ -1103,7 +1164,7 @@ fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result {
.unwrap_or_else(|| "Default".into());
let set = match owner {
Owner::Set(s) => s,
- Owner::Comp(c) => wrap_in_set(doc, &[c])?,
+ Owner::Comp(c) => wrap_in_set(cmd, doc, &[c])?,
};
for c in Owner::Set(set).components(doc) {
let cn = doc.get_mut(c)?;
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index 8e59b303..7380711f 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -542,7 +542,8 @@ fn load_with(
return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true));
}
let mut loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
- if loaded.doc.name.is_empty() {
+ // Older files saved an untouched default title even after the user chose a filename.
+ if loaded.doc.name.is_empty() || loaded.doc.name == "Untitled" {
loaded.doc.name = file_stem(name_hint);
}
Ok((loaded, false))
@@ -853,8 +854,14 @@ fn save_to(s: &mut Session, path: String) -> Result {
}
let i = crate::branches::file_tab(s);
let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?;
- let bytes = file_bytes(ds)?;
+ let mut snapshot = FileSnapshot::of(ds);
+ let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old));
+ if follows_path && ds.path.as_deref() != Some(path.as_str()) {
+ Arc::make_mut(&mut snapshot.doc).name = file_stem(&path);
+ }
+ let bytes = snapshot.encode()?;
write(&path, &bytes)?;
+ ds.doc = snapshot.doc;
ds.path = Some(path.clone());
ds.mark_saved();
ds.revision += 1;
diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs
index ff78f73b..7a5c1f90 100644
--- a/crates/engine/src/components_behaviour_tests.rs
+++ b/crates/engine/src/components_behaviour_tests.rs
@@ -879,3 +879,35 @@ fn auto_layout_instances_follow_their_main() {
assert_eq!(node(&s, i).child_layout.min_w, Some(80.0));
healthy(&s);
}
+
+#[test]
+fn a_new_component_set_is_named_after_its_first_variant() {
+ // "Button/State=Hover" groups under "Button": the set takes the path before the last '/'.
+ // A bare property name ("State=Default, Size=Small") names no set, and neither does an empty
+ // name: the set gets the next numbered default, like a new component. A plain name is kept.
+ let rows = [
+ ("Button/State=Hover", "Button", "slash-prefixed: the path before the last '/'"),
+ ("Icons/Arrow/Dir=Up", "Icons/Arrow", "nested path: everything before the last '/'"),
+ ("State=Default, Size=Small", "Component 1", "bare prop=value: not \"State\""),
+ ("", "Component 1", "empty name: the numbered default"),
+ ];
+ for (name, expected, why) in rows {
+ let mut s = Session::new();
+ let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20})));
+ ex(&mut s, "node.set", json!({"id": f, "props": {"name": name}}));
+ let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
+ assert_eq!(node(&s, c).name, name, "{why}: the variant keeps its name");
+ let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [c]})));
+ assert_eq!(node(&s, set).name, expected, "{why}");
+ healthy(&s);
+ }
+
+ // Add variant on a lone component (a variant property on it) wraps it in a set named after it.
+ let mut s = Session::new();
+ let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20, "name": "Button"})));
+ let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
+ let set = idv(&ex(&mut s, "component.addProperty", json!({"id": c, "name": "State", "type": "variant"})));
+ assert_eq!(node(&s, set).name, "Button");
+ assert_eq!(node(&s, c).name, "State=Default");
+ healthy(&s);
+}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 82781448..e0b2f12c 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -1062,7 +1062,9 @@ impl Session {
};
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
let changed = !Arc::ptr_eq(&ds.doc, &bdoc);
- if changed {
+ // Saving may adopt the chosen filename as the document title. The encoded snapshot is
+ // already final; normalising it again would detach it from the saved baseline.
+ if changed && !matches!(spec.id, "file.save" | "file.saveAs") {
normalize(ds.doc_mut(), &bdoc);
}
let edit = changed && ds.interaction.is_none();
@@ -1881,6 +1883,8 @@ mod variables_import_shapes_tests;
#[cfg(test)]
mod variables_io_tests;
#[cfg(test)]
+mod variants_discovery_tests;
+#[cfg(test)]
mod vector_behaviour_tests;
#[cfg(test)]
mod vector_multi_behaviour_tests;
diff --git a/crates/engine/src/live_access_tests.rs b/crates/engine/src/live_access_tests.rs
index 7f5a4488..c2c75bb5 100644
--- a/crates/engine/src/live_access_tests.rs
+++ b/crates/engine/src/live_access_tests.rs
@@ -265,7 +265,7 @@ fn revert_offline_file_works_while_another_file_is_live_and_after_leaving() {
s.execute("file.new", &json!({})).unwrap();
let offline_path = saved_file(&mut s);
s.execute("file.revert", &json!({})).unwrap();
- assert_eq!(s.doc().unwrap().name, "Untitled");
+ assert_eq!(s.doc().unwrap().name, offline_path.file_stem().unwrap().to_string_lossy());
assert!(!s.ds().unwrap().is_dirty());
assert_eq!(s.ds().unwrap().versions.len(), 1);
assert_eq!(s.live_of(uid).doc_uid, Some(uid));
@@ -273,7 +273,7 @@ fn revert_offline_file_works_while_another_file_is_live_and_after_leaving() {
s.execute("doc.switch", &json!({"index": 0})).unwrap();
s.execute("collab.leave", &json!({})).unwrap();
s.execute("file.revert", &json!({})).unwrap();
- assert_eq!(s.doc().unwrap().name, "Untitled");
+ assert_eq!(s.doc().unwrap().name, path.file_stem().unwrap().to_string_lossy());
assert!(!s.ds().unwrap().is_dirty());
assert_eq!(s.ds().unwrap().versions.len(), 1);
assert_eq!(s.live().doc_uid, None);
diff --git a/crates/engine/src/online.rs b/crates/engine/src/online.rs
index b982580e..66713732 100644
--- a/crates/engine/src/online.rs
+++ b/crates/engine/src/online.rs
@@ -120,7 +120,12 @@ pub fn same_server(a: &str, b: &str) -> bool {
let s = plain_server(s).to_ascii_lowercase();
let s = s.trim_end_matches('/');
let port = if s.starts_with("wss://") { ":443" } else { ":80" };
- s.strip_suffix(port).unwrap_or(s).to_string()
+ let start = s.find("://").map_or(0, |i| i + 3);
+ let end = s[start..].find('/').map_or(s.len(), |i| start + i);
+ match s[start..end].strip_suffix(port) {
+ Some(host) => format!("{}{}{}", &s[..start], host, &s[end..]),
+ None => s.to_string(),
+ }
};
norm(a) == norm(b)
}
diff --git a/crates/engine/src/online_tests.rs b/crates/engine/src/online_tests.rs
index 1e341b14..c211c505 100644
--- a/crates/engine/src/online_tests.rs
+++ b/crates/engine/src/online_tests.rs
@@ -583,3 +583,11 @@ fn debug_output_of_a_record_leaves_the_secrets_out() {
no_secrets("debug", &text);
assert!(text.contains("share.example") && text.contains(".."), "{text}");
}
+
+#[test]
+fn same_server_ignores_the_default_port_before_a_path() {
+ use crate::online::same_server;
+ assert!(same_server("wss://share.example:443/figcraft/", "wss://share.example/figcraft/"));
+ assert!(same_server("ws://host:80/x", "ws://host/x"));
+ assert!(!same_server("wss://share.example:8443/figcraft/", "wss://share.example/figcraft/"));
+}
diff --git a/crates/engine/src/save_format_tests.rs b/crates/engine/src/save_format_tests.rs
index 4473ef7c..d635808e 100644
--- a/crates/engine/src/save_format_tests.rs
+++ b/crates/engine/src/save_format_tests.rs
@@ -89,6 +89,46 @@ fn assert_save_state_unchanged(s: &Session, before: &DocState, recent: &[String]
assert_eq!(s.prefs.recent_files, recent);
}
+#[test]
+fn first_native_save_uses_chosen_filename_for_an_untitled_document() {
+ let mut files = SaveFiles::new();
+ let path = files.path("Redis.figcraft");
+ let copy = files.path("Redis copy.figcraft");
+ let manual_copy = files.path("Other.figcraft");
+ let mut s = Session::new();
+ s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();
+ assert_eq!(s.ds().unwrap().doc.name, "Redis");
+ assert!(!s.ds().unwrap().is_dirty());
+ assert_eq!(figcraft_format::load_bytes(&std::fs::read(&path).unwrap()).unwrap().0.name, "Redis");
+
+ let mut reopened = Session::empty();
+ reopened.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
+ assert_eq!(reopened.ds().unwrap().doc.name, "Redis");
+
+ s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
+ assert_eq!(s.ds().unwrap().doc.name, "Redis copy");
+ s.execute("file.rename", &json!({"name": "Chosen title"})).unwrap();
+ s.execute("file.saveAs", &json!({"path": manual_copy.to_string_lossy()})).unwrap();
+ assert_eq!(s.ds().unwrap().doc.name, "Chosen title");
+}
+
+#[test]
+fn opening_an_older_file_uses_its_filename_only_for_the_default_title() {
+ let mut files = SaveFiles::new();
+ let old_path = files.path("Redis.figcraft");
+ let named_path = files.path("Other.figcraft");
+ let mut original = Session::new();
+ std::fs::write(&old_path, crate::cmd::file::file_bytes(original.ds().unwrap()).unwrap()).unwrap();
+ original.execute("file.rename", &json!({"name": "Chosen title"})).unwrap();
+ std::fs::write(&named_path, crate::cmd::file::file_bytes(original.ds().unwrap()).unwrap()).unwrap();
+
+ let mut reopened = Session::empty();
+ reopened.execute("file.open", &json!({"path": old_path.to_string_lossy()})).unwrap();
+ assert_eq!(reopened.ds().unwrap().doc.name, "Redis");
+ reopened.execute("file.open", &json!({"path": named_path.to_string_lossy()})).unwrap();
+ assert_eq!(reopened.ds().unwrap().doc.name, "Chosen title");
+}
+
fn read_fig(path: &std::path::Path, result: &Value) -> figcraft_doc::Document {
let bytes = std::fs::read(path).unwrap();
assert_eq!(result["format"], "fig");
diff --git a/crates/engine/src/variants_discovery_tests.rs b/crates/engine/src/variants_discovery_tests.rs
new file mode 100644
index 00000000..b792e727
--- /dev/null
+++ b/crates/engine/src/variants_discovery_tests.rs
@@ -0,0 +1,179 @@
+//! Combine as variants and Add variant where people look for them (#903): the predicates the
+//! context menu and the Design panel offer them by, combining refusing components that are
+//! variants already (it used to put a set inside a set), and Add variant on a main component that
+//! isn't in a set yet making it one.
+
+use serde_json::json;
+
+use crate::Session;
+use crate::cmd::component::{can_add_variant, can_combine_variants};
+use crate::components::tests::{ex, healthy, idv, kids, node};
+use figcraft_doc::*;
+
+/// A main component named `name` at `x`, made from a 120×40 frame with a "Bg" rectangle.
+fn component(s: &mut Session, name: &str, x: f64) -> NodeId {
+ let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 120, "height": 40, "name": name})));
+ ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 120, "height": 40, "parent": f, "props": {"name": "Bg"}}));
+ idv(&ex(s, "component.create", json!({"ids": [f]})))
+}
+
+fn undo_len(s: &Session) -> usize {
+ s.ds().unwrap().history.undo.len()
+}
+
+fn sets(s: &Session) -> usize {
+ s.doc().unwrap().nodes().filter(|n| matches!(n.kind, NodeKind::ComponentSet { .. })).count()
+}
+
+#[test]
+fn combine_is_offered_for_two_or_more_main_components_only() {
+ let mut s = Session::new();
+ let a = component(&mut s, "A", 0.0);
+ let b = component(&mut s, "B", 200.0);
+ let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 50, "height": 50})));
+ let inst = idv(&ex(&mut s, "instance.create", json!({"component": a, "x": 0, "y": 400})));
+ let doc = s.doc().unwrap();
+ assert!(can_combine_variants(doc, &[a, b]));
+ assert!(!can_combine_variants(doc, &[]), "nothing selected");
+ assert!(!can_combine_variants(doc, &[a]), "one component (the command still takes it)");
+ assert!(!can_combine_variants(doc, &[a, a]), "the same component twice");
+ assert!(!can_combine_variants(doc, &[a, frame]), "a frame isn't a component");
+ assert!(!can_combine_variants(doc, &[a, inst]), "an instance isn't a main component");
+
+ let c = component(&mut s, "C", 400.0);
+ let d = component(&mut s, "D", 600.0);
+ let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [c, d]})));
+ let doc = s.doc().unwrap();
+ assert!(!can_combine_variants(doc, &[c, d]), "variants of one set");
+ assert!(!can_combine_variants(doc, &[c, a]), "a variant and a lone component");
+ assert!(!can_combine_variants(doc, &[set, a]), "a set isn't a component");
+
+ // Components in different frames still combine.
+ let holder = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 600, "width": 300, "height": 100})));
+ let e = component(&mut s, "E", 0.0);
+ ex(&mut s, "node.reparent", json!({"ids": [e], "parent": holder}));
+ assert!(can_combine_variants(s.doc().unwrap(), &[a, e]));
+}
+
+#[test]
+fn add_variant_is_offered_for_sets_variants_and_lone_components() {
+ let mut s = Session::new();
+ let a = component(&mut s, "A", 0.0);
+ let b = component(&mut s, "B", 200.0);
+ let lone = component(&mut s, "Lone", 400.0);
+ let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [a, b]})));
+ let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 50, "height": 50})));
+ let inst = idv(&ex(&mut s, "instance.create", json!({"component": lone, "x": 0, "y": 400})));
+ let doc = s.doc().unwrap();
+ assert!(can_add_variant(doc, set));
+ assert!(can_add_variant(doc, a));
+ assert!(can_add_variant(doc, lone));
+ assert!(!can_add_variant(doc, frame));
+ assert!(!can_add_variant(doc, inst));
+ assert!(!can_add_variant(doc, kids(&s, inst)[0]), "a layer inside an instance");
+ assert!(!can_add_variant(doc, NodeId(999_999)));
+}
+
+#[test]
+fn combining_variants_again_is_refused_instead_of_nesting_sets() {
+ let mut s = Session::new();
+ let a = component(&mut s, "State=Default", 0.0);
+ let b = component(&mut s, "State=Hover", 200.0);
+ let lone = component(&mut s, "State=Pressed", 400.0);
+ let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [a, b]})));
+ let before = s.doc().unwrap().clone();
+ let steps = undo_len(&s);
+ for ids in [vec![a, b], vec![a, lone]] {
+ let err = s.execute("component.combineVariants", &json!({"ids": ids})).unwrap_err();
+ assert!(err.to_string().contains("variants already"), "{err}");
+ }
+ assert_eq!(*s.doc().unwrap(), before, "a refused combine changes nothing");
+ assert_eq!(undo_len(&s), steps);
+ assert_eq!(sets(&s), 1);
+ assert_eq!(kids(&s, set), vec![a, b]);
+ // One lone component still makes a set of one (other commands rely on it).
+ ex(&mut s, "component.combineVariants", json!({"ids": [lone]}));
+ assert_eq!(sets(&s), 2);
+ healthy(&s);
+}
+
+#[test]
+fn combining_refuses_nested_components_and_components_inside_instances() {
+ let mut s = Session::new();
+ // A "Card" component holding an "Icon" component.
+ let card = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
+ let icon = idv(&ex(&mut s, "frame.create", json!({"x": 10, "y": 10, "width": 20, "height": 20, "name": "Icon", "parent": card})));
+ let icon = idv(&ex(&mut s, "component.create", json!({"ids": [icon]})));
+ let card = idv(&ex(&mut s, "component.create", json!({"ids": [card]})));
+ let other = component(&mut s, "Other", 300.0);
+ let inst = idv(&ex(&mut s, "instance.create", json!({"component": card, "x": 0, "y": 300})));
+ let inner = kids(&s, inst).into_iter().find(|c| matches!(node(&s, *c).kind, NodeKind::Component { .. })).expect("the instance's copy of Icon");
+
+ let doc = s.doc().unwrap();
+ assert!(!can_combine_variants(doc, &[card, icon]), "a component and one inside it");
+ assert!(can_combine_variants(doc, &[icon, other]), "a nested component with another one is fine");
+ assert!(!can_combine_variants(doc, &[inner, other]), "a component inside an instance");
+ let before = s.doc().unwrap().clone();
+ let err = s.execute("component.combineVariants", &json!({"ids": [card, icon]})).unwrap_err();
+ assert!(err.to_string().contains("inside it"), "{err}");
+ assert_eq!(*s.doc().unwrap(), before);
+}
+
+#[test]
+fn add_variant_on_a_lone_component_makes_it_a_set_in_one_step() {
+ let mut s = Session::new();
+ let c = component(&mut s, "Button", 0.0);
+ let inst = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 0, "y": 300})));
+ let before = s.doc().unwrap().clone();
+ let steps = undo_len(&s);
+
+ let v = idv(&ex(&mut s, "component.addVariant", json!({"id": c})));
+ let set = node(&s, c).parent.unwrap();
+ let set_node = node(&s, set);
+ assert!(matches!(set_node.kind, NodeKind::ComponentSet { .. }));
+ assert_eq!(set_node.name, "Button", "the set takes the component's name");
+ assert_eq!(kids(&s, set), vec![c, v]);
+ assert_eq!(node(&s, c).name, "Property 1=Default");
+ assert_eq!(node(&s, v).name, "Property 1=Variant2");
+ assert_eq!(s.selection(), vec![v]);
+ assert!(set_node.height >= 40.0 * 2.0 + 20.0, "the set wraps both variants: {}", set_node.height);
+ assert!(matches!(node(&s, inst).kind, NodeKind::Instance { component, .. } if component == c), "instances keep their main");
+ assert_eq!(undo_len(&s), steps + 1, "wrapping and adding are one undo step");
+ healthy(&s);
+
+ s.undo().unwrap();
+ assert_eq!(*s.doc().unwrap(), before, "undo puts the lone component back");
+}
+
+#[test]
+fn add_variant_on_a_lone_component_keeps_prop_value_names_and_rolls_back_on_errors() {
+ let mut s = Session::new();
+ let c = component(&mut s, "Size=Small", 0.0);
+ let before = s.doc().unwrap().clone();
+ let steps = undo_len(&s);
+ assert!(s.execute("component.addVariant", &json!({"id": c, "property": "Nope", "value": "x"})).is_err());
+ assert_eq!(*s.doc().unwrap(), before, "a refused Add variant leaves no set behind");
+ assert_eq!(undo_len(&s), steps);
+
+ let v = idv(&ex(&mut s, "component.addVariant", json!({"id": c, "property": "Size", "value": "Large"})));
+ assert_eq!(node(&s, c).name, "Size=Small");
+ assert_eq!(node(&s, v).name, "Size=Large");
+ healthy(&s);
+}
+
+#[test]
+fn add_variant_on_a_lone_component_in_auto_layout_keeps_its_place() {
+ let mut s = Session::new();
+ let row = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100, "name": "Row"})));
+ ex(&mut s, "layout.add", json!({"ids": [row]}));
+ let first = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40, "parent": row})));
+ let c = component(&mut s, "Chip", 0.0);
+ ex(&mut s, "node.reparent", json!({"ids": [c], "parent": row}));
+ let last = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40, "parent": row})));
+
+ ex(&mut s, "component.addVariant", json!({"id": c}));
+ let set = node(&s, c).parent.unwrap();
+ assert_eq!(kids(&s, row), vec![first, set, last], "the set takes the component's place in the row");
+ assert_eq!(kids(&s, set).len(), 2);
+ healthy(&s);
+}
diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs
index da351e5b..e0a4dde0 100644
--- a/crates/geom/src/lib.rs
+++ b/crates/geom/src/lib.rs
@@ -284,7 +284,9 @@ impl ReverseSubpaths for BezPath {
kurbo::PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3),
}
}
- out.close_path();
+ if matches!(seg_path.elements().last(), Some(kurbo::PathEl::ClosePath)) {
+ out.close_path();
+ }
}
out
}
diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs
index b99d7d8c..15e24d49 100644
--- a/crates/geom/src/tests.rs
+++ b/crates/geom/src/tests.rs
@@ -108,3 +108,18 @@ fn set_rotation_keeps_center() {
}
}
}
+
+#[test]
+fn reverse_subpaths_preserves_open_and_closed() {
+ let mut open = BezPath::new();
+ open.move_to((0.0, 0.0));
+ open.line_to((10.0, 0.0));
+ open.line_to((10.0, 10.0));
+ let r = ::reverse_subpaths(&open);
+ assert!(!r.elements().iter().any(|e| matches!(e, kurbo::PathEl::ClosePath)), "{:?}", r.elements());
+
+ let mut closed = open.clone();
+ closed.close_path();
+ let r = ::reverse_subpaths(&closed);
+ assert!(matches!(r.elements().last(), Some(kurbo::PathEl::ClosePath)), "{:?}", r.elements());
+}
diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs
index 1433b942..55176aaf 100644
--- a/crates/svg/src/lib.rs
+++ b/crates/svg/src/lib.rs
@@ -586,7 +586,8 @@ impl W<'_> {
}
let end = glyphs.get(j).map_or(line.char_end, |g| g.char_start).max(start).min(chars.len());
let text: String = chars.get(start.min(end)..end).unwrap_or(&[]).iter().collect();
- let text = st.case.apply(text.trim_end_matches(['\n', '\r']));
+ let prev = start.checked_sub(1).and_then(|k| chars.get(k)).copied();
+ let text = st.case.apply_after(text.trim_end_matches(['\n', '\r']), prev);
let own = match layout.styles.get(style).and_then(|s| s.fills.as_ref()) {
Some(fills) => {
let a = fills.iter().find_map(|p| self.paint_attr(p, n.width, n.height)).unwrap_or_else(|| "fill=\"none\"".into());
@@ -601,7 +602,8 @@ impl W<'_> {
continue;
}
let text: String = chars.get(line.char_start..line.char_end.min(chars.len())).unwrap_or(&[]).iter().collect();
- let text = st.case.apply(text.trim_end_matches(['\n', '\r']));
+ let prev = line.char_start.checked_sub(1).and_then(|k| chars.get(k)).copied();
+ let text = st.case.apply_after(text.trim_end_matches(['\n', '\r']), prev);
let (x, y) = layout.glyphs.get(line.glyph_start).map_or((0.0, line.baseline), |g| (g.x, g.y));
let _ = write!(spans, "{}", num(x), num(y), esc(&text));
}
diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs
index 5a333d07..8dece77f 100644
--- a/crates/svg/src/tests.rs
+++ b/crates/svg/src/tests.rs
@@ -116,6 +116,25 @@ fn text_run_fill_survives_live_text_export() {
assert!(svg.contains("fill=\"#00FF00\"") && svg.contains(">b") && svg.contains(">a"), "{svg}");
}
+#[test]
+fn title_case_does_not_restart_words_across_fill_runs_in_live_text() {
+ use figcraft_doc::{StyleRun, TextCase, TextData};
+ figcraft_text::scan_system_fonts();
+ let mut d = Document::new("t");
+ let page = d.first_page().unwrap();
+ let mut text = TextData::new("hello");
+ text.style.font_size = 24.0;
+ text.style.case = TextCase::Title;
+ let mut style = text.style.clone();
+ style.fills = Some(vec![Paint::solid(Color::from_hex("00FF00").unwrap())]);
+ text.runs.push(StyleRun { start: 2, end: 3, style });
+ text.normalize();
+ let id = d.create(page, NodeKind::Text(text), "Text", 0.0, 0.0, 200.0, 40.0).unwrap();
+ d.get_mut(id).unwrap().fills = vec![Paint::solid(Color::BLACK)];
+ let svg = export_svg(&d, &[id], &SvgOptions { outline_text: false, ..SvgOptions::default() }).unwrap();
+ assert!(svg.contains(">He") && svg.contains(">l") && svg.contains(">lo"), "{svg}");
+}
+
#[test]
fn point_joins_equal_to_the_layer_join_keep_a_plain_stroke() {
use figcraft_doc::{StrokeJoin, VectorNetwork};
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 49163986..46451ae0 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -183,7 +183,9 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
}
}
let own_cursor = input(app, ui, &resp);
- crate::drop_overlay(&ctx, ctx.content_rect(), tr("Drop to import"));
+ // A browser names no dragged file until the drop, so documents there still say "import".
+ let documents = ctx.input(|i| i.raw.hovered_files.iter().all(|f| f.path.as_deref().is_some_and(crate::is_document)));
+ crate::drop_overlay(&ctx, ctx.content_rect(), if documents { tr("Drop to open") } else { tr("Drop to import") });
crate::guides::end_released(&ctx);
crate::live::track_pointer(app, &ctx);
app.sync_preedit();
@@ -504,10 +506,15 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
if !app.canvas.dragging && crate::selection_spacing::input(app, &ctx, hover_pos, resp.hovered(), app.canvas.space_pan) {
return false;
}
- // Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]).
+ // Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]);
+ // a dropped document opens as a file of its own, as on the home screen.
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
for f in dropped {
let path = f.path().to_path_buf();
+ if crate::is_document(&path) {
+ crate::pending_open::open_dropped(app, &f);
+ continue;
+ }
let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default();
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into());
let (params, read) = if path.exists() {
diff --git a/crates/ui-egui/src/comments_tests.rs b/crates/ui-egui/src/comments_tests.rs
index 696ff322..eea2278e 100644
--- a/crates/ui-egui/src/comments_tests.rs
+++ b/crates/ui-egui/src/comments_tests.rs
@@ -7,7 +7,7 @@ use egui::{Event, Key, Modifiers, Pos2, pos2, vec2};
use figcraft_engine::Session;
use serde_json::{Value, json};
-use crate::comments::{cluster, insert_mention, mention_matches, mention_query};
+use crate::comments::{cluster, insert_mention, mention_matches, mention_query, start_draft};
use crate::present_tests::{Ui, id};
fn add(s: &mut Session, x: f64, y: f64, text: &str, author: &str) -> u64 {
@@ -41,6 +41,35 @@ fn comments(u: &Ui) -> Value {
u.app.inspect()["comments"].clone()
}
+#[test]
+fn switching_from_comment_to_move_closes_the_unposted_composer() {
+ let mut u = Ui::new(Session::new());
+ u.app.run("tool.set", json!({"tool": "comment"}));
+ start_draft(&mut u.app, 100.0, 80.0);
+ assert!(comments(&u)["draft"].is_array());
+
+ u.app.run("tool.set", json!({"tool": "move"}));
+ u.frame(vec![]);
+ assert!(comments(&u)["draft"].is_null());
+}
+
+#[test]
+fn control_channel_tool_switch_closes_the_unposted_composer() {
+ let mut u = Ui::new(Session::new());
+ u.app.run("tool.set", json!({"tool": "comment"}));
+ start_draft(&mut u.app, 100.0, 80.0);
+ assert!(comments(&u)["draft"].is_array());
+
+ let invalid = json!({"command": "tool.set", "params": {"tool": "not-a-tool"}});
+ assert!(crate::control::handle(&mut u.app, &egui::Context::default(), "engine.execute", &invalid).is_err());
+ assert!(comments(&u)["draft"].is_array());
+
+ crate::control::handle(&mut u.app, &egui::Context::default(), "engine.execute", &json!({"command": "tool.set", "params": {"tool": "move"}}))
+ .unwrap();
+ u.frame(vec![]);
+ assert!(comments(&u)["draft"].is_null());
+}
+
#[test]
fn mention_helpers() {
// The `@query` before the cursor.
diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs
index 6c9ff7d4..2b2573c7 100644
--- a/crates/ui-egui/src/component_panel.rs
+++ b/crates/ui-egui/src/component_panel.rs
@@ -2,8 +2,9 @@
//!
//! - An instance: its main component (with a swap menu), variant dropdowns, property values,
//! the selected layer's overrides, and Reset / Detach / Go to main / Push changes.
-//! - A component or component set: its properties (add, rename, default, delete) and, for a set,
-//! the variant property matrix.
+//! - A component or component set: its properties (add, rename, default, delete), Add variant
+//! (a lone main component becomes a set) and, for a set, the variant property matrix.
+//! - Several main components: Combine as variants.
//! - A layer inside a component: bind its visibility / text / instance to a property, or make a
//! frame a slot.
//! - A slot inside an instance (and an instance's slot properties): fill it with components
@@ -11,6 +12,7 @@
use egui::{Id, Sense, Ui, vec2};
use figcraft_doc::*;
+use figcraft_engine::cmd::component::{can_add_variant, can_combine_variants};
use figcraft_engine::components::{
PROP_BINDING, default_variant, inside_instance, owning_component, owning_instance, slot_def, slot_edited, slots_of, variant_conflicts,
variant_options, variant_props, variant_set,
@@ -27,7 +29,10 @@ use crate::widgets;
/// Entry point (called from the Design panel header for every selection).
pub fn instance_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
- let [id] = sel else { return };
+ let [id] = sel else {
+ combine_ui(app, ui, doc, sel);
+ return;
+ };
let Some(n) = doc.node(*id) else { return };
match &n.kind {
NodeKind::Instance { .. } => instance_ui(app, ui, doc, n),
@@ -57,6 +62,19 @@ pub fn instance_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[No
}
}
+/// Two or more main components selected: a button combines them into a component set.
+fn combine_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
+ if !can_combine_variants(doc, sel) {
+ return;
+ }
+ row(ui, 32.0, |ui| {
+ let label = crate::i18n::command("component.combineVariants", "Combine as variants");
+ if widgets::dropdown(ui, &label, 160.0, Some(Icon::ComponentSet)).clicked() {
+ app.run("component.combineVariants", json!({"ids": sel}));
+ }
+ });
+}
+
/// A component's description: a multi-line field, saved when it loses focus.
fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str) {
widgets::separator(ui);
@@ -365,7 +383,8 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
});
}
}
- if is_set {
+ // A lone main component too: Add variant makes it a set of itself and the new variant.
+ if can_add_variant(doc, owner) {
row(ui, 32.0, |ui| {
if widgets::dropdown(ui, tr("Add variant"), 120.0, Some(Icon::Plus)).clicked() {
app.run("component.addVariant", json!({"id": owner}));
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
index a4a8dd15..2d35609a 100644
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -239,6 +239,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
let cmd = p.get("command").and_then(Value::as_str).ok_or("`command` required")?;
let params = p.get("params").cloned().unwrap_or(json!({}));
let r = app.session.execute(cmd, ¶ms).map_err(|e| e.to_string());
+ app.clear_comment_draft_after_tool_set(cmd, r.is_ok());
// Programmatic calls never open dialogs.
app.session.ui_requests.retain(|u| !u.kind.starts_with("dialog."));
app.handle_ui_requests();
diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs
index c174242a..b532d9a7 100644
--- a/crates/ui-egui/src/home.rs
+++ b/crates/ui-egui/src/home.rs
@@ -180,19 +180,11 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
fn dropped(app: &mut FigApp, ctx: &egui::Context) {
let files = ctx.input(|i| i.raw.dropped_files.clone());
for f in files {
- let path = f.path().to_path_buf();
- if !crate::opens(&path) {
+ if !crate::opens(f.path()) {
app.toast(tr("Drop a .figcraft, .fig, .json, .svg or image file."));
continue;
}
- // `opens` saw an extension, so there is a file name to tell the format by.
- let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
- let (params, read) = if path.exists() {
- (json!({"path": path.to_string_lossy()}), None)
- } else {
- (json!({"name": name}), Some(crate::dropped_bytes(&f, &name)))
- };
- app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None, read });
+ crate::pending_open::open_dropped(app, &f);
}
}
diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs
index c74c35bf..ebbf6d95 100644
--- a/crates/ui-egui/src/home_tests.rs
+++ b/crates/ui-egui/src/home_tests.rs
@@ -668,9 +668,8 @@ fn dropping_a_native_file_on_the_home_screen_opens_it() {
#[test]
fn dropping_a_native_file_on_canvas_keeps_its_receiving_document_after_tab_switch() {
let dir = Temp::new();
- let path = dir.path("Canvas import.figcraft");
- let mut source = Session::new();
- source.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();
+ let path = dir.path("Canvas import.svg");
+ std::fs::write(&path, r#""#).unwrap();
let mut session = Session::new();
let target_uid = session.ds().unwrap().uid;
@@ -696,7 +695,55 @@ fn dropping_a_native_file_on_canvas_keeps_its_receiving_document_after_tab_switc
u.frame(vec![]);
assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the user's active tab stays in front");
- assert_eq!(u.app.session.docs.iter().map(|doc| doc.doc.pages.len()).collect::>(), [2, 1]);
+ let layers = |uid: u64| {
+ let doc = &u.app.session.docs.iter().find(|doc| doc.uid == uid).unwrap().doc;
+ doc.node(doc.pages[0]).unwrap().children.len()
+ };
+ assert_eq!((layers(target_uid), layers(other_uid)), (1, 0), "the drop lands in the tab that received it");
+}
+
+#[test]
+fn dropping_a_document_on_canvas_opens_it_as_a_new_file() {
+ let dir = Temp::new();
+ let path = dir.path("Dropped.figcraft");
+ let mut saved = Session::new();
+ saved.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();
+ let mut u = Ui::new(Session::new());
+ u.app.ui.home = false;
+ u.frame(vec![]);
+ let before = u.app.session.doc().unwrap().clone();
+ let s = drop_file(&mut u, &path);
+ assert!(has_text(&s, "Drop to open"), "dragging a document over the canvas says it opens");
+ let s = flat(&u.frame_output(vec![]).shapes);
+ assert!(has_text(&s, "Opening as a new file"), "the card says so too");
+ u.frame(vec![]);
+ assert!(u.app.ui.pending_opens.is_empty());
+ assert_eq!(u.app.session.docs.len(), 2, "a tab of its own");
+ assert_eq!(u.app.session.ds().unwrap().path.as_deref(), Some(path.to_string_lossy().as_ref()), "in front");
+ assert_eq!(u.app.session.docs[0].doc.as_ref(), &before, "the file that was open gets no pages added");
+}
+
+#[test]
+fn a_document_dropped_on_canvas_without_a_file_on_disk_opens_as_a_new_file() {
+ // Every drop in the browser (#916).
+ let dir = Temp::new();
+ let saved = dir.path("Saved.figcraft");
+ Session::new().execute("file.saveAs", &json!({"path": saved.to_string_lossy()})).unwrap();
+ let bytes = std::fs::read(&saved).unwrap();
+ let mut u = Ui::new(Session::new());
+ u.app.ui.home = false;
+ u.frame(vec![]);
+ let pos = u.app.canvas.rect.center();
+ let file: egui::DroppedFileHandle = std::sync::Arc::new(DroppedBytes("Shared.figcraft".into(), bytes));
+ u.frame_raw(egui::RawInput { events: vec![Event::PointerMoved(pos)], dropped_files: vec![file], ..Default::default() });
+ for _ in 0..3 {
+ u.frame(vec![]);
+ }
+ assert!(u.app.ui.pending_opens.is_empty());
+ assert_eq!(u.app.ui.last_error, None);
+ assert_eq!(u.app.session.docs.len(), 2, "a tab of its own");
+ assert_eq!(u.app.session.docs[0].doc.pages.len(), 1, "no pages added to the open file");
+ assert_eq!(u.app.session.docs[1].path, None, "with no save path made up for it");
}
#[test]
diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs
index 6ae19546..192073b3 100644
--- a/crates/ui-egui/src/idle_tests.rs
+++ b/crates/ui-egui/src/idle_tests.rs
@@ -26,6 +26,10 @@ pub(crate) struct Harness {
impl Harness {
pub(crate) fn new(s: Session) -> Self {
+ // The one system font scan bumps the layout generation, which makes the canvas render
+ // again: scanned here, before the first frame, rather than by whichever test happens to
+ // run first while this one waits for idle (#333).
+ figcraft_text::scan_system_fonts();
Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0 }
}
diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs
index dfab115a..f172b309 100644
--- a/crates/ui-egui/src/layers.rs
+++ b/crates/ui-egui/src/layers.rs
@@ -229,6 +229,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
let mut move_to: Option<(NodeId, String)> = None;
// Pages are dragged up and down the list to reorder them.
let mut reorder = crate::list_drag::ListDrag::in_order(ui, "pages");
+ let page_key = (uid, cur);
+ let scroll_to_cur = app.ui.pages_seen_page != Some(page_key);
+ let mut current_row_rendered = false;
// A long list scrolls in its own area, so the layers keep their room below it.
let max_h = pages_list_height(app.ui.pages_height, ui.available_height());
let mut moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| {
@@ -238,6 +241,12 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
}
let sense = if filtering { Sense::click() } else { Sense::click_and_drag() };
let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), sense);
+ if id == cur {
+ current_row_rendered = true;
+ if scroll_to_cur {
+ ui.scroll_to_rect(r, None);
+ }
+ }
if !filtering {
reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() });
}
@@ -301,6 +310,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
}
reorder.finish(ui)
});
+ if current_row_rendered {
+ app.ui.pages_seen_page = Some(page_key);
+ }
// A drop outside the list's visible area (and the row gap at its edges) isn't on a page row,
// even one scrolled out of view.
if !ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| moved.inner_rect.expand2(vec2(0.0, 3.0)).contains(p)) {
diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs
index b963ee82..4d987e66 100644
--- a/crates/ui-egui/src/layers_behaviour_tests.rs
+++ b/crates/ui-egui/src/layers_behaviour_tests.rs
@@ -507,3 +507,59 @@ fn the_pages_list_keeps_three_rows_and_leaves_the_layers_room() {
// A panel too short for both keeps the three rows.
assert_eq!(pages_list_height(Some(500.0), 100.0), 3.0 * ROW);
}
+
+/// Selecting a page that is far down the list automatically scrolls it into view.
+#[test]
+fn selecting_a_distant_page_scrolls_it_into_view() {
+ let mut s = Session::new();
+ let rect = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
+ for _ in 0..40 {
+ s.execute("page.add", &json!({})).unwrap();
+ }
+ let pages = s.doc().unwrap().pages.clone();
+ let first = pages[0];
+ let last = *pages.last().unwrap();
+ s.execute("page.select", &json!({"id": first})).unwrap();
+ s.execute("select.none", &json!({})).unwrap();
+ let mut u = Ui::new(s);
+ let layer = u.row_at(rect);
+ u.frame(vec![]);
+ // Select the last page.
+ u.app.run("page.select", json!({"id": last}));
+ for _ in 0..15 {
+ u.frame(vec![]);
+ }
+ assert_eq!(u.app.ui.pages_seen_page, Some((u.app.session.ds().unwrap().uid, last)));
+ let opened = pages_down_to(&mut u, layer.y - 60.0);
+ let top = pages.iter().position(|p| Some(p) == opened.first()).unwrap();
+ assert!(top > 20, "the list scrolled past its first pages to reveal the selection (top row: page {top})");
+}
+
+/// If the active page is filtered out by search, it is not marked as seen
+/// until the filter is cleared and its row actually renders.
+#[test]
+fn filtered_out_active_page_is_seen_only_after_rendering() {
+ let mut s = Session::new();
+ let p1 = s.doc().unwrap().pages[0];
+ let p2 = id(s.execute("page.add", &json!({})).unwrap());
+ s.execute("page.rename", &json!({"id": p1, "name": "Alpha"})).unwrap();
+ s.execute("page.rename", &json!({"id": p2, "name": "Beta"})).unwrap();
+ s.execute("page.select", &json!({"id": p1})).unwrap();
+ let uid = s.ds().unwrap().uid;
+ let mut u = Ui::new(s);
+ assert_eq!(u.app.ui.pages_seen_page, Some((uid, p1)));
+
+ // Filter to only "Alpha", then select "Beta" (p2).
+ u.app.ui.page_search = true;
+ u.app.ui.page_filter = "Alpha".into();
+ u.app.run("page.select", json!({"id": p2}));
+ u.frame(vec![]);
+
+ // Beta was filtered out and not rendered, so pages_seen_page has not recorded it.
+ assert_ne!(u.app.ui.pages_seen_page, Some((uid, p2)));
+
+ // Clear filter: Beta renders now and is recorded.
+ u.app.ui.page_filter.clear();
+ u.frame(vec![]);
+ assert_eq!(u.app.ui.pages_seen_page, Some((uid, p2)));
+}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 072aec62..c93333b3 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -215,6 +215,8 @@ mod variables_export_ui_tests;
#[cfg(test)]
mod variables_rename_tests;
#[cfg(test)]
+mod variants_discovery_tests;
+#[cfg(test)]
mod vector_panel_tests;
#[cfg(test)]
mod vector_tail_ui_tests;
@@ -495,6 +497,8 @@ pub struct UiState {
pub page_filter: String,
/// The height asked for the pages list (`None`: a share of the panel).
pub pages_height: Option,
+ /// The document and active page last drawn in the pages list.
+ pub pages_seen_page: Option<(u64, NodeId)>,
pub expanded: BTreeSet,
/// The layer being renamed in the layers panel.
pub rename: Option,
@@ -667,6 +671,7 @@ impl Default for UiState {
page_search: false,
page_filter: String::new(),
pages_height: None,
+ pages_seen_page: None,
expanded: BTreeSet::new(),
rename: None,
rename_page: None,
@@ -831,9 +836,17 @@ impl FigApp {
}
}
+ /// Dismiss the composer after a successful switch away from the Comment tool.
+ pub(crate) fn clear_comment_draft_after_tool_set(&mut self, id: &str, succeeded: bool) {
+ if id == "tool.set" && succeeded && self.session.tool.tool != figcraft_engine::tools::ToolId::Comment {
+ self.ui.comments.draft = None;
+ }
+ }
+
/// Run a command, surfacing errors as a toast. Returns the result.
pub fn run(&mut self, id: &str, params: Value) -> Option {
let r = self.session.execute(id, ¶ms);
+ self.clear_comment_draft_after_tool_set(id, r.is_ok());
self.handle_ui_requests();
r.map_err(|e| self.failed(id, &e)).ok()
}
@@ -1817,6 +1830,12 @@ pub(crate) fn opens(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e)))
}
+/// Whether `path` is a whole document (`.figcraft`, `.fig`, file JSON) rather than an image or SVG:
+/// dropped on the canvas it opens as a file of its own instead of adding its pages to the open one.
+pub(crate) fn is_document(path: &Path) -> bool {
+ path.extension().and_then(|e| e.to_str()).is_some_and(|e| ["figcraft", "fig", "json"].iter().any(|x| x.eq_ignore_ascii_case(e)))
+}
+
/// Reads the contents of dropped file `name` that has no file on disk to read by path.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle, name: &str) -> ReadFuture {
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index e81a7c49..9659c6b3 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -853,6 +853,14 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
None
}
});
+ // Two or more main components combine into a component set. Judged on the selection
+ // the command acts on: a layer row right-clicked in a multi-selection passes only itself.
+ let combine = app
+ .session
+ .doc()
+ .ok()
+ .filter(|doc| figcraft_engine::cmd::component::can_combine_variants(doc, &app.session.selection()))
+ .map(|_| i18n::command("component.combineVariants", "Combine as variants"));
let mut entries: Vec<(&str, &str)> = if has_sel {
vec![
(tr("Copy"), "edit.copy"),
@@ -910,6 +918,11 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
{
entries.insert(at + 1, entry);
}
+ if let Some(label) = &combine
+ && let Some(at) = entries.iter().position(|e| e.1 == "component.create")
+ {
+ entries.insert(at + 1, (label, "component.combineVariants"));
+ }
// A right-clicked ruler guide (selected, with no layers) can be removed.
if !has_sel && app.session.ds().is_ok_and(|d| d.selected_guide().is_some()) {
entries.splice(0..0, [(tr("Remove guide"), "guide.remove"), ("-", "")]);
diff --git a/crates/ui-egui/src/pending_open.rs b/crates/ui-egui/src/pending_open.rs
index 501367c0..f5e978c6 100644
--- a/crates/ui-egui/src/pending_open.rs
+++ b/crates/ui-egui/src/pending_open.rs
@@ -32,6 +32,16 @@ pub struct ImportTarget {
pub name: String,
}
+/// Queues dropped file `f` to open as a document of its own: by path, or by its contents when there
+/// is no file on disk to read. Callers checked its extension, so there is a name to tell the format by.
+pub(crate) fn open_dropped(app: &mut FigApp, f: &egui::DroppedFileHandle) {
+ let path = f.path();
+ let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
+ let (params, read) =
+ if path.exists() { (json!({"path": path.to_string_lossy()}), None) } else { (json!({"name": name}), Some(crate::dropped_bytes(f, &name))) };
+ app.ui.pending_opens.push(PendingOpen { label: name, params, command: "file.open", target: None, read });
+}
+
fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option {
let target = match pending.target {
Some(target) => target,
diff --git a/crates/ui-egui/src/variants_discovery_tests.rs b/crates/ui-egui/src/variants_discovery_tests.rs
new file mode 100644
index 00000000..e153d982
--- /dev/null
+++ b/crates/ui-egui/src/variants_discovery_tests.rs
@@ -0,0 +1,111 @@
+//! Combine as variants and Add variant where people look for them (#903), driven through egui
+//! headless: the Design panel offers Combine for two or more main components and Add variant for
+//! a lone one, and the canvas / layers context menu lists Combine only when it applies.
+
+use egui::Pos2;
+use figcraft_doc::{NodeId, NodeKind};
+use figcraft_engine::Session;
+use serde_json::json;
+
+use crate::layers_drag_tests::Ui;
+use crate::live_tests::{flat, has_text, text_center};
+use crate::present_tests::id;
+
+const COMBINE: &str = "Combine as variants";
+
+/// A session with lone main components named A, B and C side by side, and a frame. Returns
+/// (session, [a, b, c], frame).
+fn doc() -> (Session, [NodeId; 3], NodeId) {
+ let mut s = Session::new();
+ let mut comps = Vec::new();
+ for (k, name) in ["A", "B", "C"].iter().enumerate() {
+ let f = id(&s.execute("frame.create", &json!({"x": k * 160, "y": 0, "width": 120, "height": 40, "name": name})).unwrap());
+ comps.push(id(&s.execute("component.create", &json!({"ids": [f]})).unwrap()));
+ }
+ let frame = id(&s.execute("frame.create", &json!({"x": 0, "y": 200, "width": 60, "height": 60})).unwrap());
+ (s, [comps[0], comps[1], comps[2]], frame)
+}
+
+fn select(u: &mut Ui, ids: &[NodeId]) {
+ u.app.session.execute("select.set", &json!({"ids": ids})).unwrap();
+ u.frame(vec![]);
+}
+
+/// The text painted this frame.
+fn shows(u: &mut Ui, s: &str) -> Option {
+ let shapes = flat(&u.frame_output(vec![]).shapes);
+ has_text(&shapes, s).then(|| text_center(&shapes, s))
+}
+
+fn click(u: &mut Ui, p: Pos2) {
+ u.button(p, true);
+ u.button(p, false);
+}
+
+fn set_of(u: &Ui, c: NodeId) -> Option {
+ let doc = u.app.session.doc().unwrap();
+ doc.node(c).and_then(|n| n.parent).filter(|p| doc.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::ComponentSet { .. })))
+}
+
+fn undo_len(u: &Ui) -> usize {
+ u.app.session.ds().unwrap().history.undo.len()
+}
+
+#[test]
+fn the_design_panel_combines_two_selected_components() {
+ let (s, [a, b, _], _) = doc();
+ let mut u = Ui::new(s);
+ select(&mut u, &[a, b]);
+ let at = shows(&mut u, COMBINE).expect("the Design panel offers Combine as variants");
+ let undo = undo_len(&u);
+ click(&mut u, at);
+ let set = set_of(&u, a).expect("combined into a set");
+ assert_eq!(set_of(&u, b), Some(set));
+ assert_eq!(u.app.session.selection(), vec![set]);
+ assert_eq!(undo_len(&u), undo + 1);
+ assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
+ assert!(shows(&mut u, COMBINE).is_none(), "a set selected: nothing to combine");
+}
+
+#[test]
+fn the_design_panel_offers_combine_only_when_it_applies() {
+ // The cases are the engine's (`can_combine_variants`); this pins the panel to it.
+ let (s, [a, ..], frame) = doc();
+ let mut u = Ui::new(s);
+ select(&mut u, &[a, frame]);
+ assert!(shows(&mut u, COMBINE).is_none(), "a component and a frame");
+}
+
+#[test]
+fn the_design_panel_adds_a_variant_to_a_lone_component() {
+ let (s, [a, ..], _) = doc();
+ let mut u = Ui::new(s);
+ select(&mut u, &[a]);
+ let at = shows(&mut u, "Add variant").expect("a lone main component offers Add variant");
+ click(&mut u, at);
+ let set = set_of(&u, a).expect("the component became a set");
+ assert_eq!(u.app.session.doc().unwrap().node(set).unwrap().children.len(), 2);
+ assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
+}
+
+#[test]
+fn the_context_menu_lists_combine_for_two_selected_components() {
+ let (s, [a, b, _], _) = doc();
+ let mut u = Ui::new(s);
+ // One component: no Combine, Create component as before.
+ select(&mut u, &[a]);
+ u.app.ui.context_menu = Some((Pos2::new(700.0, 300.0), vec![a]));
+ assert!(shows(&mut u, COMBINE).is_none());
+ assert!(shows(&mut u, "Create component").is_some());
+ u.app.ui.context_menu = None;
+ // Two components, right-clicked on one layer row (the menu gets only that row): judged on
+ // the selection, which is what the command combines.
+ select(&mut u, &[a, b]);
+ u.app.ui.context_menu = Some((Pos2::new(700.0, 300.0), vec![a]));
+ u.frame(vec![]);
+ let at = shows(&mut u, COMBINE).expect("the context menu offers Combine as variants");
+ click(&mut u, at);
+ let set = set_of(&u, a).expect("combined into a set");
+ assert_eq!(set_of(&u, b), Some(set));
+ assert!(u.app.ui.context_menu.is_none(), "the menu closes");
+}
diff --git a/docs/components-parity.md b/docs/components-parity.md
index dcd93305..fad4c6fe 100644
--- a/docs/components-parity.md
+++ b/docs/components-parity.md
@@ -16,7 +16,7 @@ accounts).
| Per-layer overrides with reset; kept across main edits, nested swaps, copy/paste | done | main edits match a full rebuild exactly at 2,000 instances |
| What an instance overrides: fills, strokes, effects, visibility, layer names, text content and every text property (resizing included), auto layout padding, gap and alignment, layout grids, export settings, prototype interactions, nested swaps and property values; the size and resizing (hug, fill, fixed, min and max) of layers inside it | done | each kept when the main changes that property, reset per group; opacity and corner radius too (documentation silent); size and resizing are kept through relayout, undo, save, `.fig` and REST import and detach, and stay with the instance on push |
| What it doesn't: position, rotation, constraints and absolute or grid placement of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out; the instance itself resizes and its layers follow the main's constraints |
-| Component sets / variants, add variant, switch | done | |
+| Component sets / variants, add variant, switch | done | Combine as variants is in the Object menu, and the canvas and layers context menu and the Design panel offer it when two or more main components are selected; Add variant on a main component not in a set yet makes it a set of itself and the new variant, one undo step (#903); components that are variants already aren't combined again (it used to put a set inside a set) |
| A main's copy within its file is an instance of it: Duplicate, Alt-drag, Copy then Paste | done | a variant duplicated in its set stays a new variant and a whole set copies as a new set; only the copied roots become instances (a main inside a copied frame is copied as a main); Cut then Paste and pastes into other files keep the component (#892) |
| Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | |
| Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | |
diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh
index 20da2f76..d38dc0bc 100755
--- a/packaging/linux/package.sh
+++ b/packaging/linux/package.sh
@@ -121,7 +121,7 @@ if has appimage; then
if [ -z "$TOOL" ]; then
TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage"
if [ ! -x "$TOOL" ]; then
- curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
+ curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
chmod +x "$TOOL"
fi
fi
diff --git a/packaging/server/install.sh b/packaging/server/install.sh
index 11c489a3..826c2bb4 100755
--- a/packaging/server/install.sh
+++ b/packaging/server/install.sh
@@ -147,7 +147,7 @@ FILES="$HERE" # where the units and the Caddyfile come from
from_release() { # download and check the release archive; sets NEW_BIN and FILES
local version="$VERSION" name dir
if [ -z "$version" ]; then
- version="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' | head -n 1)" || return 1
+ version="$(curl --proto '=https' -fsSL "https://api.github.com/repos/$REPO/releases/latest" | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' | head -n 1)" || return 1
[ -n "$version" ] || return 1
fi
name="figcraft-server-$version-linux-$ARCH"
@@ -155,8 +155,8 @@ from_release() { # download and check the release archive; sets NEW_BIN and FILE
rm -rf "$dir"
mkdir -p "$dir"
say "Downloading $name"
- curl -fsSL -o "$dir/$name.tar.gz" "https://github.com/$REPO/releases/download/v$version/$name.tar.gz" || return 1
- curl -fsSL -o "$dir/SHA256SUMS.txt" "https://github.com/$REPO/releases/download/v$version/SHA256SUMS.txt" || return 1
+ curl --proto '=https' -fsSL -o "$dir/$name.tar.gz" "https://github.com/$REPO/releases/download/v$version/$name.tar.gz" || return 1
+ curl --proto '=https' -fsSL -o "$dir/SHA256SUMS.txt" "https://github.com/$REPO/releases/download/v$version/SHA256SUMS.txt" || return 1
(cd "$dir" && grep " $name.tar.gz\$" SHA256SUMS.txt | sha256sum -c --quiet -) || die "the download doesn't match SHA256SUMS.txt"
tar -C "$dir" -xzf "$dir/$name.tar.gz"
NEW_BIN="$dir/$name/figcraft-server"
diff --git a/sonar-project.properties b/sonar-project.properties
new file mode 100644
index 00000000..92562d54
--- /dev/null
+++ b/sonar-project.properties
@@ -0,0 +1,2 @@
+sonar.projectKey=storytold_figcraft
+sonar.organization=storytold