Merge remote-tracking branch 'upstream/main' into pr664-fix

This commit is contained in:
phonedu
2026-10-22 19:53:01 +08:00
46 changed files with 1112 additions and 456 deletions
+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+4 -4
View File
@@ -42,12 +42,12 @@ jobs:
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
libgl1-mesa-dev libgtk-3-dev
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: clippy, rustfmt
# Only main saves caches; PRs restore main's, so PR caches don't evict main's.
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
@@ -75,10 +75,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: wasm32
save-if: ${{ github.ref == 'refs/heads/main' }}
+20
View File
@@ -0,0 +1,20 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
Generated
+193 -258
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
version = "0.7.0"
version = "0.9.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -34,6 +34,9 @@ figcraft-plugin = { path = "crates/plugin" }
# Which paths stored in a document may be followed (craft-libs).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the
# same native titlebar per desktop (craft-libs). The `egui` feature adds `decorate`/`decorations`.
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
+3
View File
@@ -20,6 +20,9 @@
<img alt="macOS · Windows · Linux · FreeBSD · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-native-0EA5C6?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_figcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/figcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
+1
View File
@@ -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 |
+6 -3
View File
@@ -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"
-112
View File
@@ -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<PathBuf> {
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<PathBuf>) -> 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>) -> bool {
wayland && server_decorations == Some(false)
}
fn probe(socket: PathBuf) -> Option<bool> {
// 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<bool> {
// 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::<Registry>(&connection).ok()?;
Some(globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1")))
}
struct Registry;
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for Registry {
fn event(
_state: &mut Self,
_registry: &wl_registry::WlRegistry,
_event: wl_registry::Event,
_data: &GlobalListContents,
_connection: &Connection,
_queue: &QueueHandle<Self>,
) {
}
}
#[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(())
}
}
+79 -17
View File
@@ -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)]
+4
View File
@@ -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
+1 -1
View File
@@ -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)
}
+1
View File
@@ -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",
"",
+14 -2
View File
@@ -162,7 +162,13 @@ impl Color {
p.parse::<f32>().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::<f32>().ok()?;
let s = parts.get(1)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
let l = parts.get(2)?.trim_end_matches('%').parse::<f32>().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" {
+10
View File
@@ -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);
}
+12
View File
@@ -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::<f64>().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");
+84 -23
View File
@@ -56,11 +56,17 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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<String> {
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<NodeId> {
let cmd = "component.combineVariants";
fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
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<NodeId> {
Ok(set)
}
fn combine(s: &mut Session, p: &Value) -> Result<Value> {
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::<BTreeSet<_>>().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<NodeId> {
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<Value> {
let cmd = "component.combineVariants";
let mut seen = BTreeSet::new();
let ids: Vec<NodeId> = 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<Value> {
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<Value> {
.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)?;
+9 -2
View File
@@ -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<Value> {
}
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;
@@ -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);
}
+5 -1
View File
@@ -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;
+2 -2
View File
@@ -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);
+6 -1
View File
@@ -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)
}
+8
View File
@@ -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/"));
}
+40
View File
@@ -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");
@@ -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);
}
+3 -1
View File
@@ -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
}
+15
View File
@@ -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 = <BezPath as ReverseSubpaths>::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 = <BezPath as ReverseSubpaths>::reverse_subpaths(&closed);
assert!(matches!(r.elements().last(), Some(kurbo::PathEl::ClosePath)), "{:?}", r.elements());
}
+4 -2
View File
@@ -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, "<tspan x=\"{}\" y=\"{}\">{}</tspan>", num(x), num(y), esc(&text));
}
+19
View File
@@ -116,6 +116,25 @@ fn text_run_fill_survives_live_text_export() {
assert!(svg.contains("fill=\"#00FF00\"") && svg.contains(">b</tspan>") && svg.contains(">a</tspan>"), "{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</tspan>") && svg.contains(">l</tspan>") && svg.contains(">lo</tspan>"), "{svg}");
}
#[test]
fn point_joins_equal_to_the_layer_join_keep_a_plain_stroke() {
use figcraft_doc::{StrokeJoin, VectorNetwork};
+9 -2
View File
@@ -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() {
+30 -1
View File
@@ -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.
+23 -4
View File
@@ -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}));
+1
View File
@@ -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, &params).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();
+2 -10
View File
@@ -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);
}
}
+51 -4
View File
@@ -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#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><rect width="20" height="10"/></svg>"#).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::<Vec<_>>(), [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]
+4
View File
@@ -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 }
}
+12
View File
@@ -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)) {
@@ -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)));
}
+19
View File
@@ -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<f32>,
/// The document and active page last drawn in the pages list.
pub pages_seen_page: Option<(u64, NodeId)>,
pub expanded: BTreeSet<NodeId>,
/// The layer being renamed in the layers panel.
pub rename: Option<layers::Rename>,
@@ -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<Value> {
let r = self.session.execute(id, &params);
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 {
+13
View File
@@ -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"), ("-", "")]);
+10
View File
@@ -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<Value> {
let target = match pending.target {
Some(target) => target,
@@ -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<Pos2> {
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<NodeId> {
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");
}
+1 -1
View File
@@ -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 | |
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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"
+2
View File
@@ -0,0 +1,2 @@
sonar.projectKey=storytold_figcraft
sonar.organization=storytold