mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-22 19:08:51 +00:00
Compare commits
46
Commits
fix/issue-892
...
release
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28de91d9ae | ||
|
|
35da7b7cff | ||
|
|
74c4a84da3 | ||
|
|
123e46550f | ||
|
|
44e64d8d47 | ||
|
|
bb532a5790 | ||
|
|
f6f44dcc53 | ||
|
|
96a82edecf | ||
|
|
f143260c3a | ||
|
|
bb93e5e433 | ||
|
|
da96d54157 | ||
|
|
a1758c163f | ||
|
|
b1d00f0802 | ||
|
|
efb95b29bf | ||
|
|
9257f97d69 | ||
|
|
2e12bdfd91 | ||
|
|
f53f37dfe0 | ||
|
|
59f3b3b733 | ||
|
|
77f4271061 | ||
|
|
8f3afbf6a1 | ||
|
|
1c79c1646f | ||
|
|
bf3fc3cc6e | ||
|
|
8b198ea364 | ||
|
|
d9c924c3ba | ||
|
|
92290dd208 | ||
|
|
7c68ebf183 | ||
|
|
fc37c808aa | ||
|
|
23463f719b | ||
|
|
49586b1c47 | ||
|
|
b5a315a748 | ||
|
|
bcd5b6e632 | ||
|
|
bbe1a12eeb | ||
|
|
594dc8c6cf | ||
|
|
dd6bd3fe1c | ||
|
|
9b711e212b | ||
|
|
b5038726c4 | ||
|
|
9c6920cf7d | ||
|
|
2bcd004071 | ||
|
|
eb7ead9455 | ||
|
|
ab59877559 | ||
|
|
77ffc57432 | ||
|
|
4e7256bf6a | ||
|
|
e123a2efe7 | ||
|
|
bae2f057a1 | ||
|
|
c3fe69c629 | ||
|
|
629c297de9 |
No files matched your search
@@ -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]"
|
||||
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};
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# FigCraft interface strings: Georgian.
|
||||
#
|
||||
# Draft — needs native review: machine-drafted from the English catalog. Brand names and keyboard
|
||||
# glyphs stay as they are. Georgian has no letter case, and a noun after a numeral stays singular, so
|
||||
# counts read "შრე: {n}" and plural entries give the same text for one and other. See docs/translating.md.
|
||||
# Written with AI assistance (Claude) from the meaning of each English string, following the glossary
|
||||
# the Craft apps share, and reviewed by a native Georgian speaker; kept as a draft until a maintainer
|
||||
# changes its status. Brand names and keyboard glyphs stay as they are. Georgian has no letter case,
|
||||
# and a noun after a numeral stays singular, so counts read "შრე: {n}" and plural entries give the
|
||||
# same text for one and other. See docs/translating.md.
|
||||
|
||||
[meta]
|
||||
name = "ქართული"
|
||||
status = "draft — needs native review"
|
||||
status = "draft — reviewed by a native speaker, awaiting a maintainer's approval"
|
||||
|
||||
[ui]
|
||||
"% of all commits" = "ყველა კომიტის %"
|
||||
|
||||
@@ -1640,6 +1640,120 @@ status = "draft — needs native review"
|
||||
"⌘ click" = "⌘ clique"
|
||||
"⌘ scroll" = "⌘ rolar"
|
||||
"⌥ drag" = "⌥ arrastar"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Uma página de produto responsiva: destaque, recursos, dados, depoimento e rodapé"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "Um título, uma pauta, um número em destaque, um gráfico e um slide de encerramento"
|
||||
"A to Z" = "De A a Z"
|
||||
"Adding to {name}" = "Adicionando a {name}"
|
||||
"All the fonts this file uses are installed" = "Todas as fontes usadas neste arquivo estão instaladas"
|
||||
"Alphabetical" = "Alfabética"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Uma tela de análise em grade: dados, gráficos vetoriais e uma tabela"
|
||||
"Apps" = "Aplicativos"
|
||||
"Apr" = "abr."
|
||||
"Aug" = "ago."
|
||||
"Bottom padding" = "Espaçamento interno inferior"
|
||||
"Bound to {name}. Click to detach the variable." = "Vinculada a {name}. Clique para desvincular a variável."
|
||||
"Checking the fonts installed on this computer…" = "Verificando as fontes instaladas neste computador…"
|
||||
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Estilos de cor e texto, variáveis Claro e Escuro, botões, campos e cartões"
|
||||
"Convert to frame" = "Converter em quadro"
|
||||
"Convert to section" = "Converter em seção"
|
||||
"Created" = "Criado"
|
||||
"Dashboard" = "Painel"
|
||||
"Date created" = "Data de criação"
|
||||
"Dec" = "dez."
|
||||
"Design system starter" = "Ponto de partida para sistema de design"
|
||||
"Draw a frame" = "Desenhe um quadro"
|
||||
"Drop a .figcraft, .fig, .json, .svg or image file." = "Solte um arquivo .figcraft, .fig, .json, .svg ou uma imagem."
|
||||
"Drop to import" = "Solte para importar"
|
||||
"Drop to open" = "Solte para abrir"
|
||||
"Edited {when}" = "Editado {when}"
|
||||
"Export frames and layers as PNG, JPG, SVG or PDF" = "Exporte quadros e camadas como PNG, JPG, SVG ou PDF"
|
||||
"Feb" = "fev."
|
||||
"File not found" = "Arquivo não encontrado"
|
||||
"Four screens of a trail app with components and a prototype flow" = "Quatro telas de um aplicativo de trilhas com componentes e um fluxo de protótipo"
|
||||
"Getting started" = "Primeiros passos"
|
||||
"Greyscale components and two example screens to sketch a flow with" = "Componentes em escala de cinza e duas telas de exemplo para esboçar um fluxo"
|
||||
"Hide these tips (Getting started in the Help menu shows them again)" = "Ocultar estas dicas (Primeiros passos no menu Ajuda as exibe novamente)"
|
||||
"Hide when publishing" = "Ocultar ao publicar"
|
||||
"Icon components on a 24 px grid with keylines and a size preview" = "Componentes de ícone em uma grade de 24 px, com linhas-guia e prévia de tamanho"
|
||||
"Icon set starter" = "Ponto de partida para conjunto de ícones"
|
||||
"Individual padding" = "Espaçamento interno individual"
|
||||
"Interface size" = "Tamanho da interface"
|
||||
"Jan" = "jan."
|
||||
"Jul" = "jul."
|
||||
"Jun" = "jun."
|
||||
"Kits" = "Kits"
|
||||
"Landing page" = "Página de apresentação"
|
||||
"Last modified" = "Modificado por último"
|
||||
"Left padding" = "Espaçamento interno à esquerda"
|
||||
"Link frames on the Prototype tab, then present the flow" = "Vincule os quadros na aba Protótipo e apresente o fluxo"
|
||||
"Make a component" = "Criar um componente"
|
||||
"Mar" = "mar."
|
||||
"May" = "mai."
|
||||
"Missing fonts" = "Fontes ausentes"
|
||||
"Mobile app" = "Aplicativo para celular"
|
||||
"Newest first" = "Mais recentes primeiro"
|
||||
"No files or templates match your search" = "Nenhum arquivo ou modelo corresponde à sua busca"
|
||||
"No other pages" = "Não há outras páginas"
|
||||
"No templates match your search" = "Nenhum modelo corresponde à sua busca"
|
||||
"Nov" = "nov."
|
||||
"Oct" = "out."
|
||||
"Oldest first" = "Mais antigos primeiro"
|
||||
"One announcement in five common sizes: square, portrait, story, link and banner" = "Um anúncio em cinco tamanhos comuns: quadrado, retrato, story, link e banner"
|
||||
"Opening the file…" = "Abrindo o arquivo…"
|
||||
"Opening {n} files as new files" = { one = "Abrindo como um novo arquivo", other = "Abrindo {n} arquivos como novos arquivos" }
|
||||
"Order" = "Ordem"
|
||||
"Other" = "Outro"
|
||||
"Pick the Frame tool, then drag on the canvas or choose a device size" = "Selecione a ferramenta Quadro e arraste na tela ou escolha o tamanho de um dispositivo"
|
||||
"Plugin" = "Plugin"
|
||||
"Plugins & widgets" = "Plugins e widgets"
|
||||
"Presentation slides" = "Slides de apresentação"
|
||||
"Playground" = "Área de testes"
|
||||
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Em relação à escala de exibição do sistema (100% corresponde ao tamanho definido pelo sistema). O zoom do documento é independente."
|
||||
"Remove from recents" = "Remover dos recentes"
|
||||
"Reorder selection" = "Reordenar seleção"
|
||||
"Right padding" = "Espaçamento interno à direita"
|
||||
"Search templates" = "Buscar modelos"
|
||||
"See all templates" = "Ver todos os modelos"
|
||||
"Select layers and they space themselves as their content changes" = "Selecione camadas e elas ajustarão o espaçamento conforme o conteúdo mudar"
|
||||
"Search files and templates" = "Buscar arquivos e modelos"
|
||||
"Search results" = "Resultados da busca"
|
||||
"Search pages" = "Buscar páginas"
|
||||
"Sep" = "set."
|
||||
"Share with a code" = "Compartilhar com um código"
|
||||
"Show all templates" = "Mostrar todos os modelos"
|
||||
"Show in Explorer" = "Mostrar no Explorador de Arquivos"
|
||||
"Show in Finder" = "Mostrar no Finder"
|
||||
"Show in folder" = "Mostrar na pasta"
|
||||
"Social and slides" = "Redes sociais e slides"
|
||||
"Social post sizes" = "Tamanhos de publicação para redes sociais"
|
||||
"Sort by" = "Ordenar por"
|
||||
"Sort by {column}" = "Ordenar por {column}"
|
||||
"Start from a template" = "Começar com um modelo"
|
||||
"Start sharing with the Share button, then pass on the code it gives" = "Comece a compartilhar pelo botão Compartilhar e envie o código gerado"
|
||||
"Templates" = "Modelos"
|
||||
"The destination document is unavailable. Nothing was imported." = "O documento de destino está indisponível. Nada foi importado."
|
||||
"The destination document was closed. Nothing was imported." = "O documento de destino foi fechado. Nada foi importado."
|
||||
"The destination page was removed. Nothing was imported." = "A página de destino foi removida. Nada foi importado."
|
||||
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "A janela é pequena demais para {size}%, então a interface é exibida em {drawn}%. Aumente a janela para usar {size}%."
|
||||
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este arquivo usa fontes que não estão instaladas neste computador. O texto será exibido com uma fonte substituta até que você as instale."
|
||||
"Top padding" = "Espaçamento interno superior"
|
||||
"Try another search, or browse templates." = "Tente outra busca ou explore os modelos."
|
||||
"Try another word, or look at every kind." = "Tente outra palavra ou veja todos os tipos."
|
||||
"Turn a layer into a component, reuse it, and change every copy at once" = "Transforme uma camada em componente, reutilize-a e altere todas as cópias de uma só vez"
|
||||
"Use your browser's zoom to change the interface size." = "Use o zoom do navegador para alterar o tamanho da interface."
|
||||
"Uses variables: each layer shows them in its own mode" = "Usa variáveis: cada camada as exibe em seu próprio modo"
|
||||
"Websites" = "Sites"
|
||||
"What you try here changes this preview and the code below, not the file" = "O que você experimentar aqui altera esta prévia e o código abaixo, não o arquivo"
|
||||
"Widget" = "Widget"
|
||||
"Wireframe kit" = "Kit de wireframe"
|
||||
"Z to A" = "De Z a A"
|
||||
"just now" = "agora mesmo"
|
||||
"only in a text style or variable" = "somente em um estilo de texto ou variável"
|
||||
"slot layers without a matching slot property were imported as frames" = "camadas de slot sem uma propriedade de slot correspondente foram importadas como quadros"
|
||||
"yesterday" = "ontem"
|
||||
"{month} {day}, {year}" = "{day} de {month} de {year}"
|
||||
"{n} text layers" = { one = "{n} camada de texto", other = "{n} camadas de texto" }
|
||||
"{percent}%" = "{percent}%"
|
||||
|
||||
[commands]
|
||||
"align.bottom" = "Alinhar embaixo"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# FigCraft interface strings: Russian.
|
||||
# Wording reviewed and approved by VanTan.
|
||||
# Strings added since that review (2026-10-20) are a machine draft awaiting review.
|
||||
# Keep Frame, Dev Mode and Smart animate as familiar tool names.
|
||||
# See docs/translating.md; plural forms follow Russian integer rules.
|
||||
|
||||
@@ -16,6 +17,7 @@ name = "Русский"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, коммитов: {commits}, +{added} / −{deleted} строк (Δ {delta}), +{bin_added} / −{bin_deleted} бинарных ресурсов, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Коллекция этого файла с этими режимами и переменными: переопределяйте значения для каждого режима и добавляйте собственные переменные"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Файл .figcraft с таким именем уже существует. Чтобы подтвердить замену, выберите его полное имя."
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Отдельный слой выравнивается по своему фрейму: выделите больше слоёв или слой внутри фрейма."
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Адаптивная страница продукта: главный блок, возможности, показатели, цитата и подвал"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Пружинная анимация длится до затухания: её время задают масса, жёсткость и демпфирование"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "Заголовок, повестка, крупный показатель, диаграмма и заключительный слайд"
|
||||
@@ -65,6 +67,7 @@ name = "Русский"
|
||||
"Add variant with a new value" = "Добавить вариант с новым значением"
|
||||
"Added" = "Добавлено"
|
||||
"Added the library \"{name}\"" = "Добавлена библиотека «{name}»"
|
||||
"Adding to {name}" = "Добавление в {name}"
|
||||
"Adjust" = "Коррекция"
|
||||
"Adjustment presets" = "Пресеты коррекции"
|
||||
"Advanced" = "Дополнительно"
|
||||
@@ -88,6 +91,7 @@ name = "Русский"
|
||||
"All components" = "Все компоненты"
|
||||
"All fonts" = "Все шрифты"
|
||||
"All supported properties" = "Все поддерживаемые свойства"
|
||||
"All the fonts this file uses are installed" = "Все шрифты, которые использует этот файл, установлены"
|
||||
"All variables" = "Все переменные"
|
||||
"Alpha" = "Альфа"
|
||||
"Alphabetical" = "По алфавиту"
|
||||
@@ -168,6 +172,7 @@ name = "Русский"
|
||||
"Both directions" = "Оба направления"
|
||||
"Bottom" = "Снизу"
|
||||
"Bottom left radius" = "Радиус нижнего левого угла"
|
||||
"Bottom padding" = "Нижний внутренний отступ"
|
||||
"Bottom right radius" = "Радиус нижнего правого угла"
|
||||
"Branch" = "Ветка"
|
||||
"Branch archived" = "Ветка архивирована"
|
||||
@@ -194,6 +199,7 @@ name = "Русский"
|
||||
"C or ⇧C" = "C или ⇧C"
|
||||
"CMYK colour" = "Цвет CMYK"
|
||||
"Can view" = "Может просматривать"
|
||||
"Checking the fonts installed on this computer…" = "Проверка шрифтов, установленных на этом компьютере…"
|
||||
"Checking the server…" = "Проверка сервера…"
|
||||
"Clear from this list" = "Убрать из этого списка"
|
||||
"Code or invite link" = "Код или ссылка приглашения"
|
||||
@@ -293,6 +299,8 @@ name = "Русский"
|
||||
"Contributors" = "Участники"
|
||||
"Contributors: {people} · Commits: {commits}" = "Участники: {people} · Коммиты: {commits}"
|
||||
"Controller" = "Контроллер"
|
||||
"Convert to frame" = "Преобразовать во Frame"
|
||||
"Convert to section" = "Преобразовать в секцию"
|
||||
"Cool" = "Холодный"
|
||||
"Copied as CSS" = "Скопировано как CSS"
|
||||
"Copied as PNG" = "Скопировано как PNG"
|
||||
@@ -310,8 +318,10 @@ name = "Русский"
|
||||
"Copy {value}" = "Копировать {value}"
|
||||
"Corner radius" = "Радиус углов"
|
||||
"Corner smoothing" = "Сглаживание углов"
|
||||
"Couldn't download the variables" = "Не удалось загрузить переменные"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Не удалось связаться с {server}: показаны данные, сохранённые на этом компьютере."
|
||||
"Couldn't read {name}." = "Не удалось прочитать {name}."
|
||||
"Couldn't save automatically: {error}" = "Не удалось сохранить автоматически: {error}"
|
||||
"Count" = "Количество"
|
||||
"Create" = "Создать"
|
||||
"Create a variable collection first" = "Сначала создайте коллекцию переменных"
|
||||
@@ -407,7 +417,10 @@ name = "Русский"
|
||||
"Draw" = "Рисование"
|
||||
"Draw a frame" = "Нарисовать Frame"
|
||||
"Drawing tools" = "Инструменты рисования"
|
||||
"Drop a .figcraft, .fig, .json, .svg or image file." = "Перетащите файл .figcraft, .fig, .json, .svg или изображение."
|
||||
"Drop shadow" = "Внешняя тень"
|
||||
"Drop to import" = "Отпустите, чтобы импортировать"
|
||||
"Drop to open" = "Отпустите, чтобы открыть"
|
||||
"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Двойная лицензия MIT OR Apache-2.0. Создано командой и сообществом ArtCraft."
|
||||
"Duo" = "Дуо"
|
||||
"Duplicate" = "Дублировать"
|
||||
@@ -477,6 +490,7 @@ name = "Русский"
|
||||
"False" = "Ложь"
|
||||
"Feb" = "февр."
|
||||
"Fetching from the server…" = "Загрузка с сервера…"
|
||||
"FigCraft couldn't open its window: {error}" = "FigCraft не смог открыть своё окно: {error}"
|
||||
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft пока поддерживает не все возможности этих файлов. В замечаниях перечислено, что было пропущено или изменено в импортированной копии; исходный файл не меняется."
|
||||
"File" = "Файл"
|
||||
"File name" = "Имя файла"
|
||||
@@ -598,6 +612,7 @@ name = "Русский"
|
||||
"Icon components on a 24 px grid with keylines and a size preview" = "Компоненты иконок на сетке 24 px с направляющими и предпросмотром размеров"
|
||||
"Icon set starter" = "Основа набора иконок"
|
||||
"If" = "Если"
|
||||
"If it keeps happening, please report it at {link} with this message." = "Если это повторяется, сообщите об этом по адресу {link}, приложив это сообщение."
|
||||
"If {condition}" = "Если {condition}"
|
||||
"Ignore auto layout (absolute position)" = "Игнорировать автолейаут (абсолютная позиция)"
|
||||
"Image" = "Изображение"
|
||||
@@ -619,6 +634,7 @@ name = "Русский"
|
||||
"Included" = "Включено"
|
||||
"Included in layout" = "Участвует в компоновке"
|
||||
"Individual corners" = "Углы по отдельности"
|
||||
"Individual padding" = "Внутренние отступы по отдельности"
|
||||
"Individual strokes" = "Обводки по отдельности"
|
||||
"Ink" = "Чернила"
|
||||
"Inner shadow" = "Внутренняя тень"
|
||||
@@ -640,6 +656,7 @@ name = "Русский"
|
||||
"Interaction" = "Взаимодействие"
|
||||
"Interactions" = "Взаимодействия"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Дизайн интерфейсов и прототипирование. Бесплатный инструмент с открытым исходным кодом, независимо созданный с нуля на Rust, из семейства ArtCraft Crafting Apps."
|
||||
"Interface size" = "Размер интерфейса"
|
||||
"Intersect" = "Пересечение"
|
||||
"Intersects" = "Пересечения"
|
||||
"Invalid color" = "Недопустимый цвет"
|
||||
@@ -689,6 +706,7 @@ name = "Русский"
|
||||
"Leaves" = "Листья"
|
||||
"Left" = "Слева"
|
||||
"Left & right" = "Слева и справа"
|
||||
"Left padding" = "Левый внутренний отступ"
|
||||
"Left side" = "Левая сторона"
|
||||
"Left side width" = "Ширина левой стороны"
|
||||
"Letter case" = "Регистр букв"
|
||||
@@ -761,6 +779,7 @@ name = "Русский"
|
||||
"Mirror angle and length" = "Отражать угол и длину"
|
||||
"Mirroring" = "Зеркальность"
|
||||
"Missing" = "Отсутствует"
|
||||
"Missing fonts" = "Отсутствующие шрифты"
|
||||
"Missing layer" = "Слой отсутствует"
|
||||
"Missing style" = "Стиль отсутствует"
|
||||
"Missing variable" = "Переменная отсутствует"
|
||||
@@ -911,10 +930,13 @@ name = "Русский"
|
||||
"Open variables ({count})" = "Открыть переменные ({count})"
|
||||
"Opened as a new document" = "Открыт как новый документ"
|
||||
"Opened “{name}” in a new tab" = "«{name}» открыт в новой вкладке"
|
||||
"Opening the file…" = "Открытие файла…"
|
||||
"Opening {n} files as new files" = { one = "Открытие {n} файла как нового", few = "Открытие {n} файлов как новых", many = "Открытие {n} файлов как новых", other = "Открытие {n} файлов как новых" }
|
||||
"Open|comments" = "Открытые"
|
||||
"Options" = "Параметры"
|
||||
"Orange" = "Оранжевый"
|
||||
"Order" = "Порядок"
|
||||
"Other" = "Другое"
|
||||
"Other apps may not open every .fig file exported here." = "Другие приложения могут открыть не каждый экспортированный здесь файл .fig."
|
||||
"Others lose access and keep their copy; the file stays on the server for you" = "Другие участники потеряют доступ, сохранив свои копии; файл останется на сервере для вас"
|
||||
"Others type this under Share ▸ Join with a code" = "Другие участники вводят это в «Поделиться» ▸ «Подключиться по коду»"
|
||||
@@ -974,6 +996,8 @@ name = "Русский"
|
||||
"Place a shape or frame (its tool chosen)" = "Разместить фигуру или Frame (выбранным инструментом)"
|
||||
"Place automatically" = "Разместить автоматически"
|
||||
"Place image" = "Разместить изображение"
|
||||
"Plugin" = "Плагин"
|
||||
"Plugins & widgets" = "Плагины и виджеты"
|
||||
"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Плагины работают в песочнице FigCraft без доступа к файлам, сети и часам. Устанавливайте только плагины, которым доверяете."
|
||||
"Plugins" = "Плагины"
|
||||
"Plus darker" = "Линейное затемнение"
|
||||
@@ -1043,6 +1067,7 @@ name = "Русский"
|
||||
"Reduce motion" = "Уменьшить движение"
|
||||
"Refraction" = "Преломление"
|
||||
"Regular expression" = "Регулярное выражение"
|
||||
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Относительно масштаба экрана в системе (100% — собственный размер системы). Масштаб документа задаётся отдельно."
|
||||
"Remove" = "Удалить"
|
||||
"Remove all" = "Удалить всё"
|
||||
"Remove annotation" = "Удалить аннотацию"
|
||||
@@ -1066,6 +1091,7 @@ name = "Русский"
|
||||
"Remove the else and its actions" = "Удалить «Иначе» и его действия"
|
||||
"Remove this condition and its actions" = "Удалить это условие и его действия"
|
||||
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Удалить файл с сервера? Его больше нельзя будет открывать и публиковать оттуда. Ваша копия останется открытой здесь."
|
||||
"Remove {name} from session" = "Убрать {name} из сессии"
|
||||
"Remove {name} from {server}?" = "Удалить {name} с {server}?"
|
||||
"Removed" = "Удалено"
|
||||
"Removed from the server" = "Удалено с сервера"
|
||||
@@ -1080,6 +1106,7 @@ name = "Русский"
|
||||
"Rename on the server…" = "Переименовать на сервере…"
|
||||
"Rename page" = "Переименовать страницу"
|
||||
"Rename to" = "Новое имя"
|
||||
"Reorder selection" = "Изменить порядок выделения"
|
||||
"Replace all" = "Заменить всё"
|
||||
"Replace mixed export settings" = "Заменить разные настройки экспорта"
|
||||
"Replace with" = "Заменить на"
|
||||
@@ -1115,6 +1142,7 @@ name = "Русский"
|
||||
"Review and merge" = "Проверить и объединить"
|
||||
"Ribbon" = "Лента"
|
||||
"Right" = "Справа"
|
||||
"Right padding" = "Правый внутренний отступ"
|
||||
"Right side" = "Правая сторона"
|
||||
"Right side width" = "Ширина правой стороны"
|
||||
"Rotate 90°" = "Повернуть на 90°"
|
||||
@@ -1134,6 +1162,8 @@ name = "Русский"
|
||||
"Rulers" = "Линейки"
|
||||
"Run" = "Запустить"
|
||||
"Run {label}" = "Запустить {label}"
|
||||
"Save files automatically" = "Автоматически сохранять файлы"
|
||||
"Search pages" = "Поиск страниц"
|
||||
"Search templates" = "Поиск шаблонов"
|
||||
"See all templates" = "Все шаблоны"
|
||||
"Select layers and they space themselves as their content changes" = "Выделите слои: интервалы между ними будут подстраиваться при изменении содержимого"
|
||||
@@ -1278,6 +1308,7 @@ name = "Русский"
|
||||
"Start from a blank canvas" = "Начать с пустого холста"
|
||||
"Start from a template" = "Начать с шаблона"
|
||||
"Start on path" = "Начало на пути"
|
||||
"Start on the Home screen" = "Начинать с главной"
|
||||
"Start point" = "Начальная точка"
|
||||
"Start sharing" = "Включить общий доступ"
|
||||
"Start sharing with the Share button, then pass on the code it gives" = "Включите общий доступ кнопкой «Поделиться», затем передайте полученный код"
|
||||
@@ -1339,6 +1370,9 @@ name = "Русский"
|
||||
"Texts" = "Тексты"
|
||||
"Texture" = "Текстура"
|
||||
"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "На холсте цвета отображаются в sRGB; экспортированные PNG, JPG и WebP помечены профилем Display P3."
|
||||
"The destination document is unavailable. Nothing was imported." = "Документ назначения недоступен. Ничего не импортировано."
|
||||
"The destination document was closed. Nothing was imported." = "Документ назначения закрыт. Ничего не импортировано."
|
||||
"The destination page was removed. Nothing was imported." = "Страница назначения удалена. Ничего не импортировано."
|
||||
"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Файл удаляется с сервера: его больше нельзя будет открыть или подключиться к нему оттуда. Отменить это нельзя. Уже открытые копии остаются у участников."
|
||||
"The file now holds another library" = "Теперь файл содержит другую библиотеку"
|
||||
"The file now holds another library (\"{name}\")" = "Теперь файл содержит другую библиотеку («{name}»)"
|
||||
@@ -1348,6 +1382,7 @@ name = "Русский"
|
||||
"The selection uses several text styles: click to apply one" = "В выделении несколько текстовых стилей: нажмите, чтобы применить один"
|
||||
"The server deletes this file if nobody opens it before {date}." = "Сервер удалит файл, если никто не откроет его до {date}."
|
||||
"The whole file" = "Весь файл"
|
||||
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "Окно слишком мало для {size}%, поэтому интерфейс отрисовывается в масштабе {drawn}%. Увеличьте окно, чтобы использовать {size}%."
|
||||
"Theme" = "Тема"
|
||||
"There's no destination for that action" = "Для этого действия не задано назначение"
|
||||
"Thickness" = "Толщина"
|
||||
@@ -1359,6 +1394,8 @@ name = "Русский"
|
||||
"This file" = "Этот файл"
|
||||
"This file has unsaved changes" = "В файле есть несохранённые изменения"
|
||||
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Файл находится на {server} и доступен только вам. Включите общий доступ, чтобы пригласить других по новому коду."
|
||||
"This file is too large to join here (up to {mb} MB)" = "Этот файл слишком большой, чтобы подключиться здесь (до {mb} МБ)"
|
||||
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "В этом файле используются шрифты, не установленные на этом компьютере. Их текст отрисовывается запасным шрифтом, пока вы их не установите."
|
||||
"This frame is new on the branch." = "Этот Frame добавлен в ветке."
|
||||
"This frame isn't on the branch." = "Этого Frame нет в ветке."
|
||||
"This server is open to the public: no key needed." = "Сервер общедоступен: ключ не нужен."
|
||||
@@ -1378,6 +1415,7 @@ name = "Русский"
|
||||
"Top" = "Сверху"
|
||||
"Top & bottom" = "Сверху и снизу"
|
||||
"Top left radius" = "Радиус верхнего левого угла"
|
||||
"Top padding" = "Верхний внутренний отступ"
|
||||
"Top right radius" = "Радиус верхнего правого угла"
|
||||
"Transform" = "Трансформация"
|
||||
"Triangle arrow" = "Треугольная стрелка"
|
||||
@@ -1415,6 +1453,8 @@ name = "Русский"
|
||||
"Update from selection" = "Обновить из выделения"
|
||||
"Updated {components} and {styles}" = "Обновлено: {components} и {styles}"
|
||||
"Updated {name}" = "Обновлено: {name}"
|
||||
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Часто помогает обновление драйвера видеокарты. Чтобы использовать только OpenGL, запустите FigCraft из командной строки с параметром:"
|
||||
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Часто помогает обновление драйвера видеокарты. Переменная WGPU_BACKEND (vulkan, gl, metal) задаёт другой графический API, который можно попробовать."
|
||||
"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Загружает файл на сервер FigCraft и синхронизирует эту вкладку с ним. Вы получите ссылки для редактирования и просмотра."
|
||||
"Uppercase" = "Прописные"
|
||||
"Use a fixed value" = "Использовать фиксированное значение"
|
||||
@@ -1424,6 +1464,7 @@ name = "Русский"
|
||||
"Use number keys for opacity" = "Задавать непрозрачность цифровыми клавишами"
|
||||
"Use the .figcraft or .fig extension." = "Используйте расширение .figcraft или .fig."
|
||||
"Use the layer's fill" = "Использовать заливку слоя"
|
||||
"Use your browser's zoom to change the interface size." = "Изменяйте размер интерфейса масштабом браузера."
|
||||
"Used in {files} · {instances}" = "Используется в {files} · {instances}"
|
||||
"Username" = "Имя пользователя"
|
||||
"Value" = "Значение"
|
||||
@@ -1458,6 +1499,7 @@ name = "Русский"
|
||||
"Visibility" = "Видимость"
|
||||
"Visible" = "Видимый"
|
||||
"Vivid" = "Яркий"
|
||||
"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "Переменная WGPU_BACKEND задана и указывает используемый графический API; если её убрать, FigCraft выберет сам."
|
||||
"Warm" = "Тёплый"
|
||||
"Watch" = "Часы"
|
||||
"Wavy" = "Волнистый"
|
||||
@@ -1477,6 +1519,7 @@ name = "Русский"
|
||||
"White" = "Белый"
|
||||
"Who runs it, its terms and how long it keeps files" = "Оператор, условия и срок хранения файлов"
|
||||
"Why it changed" = "Причина изменения"
|
||||
"Widget" = "Виджет"
|
||||
"Widgets" = "Виджеты"
|
||||
"Width" = "Ширина"
|
||||
"Width point" = "Точка ширины"
|
||||
@@ -1574,6 +1617,7 @@ name = "Русский"
|
||||
"nobody else here yet" = "здесь пока больше никого нет"
|
||||
"non-default slot settings are not represented and were skipped" = "нестандартные настройки слотов не передаются и пропущены"
|
||||
"now" = "сейчас"
|
||||
"only in a text style or variable" = "только в текстовом стиле или переменной"
|
||||
"or" = "или"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "соединения обводки в точках сохранены; пунктирные обводки и обводки переменной ширины рисуются с соединением слоя"
|
||||
"preferred slot component keys cannot be resolved to file nodes and were skipped" = "ключи предпочтительных компонентов слотов не удалось сопоставить со слоями файла, они пропущены"
|
||||
@@ -1596,6 +1640,7 @@ name = "Русский"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "действия смены режима переменных для коллекций или режимов, которых нет в JSON переменных, пропущены"
|
||||
"slot content assignment target is missing or is not slot content" = "цель назначения содержимого слота отсутствует или не является содержимым слота"
|
||||
"slot content assignment uses the empty GUID sentinel and was skipped" = "назначение содержимого слота использует пустой GUID-маркер и пропущено"
|
||||
"slot layers without a matching slot property were imported as frames" = "слои слотов без подходящего свойства слота импортированы как фреймы"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "некоторые векторные точки ссылаются на настройки точек, которых нет в файле; они используют соединения слоя и обычные углы"
|
||||
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "некоторые векторные сегменты ссылаются на настройки стиля, которых нет в файле; они используют обводку слоя"
|
||||
"stale or invalid grid track sizing entries were ignored" = "устаревшие или недопустимые записи размеров треков сетки проигнорированы"
|
||||
@@ -1682,6 +1727,7 @@ name = "Русский"
|
||||
"{n} replies" = { one = "{n} ответ", few = "{n} ответа", many = "{n} ответов", other = "{n} ответа" }
|
||||
"{n} rows" = { one = "{n} строка", few = "{n} строки", many = "{n} строк", other = "{n} строки" }
|
||||
"{n} styles" = { one = "{n} стиль", few = "{n} стиля", many = "{n} стилей", other = "{n} стиля" }
|
||||
"{n} text layers" = { one = "{n} текстовый слой", few = "{n} текстовых слоя", many = "{n} текстовых слоёв", other = "{n} текстовых слоя" }
|
||||
"{n} variables" = { one = "{n} переменная", few = "{n} переменные", many = "{n} переменных", other = "{n} переменной" }
|
||||
"{n} weeks ago" = { one = "{n} неделю назад", few = "{n} недели назад", many = "{n} недель назад", other = "{n} недели назад" }
|
||||
"{n} years ago" = { one = "{n} год назад", few = "{n} года назад", many = "{n} лет назад", other = "{n} года назад" }
|
||||
@@ -1690,6 +1736,7 @@ name = "Русский"
|
||||
"{n}m" = "{n} мин"
|
||||
"{n}w" = "{n} нед"
|
||||
"{page}, nothing selected" = "{page}, ничего не выделено"
|
||||
"{percent}%" = "{percent}%"
|
||||
"{profile} (current)" = "{profile} (текущий)"
|
||||
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} — значение свойства; {property ? \"a\" : \"b\"} выбирает строку. Другие {…} остаются как есть."
|
||||
"{server} can't say what became of your files (it is older than this app)." = "{server} не может сообщить, что стало с вашими файлами (версия сервера старее приложения)."
|
||||
@@ -1732,6 +1779,7 @@ name = "Русский"
|
||||
"arrange.front" = "На передний план"
|
||||
"arrange.packH" = "Уплотнить по горизонтали"
|
||||
"arrange.packV" = "Уплотнить по вертикали"
|
||||
"arrange.reorder" = "Изменить порядок выделения"
|
||||
"arrange.roundToPixels" = "Округлить до пикселей"
|
||||
"arrange.spacing" = "Интервал"
|
||||
"arrange.tidy" = "Упорядочить"
|
||||
@@ -1766,6 +1814,7 @@ name = "Русский"
|
||||
"collab.publish" = "Опубликовать на сервере"
|
||||
"collab.recoverFile" = "Восстановить онлайн-файл"
|
||||
"collab.registerLinks" = "Открывать ссылки приглашения в FigCraft"
|
||||
"collab.removePerson" = "Убрать из сессии"
|
||||
"collab.report" = "Пожаловаться на файл оператору сервера"
|
||||
"collab.serverInfo" = "Информация о сервере"
|
||||
"collab.share" = "Поделиться по коду подключения"
|
||||
@@ -1944,6 +1993,7 @@ name = "Русский"
|
||||
"object.batchRename" = "Массовое переименование"
|
||||
"object.convertToFrame" = "Преобразовать во Frame"
|
||||
"object.convertToGroup" = "Преобразовать в группу"
|
||||
"object.convertToSection" = "Преобразовать в секцию"
|
||||
"object.flipH" = "Отразить по горизонтали"
|
||||
"object.flipV" = "Отразить по вертикали"
|
||||
"object.frameSelection" = "Поместить выделение во Frame"
|
||||
@@ -1990,6 +2040,7 @@ name = "Русский"
|
||||
"plugin.ui" = "Панель плагина"
|
||||
"plugins.manage" = "Управление плагинами…"
|
||||
"plugins.runLast" = "Запустить последний плагин"
|
||||
"pref.autoSave" = "Автоматически сохранять файлы"
|
||||
"pref.highlightHover" = "Подсвечивать слои при наведении"
|
||||
"pref.keepTool" = "Оставлять инструмент выбранным после использования"
|
||||
"pref.language" = "Язык интерфейса"
|
||||
@@ -1999,6 +2050,8 @@ name = "Русский"
|
||||
"pref.reduceMotion" = "Уменьшить движение"
|
||||
"pref.renameDuplicates" = "Переименовывать дубликаты слоёв"
|
||||
"pref.showDimensions" = "Показать размеры объектов"
|
||||
"pref.startOnHome" = "Начинать с главной"
|
||||
"pref.uiScale" = "Размер интерфейса"
|
||||
"pref.userName" = "Ваше имя в комментариях"
|
||||
"proto.action.add" = "Добавить действие прототипа"
|
||||
"proto.action.move" = "Изменить порядок действия прототипа"
|
||||
@@ -2104,14 +2157,18 @@ name = "Русский"
|
||||
"text.listIndent" = "Увеличить отступ списка"
|
||||
"text.listOutdent" = "Уменьшить отступ списка"
|
||||
"text.listStyle" = "Стиль списка"
|
||||
"text.loadFont" = "Загрузить файл шрифта"
|
||||
"text.maxLines" = "Максимум строк"
|
||||
"text.missingFonts" = "Отсутствующие шрифты"
|
||||
"text.numberList" = "Нумерованный список"
|
||||
"text.onPath" = "Текст на пути"
|
||||
"text.paragraphIndent" = "Абзацный отступ"
|
||||
"text.paragraphSpacing" = "Интервал между абзацами"
|
||||
"text.pathOptions" = "Параметры текста на пути"
|
||||
"text.preview" = "Предпросмотр изменения текста"
|
||||
"text.refit" = "Подогнать текст под шрифты"
|
||||
"text.removeFromPath" = "Снять текст с пути"
|
||||
"text.rescanFonts" = "Повторно найти установленные шрифты"
|
||||
"text.set" = "Задать текст"
|
||||
"text.setFeatures" = "Функции OpenType"
|
||||
"text.setVariations" = "Оси вариативного шрифта"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -27,7 +27,8 @@ pub struct Lang {
|
||||
pub code: &'static str,
|
||||
/// The language's own name for itself (the language picker shows it untranslated).
|
||||
pub name: &'static str,
|
||||
/// Machine-drafted, waiting for a native speaker's review (`[meta] status`).
|
||||
/// A draft (`[meta] status` set): waiting for a native speaker's review, or reviewed and waiting for
|
||||
/// a maintainer's approval.
|
||||
pub draft: bool,
|
||||
/// Index into [`LANGS`] of the catalog a regional one overlays ("es" under "es-419": it lists
|
||||
/// only what differs), else 0 (English).
|
||||
|
||||
@@ -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 | |
|
||||
|
||||
+4
-2
@@ -17,7 +17,9 @@ Simplified.
|
||||
The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay
|
||||
`es-419`), French (`fr`), German (`de`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`), Japanese
|
||||
(`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`), Finnish
|
||||
(`fi`), Polish (`pl`), Georgian (`ka`) and Bengali (`bn`), machine-drafted and waiting for native speakers' review. Russian (`ru`, Русский) was
|
||||
(`fi`), Polish (`pl`) and Bengali (`bn`), machine-drafted and waiting for native speakers' review. Georgian
|
||||
(`ka`) was written with AI assistance and reviewed by a native speaker, and stays a draft until a
|
||||
maintainer changes its status. Russian (`ru`, Русский) was
|
||||
reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and Smart animate as familiar
|
||||
tool names and uses « » quotes. Turkish (`tr`) was machine-drafted and reviewed by a native speaker,
|
||||
and Swedish (`sv`) translates every UI string, command label and menu wording.
|
||||
@@ -106,7 +108,7 @@ picker leaves Georgian out. A new CJK or other non-Latin language also needs its
|
||||
The check fails on keys the code uses that `en.toml` lacks (it prints the lines to paste), keys no
|
||||
code uses, unknown command ids, placeholders that don't match English, and, for complete languages,
|
||||
anything untranslated. Machine-drafted catalogs stay marked as drafts until a native speaker has
|
||||
reviewed them.
|
||||
reviewed them; a reviewed catalog stays a draft until a maintainer approves it (its `status` says which).
|
||||
|
||||
## Adding strings in code
|
||||
|
||||
|
||||
Reference in new issue
Block a user