mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 12:26:52 -06:00
Merge remote-tracking branch 'upstream/main' into pr664-fix
This commit is contained in:
@@ -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]"
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -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"] }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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" {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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/"));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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, ¶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 {
|
||||
|
||||
@@ -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"), ("-", "")]);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 | |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
sonar.projectKey=storytold_figcraft
|
||||
sonar.organization=storytold
|
||||
Reference in New Issue
Block a user