mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 19:36:46 -06:00
Merge main into #984
This commit is contained in:
@@ -3,5 +3,13 @@
|
||||
[env]
|
||||
CRAFT_FONTS_DIR = { value = "../craft-fonts", relative = true }
|
||||
|
||||
# The Windows UI Automation adapter can be asked about a node that disappeared in the
|
||||
# same frame. On MinGW, converting that HRESULT through windows-result's thread COM
|
||||
# error-info path has crashed in GetErrorInfo. Keep the HRESULT without that extra
|
||||
# COM lookup; this leaves the AccessKit tree and screen-reader support enabled. Every Windows GNU
|
||||
# target (x86_64, i686, the gnullvm ones) gets it, as the check in apps/figcraft/src/main.rs asks.
|
||||
[target.'cfg(all(windows, target_env = "gnu"))']
|
||||
rustflags = ["--cfg=windows_slim_errors"]
|
||||
|
||||
[alias]
|
||||
xtask = "run -q -p xtask --"
|
||||
|
||||
@@ -21,11 +21,10 @@ jobs:
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# The craft-fonts commit release.yml builds with. Needs fonts/latin-manifest.txt (Inter):
|
||||
# crates/text/build.rs fails a build that requires craft-fonts without it. Only craft-fonts'
|
||||
# pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. Re-pin
|
||||
# once one commit has both (storytold/craft-fonts#42).
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
# Combined Latin presentation and core UI catalog. Required web builds enforce
|
||||
# Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering.
|
||||
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
require-craft-fonts: true
|
||||
packages: >-
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
|
||||
libgtk-3-dev
|
||||
|
||||
@@ -32,4 +32,4 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
run-package-check: true
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
|
||||
@@ -24,13 +24,12 @@ jobs:
|
||||
with:
|
||||
app-name: figcraft
|
||||
app-title: FigCraft
|
||||
# Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release without it. Only
|
||||
# craft-fonts' pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter.
|
||||
# Re-pin once one commit has both (storytold/craft-fonts#42).
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
# Combined Latin presentation and core UI catalog. Required web builds enforce
|
||||
# Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering.
|
||||
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
has-server: true
|
||||
# The browser has no system fonts: without craft-fonts the web build can't draw text (#885).
|
||||
# crates/text/build.rs keeps the web build to Inter.
|
||||
# crates/text/build.rs keeps the web allowlist small (Inter and Georgian).
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
|
||||
+11
-2
@@ -26,8 +26,17 @@ Release builds embed fonts from the optional craft-fonts build input
|
||||
|---|---|---|
|
||||
| Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter |
|
||||
| Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) |
|
||||
| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
|
||||
| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
|
||||
| Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts |
|
||||
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
|
||||
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic, Noto Sans Georgian | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
|
||||
|
||||
The central catalog also contains Adobe-authored Source Sans 3 and Source Serif 4.
|
||||
FigCraft filters both families from every build in accordance with its asset policy.
|
||||
|
||||
egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own licences (OFL / Apache-2.0 / Ubuntu Font Licence); see those crates.
|
||||
|
||||
## egui dependency font icons
|
||||
|
||||
| Asset | Author | Source | Licence | Use |
|
||||
|---|---|---|---|---|
|
||||
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. |
|
||||
|
||||
Generated
+472
-146
File diff suppressed because it is too large
Load Diff
+29
-8
@@ -32,11 +32,15 @@ figcraft-mcp = { path = "crates/mcp" }
|
||||
figcraft-collab-net = { path = "crates/collab-net" }
|
||||
figcraft-plugin = { path = "crates/plugin" }
|
||||
|
||||
# craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together.
|
||||
# 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" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
|
||||
# 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"] }
|
||||
# same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it
|
||||
# to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would
|
||||
# pull a second egui family version).
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
@@ -79,9 +83,9 @@ wasmi = { version = "2", default-features = false, features = ["std", "stable",
|
||||
wat = "1.261"
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui = "0.37"
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
webbrowser = "1"
|
||||
@@ -130,7 +134,24 @@ debug = false
|
||||
strip = true
|
||||
panic = "abort"
|
||||
|
||||
# Keep the complete egui family and native event bridge on reviewed fork commits.
|
||||
[patch.crates-io]
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
|
||||
# craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a
|
||||
# panic while drawing unwinds without aborting.
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
|
||||
|
||||
@@ -51,3 +51,20 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
egui icon font subset (dependency asset)
|
||||
----------------------------------------
|
||||
epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of
|
||||
emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback
|
||||
uses the same MIT license. Upstream license text follows unchanged.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
+4
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Dev Mode measurements kept on the canvas and a read-only layers panel; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
|
||||
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
|
||||
- **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%.
|
||||
- **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)).
|
||||
@@ -334,6 +335,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
|
||||
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
|
||||
| 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding |
|
||||
| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding |
|
||||
| 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged |
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings};
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::{BatchScope, BatchStep, Session};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{CliError, CliResult};
|
||||
@@ -318,7 +318,7 @@ pub fn render(file: &str, out: &str, scale: f64, page: Option<&str>) -> CliResul
|
||||
}
|
||||
|
||||
/// Parse a script: a JSON array of steps, `{"commands": [...]}`, or JSON lines.
|
||||
pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
|
||||
pub fn parse_script(text: &str) -> Result<Vec<BatchStep>, String> {
|
||||
let steps: Vec<Value> = match serde_json::from_str::<Value>(text) {
|
||||
Ok(Value::Array(a)) => a,
|
||||
Ok(Value::Object(o)) if o.contains_key("commands") => {
|
||||
@@ -343,7 +343,8 @@ pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
|
||||
Some(p @ Value::Object(_)) => p.clone(),
|
||||
Some(_) => return Err(format!("step {i} ({cmd}): `params` must be an object")),
|
||||
};
|
||||
Ok((cmd.to_string(), params))
|
||||
let name = figcraft_engine::batch::step_name(i, &s).map_err(|e| e.to_string())?;
|
||||
Ok(BatchStep { name, ..BatchStep::new(cmd, params) })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -361,31 +362,30 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
|
||||
None => Session::new(),
|
||||
};
|
||||
// One undo step and all-or-nothing, the way the same commands behave through `command.batch`
|
||||
// (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and
|
||||
// a step that fails takes the whole script back instead of leaving the earlier steps applied.
|
||||
// The result lines are held back until the script is through, so none of them reports work that
|
||||
// was rolled back.
|
||||
let mut results: Vec<Value> = Vec::with_capacity(steps.len());
|
||||
// (MCP, the control channel, plugins): `{"$step": n}` and `{"$ref": "name"}` resolve against the
|
||||
// steps before it, and a step that fails takes the whole script back instead of leaving the
|
||||
// earlier steps applied. The result lines are held back until the script is through, so none of
|
||||
// them reports work that was rolled back.
|
||||
let mut scope = BatchScope::new(&steps).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
|
||||
let mut failed_at = 0;
|
||||
let outcome = s.grouped(&format!("run {script}"), |s| {
|
||||
for (i, (cmd, params)) in steps.iter().enumerate() {
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
failed_at = i;
|
||||
let v = s.batch_step(&results, i, cmd, params)?;
|
||||
s.batch_step(&mut scope, &step.command, &step.params)?;
|
||||
s.ui_requests.clear();
|
||||
results.push(v);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
if let Err(e) = outcome {
|
||||
let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str());
|
||||
let cmd = steps.get(failed_at).map_or("", |step| step.command.as_str());
|
||||
let i = failed_at;
|
||||
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true}));
|
||||
eprintln!("error: step {i} ({cmd}) failed: {e}");
|
||||
eprintln!("the script's document changes were rolled back");
|
||||
return Ok(ExitCode::from(1));
|
||||
}
|
||||
for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() {
|
||||
println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v}));
|
||||
for (i, (step, v)) in steps.iter().zip(scope.results()).enumerate() {
|
||||
println!("{}", json!({"step": i, "command": step.command, "ok": true, "result": v}));
|
||||
}
|
||||
if let Some(out) = out {
|
||||
let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
@@ -511,6 +511,43 @@ mod tests {
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A script names its steps with `as` and reads paths into their results, like `command.batch`.
|
||||
#[test]
|
||||
fn a_script_refers_to_named_steps_and_paths() {
|
||||
let dir = temp_dir("run-ref");
|
||||
let script = dir.join("script.json");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"[
|
||||
{"command": "node.create", "as": "card", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
|
||||
{"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}},
|
||||
{"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": "ids/0"}], "props": {"name": "Copy"}}}
|
||||
]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let out = dir.join("out.figcraft");
|
||||
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
|
||||
|
||||
let mut s = Session::empty();
|
||||
s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let top = doc.node(s.page().unwrap()).unwrap().children.clone();
|
||||
let names: Vec<_> = top.iter().map(|id| doc.node(*id).unwrap().name.clone()).collect();
|
||||
assert_eq!(names, ["Card", "Copy"]);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// Two steps with one name fail before anything runs.
|
||||
#[test]
|
||||
fn a_script_with_a_name_twice_runs_nothing() {
|
||||
let dir = temp_dir("run-dup-name");
|
||||
let script = dir.join("script.json");
|
||||
std::fs::write(&script, r#"[{"command": "select.none", "as": "a"}, {"command": "select.none", "as": "a"}]"#).unwrap();
|
||||
let Err(CliError::Failed(e)) = run_script(script.to_str().unwrap(), None, None) else { panic!("a name twice must fail") };
|
||||
assert!(e.contains("step 1: `as` \"a\" is already step 0's name"), "{e}");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A step that fails takes the whole script back: nothing is written, and the failure says so.
|
||||
#[test]
|
||||
fn a_failed_step_leaves_no_output() {
|
||||
|
||||
@@ -128,13 +128,14 @@ fn u64_at(b: &[u8], at: usize) -> Option<u64> {
|
||||
|
||||
/// A snapshot file's bytes.
|
||||
pub fn encode_snapshot(generation: u64, state: &SessionState) -> Result<Vec<u8>, String> {
|
||||
let body = message::encode(state).map_err(|e| e.to_string())?;
|
||||
let mut out = Vec::with_capacity(SNAP_HEADER + body.len());
|
||||
let mut out = Vec::with_capacity(SNAP_HEADER);
|
||||
out.extend_from_slice(SNAP_MAGIC);
|
||||
out.push(FORMAT);
|
||||
out.extend_from_slice(&generation.to_le_bytes());
|
||||
out.extend_from_slice(&crc(&body).to_le_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
out.extend_from_slice(&0u32.to_le_bytes());
|
||||
let mut out = message::encode_into(state, out).map_err(|e| e.to_string())?;
|
||||
let sum = crc(&out[SNAP_HEADER..]);
|
||||
out[SNAP_HEADER - 4..SNAP_HEADER].copy_from_slice(&sum.to_le_bytes());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -1480,14 +1480,18 @@ impl Backend for Store {
|
||||
}
|
||||
|
||||
fn idle(&mut self, file: &str, server: &mut Server) -> bool {
|
||||
let dirty = self.files.get_mut(file).and_then(|e| e.log.as_mut()).is_some_and(|log| {
|
||||
let _ = flush_log(file, log);
|
||||
log.dirty
|
||||
let compact = self.files.get_mut(file).is_some_and(|entry| {
|
||||
let snapshot_seq = entry.meta.seq;
|
||||
entry.log.as_mut().is_some_and(|log| {
|
||||
// Issued client ids and refusals are durable in the log; only edits need a
|
||||
// new image-bearing snapshot. Still try a snapshot if syncing the log failed.
|
||||
flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq)
|
||||
})
|
||||
});
|
||||
if dirty {
|
||||
if compact {
|
||||
self.compact(file, server);
|
||||
}
|
||||
// It closes anyway, everything in its snapshot: a compaction cut short needs no report.
|
||||
// It closes anyway, with its state in the snapshot and durable log.
|
||||
self.cut_short.retain(|f| f != file);
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
e.log = None;
|
||||
|
||||
@@ -127,6 +127,34 @@ fn a_file_survives_a_crash_and_compacts() {
|
||||
assert_eq!(store.list(&|_| None)[0].seq, seq, "the description follows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_sessions_keep_client_ids_without_rewriting_the_snapshot() {
|
||||
let dir = TempDir::new("read-only-reconnects");
|
||||
let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) };
|
||||
let (id, original) = {
|
||||
let mut store = Store::open(dir.path(), c.clone()).unwrap();
|
||||
let (id, _, _) = publish(&mut store, "Read only");
|
||||
let bytes = fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap();
|
||||
(id, bytes)
|
||||
};
|
||||
let mut last_client = 0;
|
||||
for _ in 0..6 {
|
||||
let mut store = Store::open(dir.path(), c.clone()).unwrap();
|
||||
let mut server = store.open(&id).unwrap();
|
||||
let client = join(&mut server, Access::View);
|
||||
assert!(client.id() > last_client, "issued ids survive closing and reopening the store");
|
||||
last_client = client.id();
|
||||
store.changed(&id, &mut server);
|
||||
// Idle must sync even if no periodic flush ran before the connection closed.
|
||||
assert!(store.idle(&id, &mut server));
|
||||
assert_eq!(store.compactions(), 0, "a client id does not require rebuilding the image-bearing snapshot");
|
||||
assert_eq!(fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap(), original);
|
||||
let log = disk::read_log(&fs::read(dir.path().join(FILES).join(&id).join(LOG)).unwrap()).unwrap();
|
||||
assert!(!log.entries.is_empty());
|
||||
assert_eq!(server.seq(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_log_is_compacted_while_open() {
|
||||
let dir = TempDir::new("compact");
|
||||
@@ -252,6 +280,19 @@ fn temporary_directories_are_removed() {
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_encoding_keeps_the_existing_framing_and_checksum() {
|
||||
let state = Server::new(doc("Snapshot framing"), "", figcraft_collab::Limits::default()).state().unwrap();
|
||||
let body = figcraft_collab::message::encode(&state).unwrap();
|
||||
let mut legacy = b"FCSN\x01".to_vec();
|
||||
legacy.extend_from_slice(&37u64.to_le_bytes());
|
||||
legacy.extend_from_slice(&crc32fast::hash(&body).to_le_bytes());
|
||||
legacy.extend_from_slice(&body);
|
||||
let encoded = disk::encode_snapshot(37, &state).unwrap();
|
||||
assert_eq!(encoded, legacy);
|
||||
assert_eq!(disk::decode_snapshot(&encoded).unwrap(), (37, state));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bytes_never_panic_the_readers() {
|
||||
let mut rng = Rng::new(7);
|
||||
|
||||
@@ -69,7 +69,15 @@ struct WebShell {
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
// `FigApp::ui` runs its own per-frame logic first (fonts, theme), like the desktop app.
|
||||
// eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep
|
||||
// autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it.
|
||||
/// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs
|
||||
/// still advance autosave, live and control deadlines without painting. `FigApp::ui` does
|
||||
/// not repeat it.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.fig.ui(ui);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ figcraft-text = { workspace = true }
|
||||
# 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"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
@@ -33,6 +33,7 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col
|
||||
# The system's reduce-motion setting (UISettings.AnimationsEnabled), interface language
|
||||
# (GlobalizationPreferences.Languages) and controllers for prototypes (Gaming.Input.Gamepad).
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
|
||||
|
||||
# Finder double-clicks, Open With, Dock drops and `open -a FigCraft file.figcraft` arrive as a
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Windows: embed the icon and version info in figcraft.exe and size its main thread's stack
|
||||
//! (no-op elsewhere).
|
||||
fn main() {
|
||||
println!("cargo::rustc-check-cfg=cfg(windows_slim_errors)");
|
||||
println!("cargo::rerun-if-changed=../../assets/app-icon/figcraft.ico");
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
if target_os == "windows" {
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_ui_egui::ControlRequest;
|
||||
use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate, RequestState};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
type Queue = Arc<Mutex<Vec<ControlRequest>>>;
|
||||
@@ -21,6 +21,34 @@ const TIMEOUT: Duration = Duration::from_secs(60);
|
||||
/// core busy until it recovers.
|
||||
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Why a request's transport wait ended without a control reply.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum WaitFailure {
|
||||
Timeout,
|
||||
/// The UI side dropped its sender before replying (the app is closing).
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
/// Wait for the UI's reply, and settle a timeout under the same gate the UI's final commit uses.
|
||||
/// A commit that got the gate first has already queued its reply: consume that reply instead of
|
||||
/// reporting a timeout. If the gate is still open, cancel it so the UI writes nothing.
|
||||
fn wait_for_reply(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGate, timeout: Duration) -> Result<ControlReply, WaitFailure> {
|
||||
match rx.recv_timeout(timeout) {
|
||||
Ok(reply) => Ok(reply),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => after_timeout(rx, gate),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(WaitFailure::Disconnected),
|
||||
}
|
||||
}
|
||||
|
||||
/// The timeout branch of [`wait_for_reply`]. Kept separate so it can be tested deterministically
|
||||
/// against a gate whose commit already happened.
|
||||
fn after_timeout(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGate) -> Result<ControlReply, WaitFailure> {
|
||||
match gate.cancel_if_open() {
|
||||
RequestState::Replied => rx.try_recv().map_err(|_| WaitFailure::Disconnected),
|
||||
RequestState::Open | RequestState::Cancelled => Err(WaitFailure::Timeout),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deferred requests for a hidden window must not make the UI's queue grow without a bound.
|
||||
pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
|
||||
target.retain(|request| !request.is_cancelled());
|
||||
@@ -69,6 +97,9 @@ fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve one control connection: read bounded JSON lines, reject oversized frames and a
|
||||
/// full queue, queue one request, wait under its gate, and write one reply. Non-JSON input
|
||||
/// answers with an error and closes the connection so HTTP body lines cannot become commands.
|
||||
fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
|
||||
out.set_read_timeout(Some(timeout))?;
|
||||
out.set_write_timeout(Some(timeout))?;
|
||||
@@ -96,7 +127,7 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
|
||||
let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = v.get("params").cloned().unwrap_or(json!({}));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let gate = RequestGate::new();
|
||||
let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
pending.retain(|request| !request.is_cancelled());
|
||||
if pending.len() >= MAX_QUEUED_REQUESTS {
|
||||
@@ -104,20 +135,22 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
|
||||
writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?;
|
||||
continue;
|
||||
}
|
||||
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, cancelled: cancelled.clone() });
|
||||
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, state: gate.clone() });
|
||||
drop(pending);
|
||||
if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
match rx.recv_timeout(timeout) {
|
||||
match wait_for_reply(&rx, &gate, timeout) {
|
||||
Ok(r) => match r.result {
|
||||
Ok(res) => json!({"id": r.id, "ok": true, "result": res}),
|
||||
Err(e) => json!({"id": r.id, "ok": false, "error": e}),
|
||||
},
|
||||
Err(_) => {
|
||||
cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
Err(WaitFailure::Timeout) => {
|
||||
json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"})
|
||||
}
|
||||
Err(WaitFailure::Disconnected) => {
|
||||
json!({"id": id, "ok": false, "error": "the FigCraft window closed before answering"})
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -28,8 +28,9 @@ fn reply(reader: &mut BufReader<TcpStream>) -> Value {
|
||||
serde_json::from_str(&line).unwrap()
|
||||
}
|
||||
|
||||
/// Build a `ui.inspect` control request with a throwaway reply channel.
|
||||
fn request() -> ControlRequest {
|
||||
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, cancelled: Default::default() }
|
||||
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, state: Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -115,6 +116,52 @@ fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() {
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
/// Asserts a commit that writes the file and queues its reply wins the gate: the transport's
|
||||
/// timeout branch consumes that reply and leaves the state `Replied`.
|
||||
#[test]
|
||||
fn a_committed_reply_wins_over_the_transport_timeout() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-control-commit-{}", std::process::id()));
|
||||
let path = dir.join("shot.png");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let gate = RequestGate::new();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
// The UI's final commit gets the gate first: the file and reply are both committed before the
|
||||
// transport's timeout branch can look at the gate.
|
||||
let committed = gate.commit(|| {
|
||||
std::fs::write(&path, b"png").unwrap();
|
||||
tx.send(ControlReply { id: json!(7), result: Ok(json!({"path": path.to_string_lossy()})) }).unwrap();
|
||||
});
|
||||
assert!(committed.is_some());
|
||||
assert!(path.is_file());
|
||||
let reply = after_timeout(&rx, &gate).expect("the queued commit is consumed");
|
||||
assert_eq!(reply.id, json!(7));
|
||||
assert!(reply.result.is_ok());
|
||||
assert_eq!(gate.state(), RequestState::Replied, "the timeout must not overwrite a commit");
|
||||
assert!(path.is_file(), "a winning commit keeps its file");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Asserts a zero-duration wait reports `Timeout` and cancels the open gate.
|
||||
#[test]
|
||||
fn a_timeout_cancels_an_open_request() {
|
||||
let gate = RequestGate::new();
|
||||
let (_tx, rx) = mpsc::channel::<ControlReply>();
|
||||
assert!(matches!(wait_for_reply(&rx, &gate, Duration::ZERO), Err(WaitFailure::Timeout)));
|
||||
assert_eq!(gate.state(), RequestState::Cancelled, "nothing may write after the timeout");
|
||||
}
|
||||
|
||||
/// Asserts a closed reply channel reports `Disconnected` and leaves the gate `Open`, not
|
||||
/// cancelled.
|
||||
#[test]
|
||||
fn a_disconnected_reply_channel_is_not_reported_as_a_timeout() {
|
||||
let gate = RequestGate::new();
|
||||
let (tx, rx) = mpsc::channel::<ControlReply>();
|
||||
drop(tx);
|
||||
assert!(matches!(wait_for_reply(&rx, &gate, Duration::from_secs(1)), Err(WaitFailure::Disconnected)));
|
||||
assert_eq!(gate.state(), RequestState::Open, "a closed app is not a cancel decision");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_and_partial_frame_connections_expire() {
|
||||
for bytes in [b"".as_slice(), b"{\"id\":"] {
|
||||
@@ -227,6 +274,9 @@ fn a_limit_sized_frame_is_accepted_at_eof() {
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
/// Asserts expired requests stop occupying the UI queue cap, a later request is admitted
|
||||
/// and answered, and a window that finally draws discards its expired command before
|
||||
/// dispatch.
|
||||
#[test]
|
||||
fn timed_out_requests_release_a_full_deferred_ui_queue() {
|
||||
use figcraft_ui_egui::{FigApp, NoServices, control, windows};
|
||||
@@ -262,7 +312,7 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() {
|
||||
// Include expired requests in the socket queue, too: admission must reclaim their slots.
|
||||
pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| {
|
||||
let r = request();
|
||||
r.cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
r.state.cancel_if_open();
|
||||
r
|
||||
}));
|
||||
let (mut client, server) = pair();
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
// A missing Windows GNU flag re-enables the COM error-info lookup that crashes while AccessKit
|
||||
// reports a removed UI Automation node. Catch build configurations that drop the workaround.
|
||||
#[cfg(all(target_os = "windows", target_env = "gnu", not(windows_slim_errors)))]
|
||||
compile_error!("Windows GNU builds must set windows_slim_errors to keep UI Automation responsive");
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod args;
|
||||
@@ -62,6 +67,9 @@ struct App {
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
/// Native eframe logic callback: store the first context, drain control requests, run
|
||||
/// guarded background work, poll macOS open events, handle close requests and title
|
||||
/// changes, and schedule recovery repaints on the app clock.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
{
|
||||
let mut slot = self.ctx_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
@@ -71,7 +79,9 @@ impl eframe::App for App {
|
||||
}
|
||||
let title = with_app(|fig| {
|
||||
control_server::drain_queue(&self.queue, &mut fig.control_queue);
|
||||
figcraft_ui_egui::control::drain_hidden(fig, ctx);
|
||||
// Phase-safe background work: control queues, autosave/recovery deadlines, live polling.
|
||||
// It runs on logic-only callbacks too, so it never reads UI input.
|
||||
figcraft_ui_egui::windows::guard(fig, ctx, |fig| fig.background_tick(ctx));
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events::poll(&self.apple_events, fig, ctx);
|
||||
// Closing the window asks about unsaved changes (and keeps the app running while other
|
||||
@@ -86,8 +96,14 @@ impl eframe::App for App {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
|
||||
self.title = title;
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
if let Some(wait) = with_app(|fig| recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))).flatten() {
|
||||
// Recovery deadlines use the same monotonic clock as the app's background work: a
|
||||
// logic-only tick leaves `ctx.input(i.time)` stale.
|
||||
if let Some(wait) = with_app(|fig| {
|
||||
let now = fig.now_secs();
|
||||
recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))
|
||||
})
|
||||
.flatten()
|
||||
{
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
|
||||
}
|
||||
}
|
||||
@@ -117,6 +133,11 @@ impl eframe::App for App {
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::FIGCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.iter().any(|a| a == "--version" || a == "-V") {
|
||||
@@ -199,6 +220,7 @@ fn main() -> eframe::Result {
|
||||
// open (no frame then).
|
||||
figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref());
|
||||
let mut fig = FigApp::new(session, Box::new(svc));
|
||||
fig.set_startup_update_check(true);
|
||||
fig.automation_roots = roots;
|
||||
fig.ui.home = false;
|
||||
// Files named on the command line are the user's too: their missing fonts are listed.
|
||||
|
||||
@@ -106,7 +106,10 @@ fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() {
|
||||
assert!(host.live().peers.is_empty());
|
||||
// A typing mistake is caught before anything connects.
|
||||
let mut typo: Vec<char> = local.code().chars().collect();
|
||||
typo[7] = if typo[7] == 'A' { 'B' } else { 'A' };
|
||||
// The last symbol contains only checksum bits. Changing payload bits can collide with
|
||||
// the eight-bit checksum, so it does not guarantee a malformed code.
|
||||
let last = typo.last_mut().unwrap();
|
||||
*last = if *last == 'A' { 'B' } else { 'A' };
|
||||
let e = guest.execute("collab.join", &json!({"code": typo.iter().collect::<String>()})).unwrap_err().to_string();
|
||||
assert!(e.contains("typing mistake"), "{e}");
|
||||
// A view-only code doesn't exist here, and says where it does.
|
||||
|
||||
@@ -89,6 +89,9 @@ fn rect(s: &mut Session) -> u64 {
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
/// Asserts two files are hosted and joined independently: each has its own port, session and
|
||||
/// edits, presence stays in its file's session, and closing one file's session leaves the
|
||||
/// other live.
|
||||
#[test]
|
||||
fn two_files_are_hosted_and_joined_independently() {
|
||||
let mut host = session("Hana");
|
||||
@@ -126,7 +129,10 @@ fn two_files_are_hosted_and_joined_independently() {
|
||||
// Each joined file opens in front when its welcome arrives, and the two joins race: the file
|
||||
// whose welcome came last is in front.
|
||||
assert_eq!(guest.ds().unwrap().uid, guest.docs[2].uid, "the file that arrived last is in front");
|
||||
// Concurrent joins may complete in either order. Put B in front explicitly so the following
|
||||
// assertions exercise A's background session independent of network timing.
|
||||
guest.execute("doc.switch", &json!({"index": guest.docs.iter().position(|d| d.uid == gb).unwrap()})).unwrap();
|
||||
assert_eq!(guest.ds().unwrap().uid, gb, "B is in front while A receives edits");
|
||||
// An edit in A reaches the guest's copy of A only, while B is in front at the guest.
|
||||
let in_a = rect(&mut host);
|
||||
host.execute("doc.switch", &json!({"index": 1})).unwrap();
|
||||
|
||||
@@ -11,6 +11,16 @@ use crate::message::{
|
||||
use crate::sim::Rng;
|
||||
use crate::{Error, Replica, diff};
|
||||
|
||||
#[test]
|
||||
fn encoding_into_a_prefix_keeps_the_wire_body() {
|
||||
let msg = moved_layer();
|
||||
let prefix = b"container header".to_vec();
|
||||
let bytes = crate::message::encode_into(&msg, prefix.clone()).unwrap();
|
||||
assert_eq!(&bytes[..prefix.len()], prefix);
|
||||
assert_eq!(&bytes[prefix.len()..], encode(&msg).unwrap());
|
||||
assert_eq!(decode::<ClientMsg>(&bytes[prefix.len()..], bytes.len()).unwrap(), msg);
|
||||
}
|
||||
|
||||
/// A typical edit: one layer dragged (its transform).
|
||||
fn moved_layer() -> ClientMsg {
|
||||
let mut doc = Document::new("f");
|
||||
|
||||
@@ -739,11 +739,13 @@ pub fn utc_date(secs: u64) -> String {
|
||||
|
||||
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard.
|
||||
pub fn encode<T: Serialize>(msg: &T) -> Result<Vec<u8>, Error> {
|
||||
let body = postcard::to_allocvec(msg).map_err(|e| Error::Encode(e.to_string()))?;
|
||||
let mut out = Vec::with_capacity(body.len() + 1);
|
||||
out.push(WIRE_VERSION);
|
||||
out.extend(body);
|
||||
Ok(out)
|
||||
encode_into(msg, Vec::new())
|
||||
}
|
||||
|
||||
/// Append the binary form of `msg` to an owned prefix, without a second payload-sized buffer.
|
||||
pub fn encode_into<T: Serialize>(msg: &T, mut prefix: Vec<u8>) -> Result<Vec<u8>, Error> {
|
||||
prefix.push(WIRE_VERSION);
|
||||
postcard::to_extend(msg, prefix).map_err(|e| Error::Encode(e.to_string()))
|
||||
}
|
||||
|
||||
/// Read a binary message of at most `max_bytes` (untrusted input: never panics, allocates at most
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Step references in `command.batch`: a step's params can use what an earlier step returned.
|
||||
//!
|
||||
//! `{"$step": n}` stands for the `id` in the result of step `n` (zero-based), and `{"$ref": "name"}`
|
||||
//! for the `id` in the result of the step that called itself `name` with `"as": "name"`. Either
|
||||
//! takes a `field`, a key of that result or a slash path into it (`ids/0`, `map/3`, `bounds/x`), the
|
||||
//! way `variable.bind` names `fills/0/color`. The CLI's script runner resolves its steps the same way.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::{Result, bad};
|
||||
|
||||
/// Deepest params nesting the resolver walks before giving up.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// One step of a batch: a command, its params and the name later steps can call it by.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BatchStep {
|
||||
pub command: String,
|
||||
pub params: Value,
|
||||
/// The step's `as`, for `{"$ref": name}` in later steps.
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl BatchStep {
|
||||
/// A step without a name.
|
||||
pub fn new(command: impl Into<String>, params: Value) -> Self {
|
||||
Self { command: command.into(), params, name: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// A step's `as` (`step` is its index, for the error): `None` when it has none.
|
||||
pub fn step_name(step: usize, json: &Value) -> Result<Option<String>> {
|
||||
match json.get("as") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(name)) if !name.is_empty() => Ok(Some(name.clone())),
|
||||
Some(_) => Err(bad("command.batch", format!("step {step}: `as` must be a name (a non-empty string)"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The steps of one batch run so far: their results, and which step each `as` name belongs to.
|
||||
/// A nested `command.batch` step gets its own scope, so its references are to its own steps.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BatchScope {
|
||||
names: HashMap<String, usize>,
|
||||
results: Vec<Value>,
|
||||
}
|
||||
|
||||
impl BatchScope {
|
||||
/// The scope for running `steps`. Two steps with the same name fail here, before any step runs.
|
||||
pub fn new(steps: &[BatchStep]) -> Result<Self> {
|
||||
let mut names = HashMap::new();
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let Some(name) = &step.name else { continue };
|
||||
if let Some(first) = names.insert(name.clone(), i) {
|
||||
return Err(bad("command.batch", format!("step {i}: `as` \"{name}\" is already step {first}'s name")));
|
||||
}
|
||||
}
|
||||
Ok(Self { names, results: Vec::with_capacity(steps.len()) })
|
||||
}
|
||||
|
||||
/// The results of the steps run so far, in order.
|
||||
pub fn results(&self) -> &[Value] {
|
||||
&self.results
|
||||
}
|
||||
|
||||
pub fn into_results(self) -> Vec<Value> {
|
||||
self.results
|
||||
}
|
||||
|
||||
/// The index of the step that runs next.
|
||||
pub fn next_step(&self) -> usize {
|
||||
self.results.len()
|
||||
}
|
||||
|
||||
pub(crate) fn push(&mut self, result: Value) {
|
||||
self.results.push(result);
|
||||
}
|
||||
|
||||
/// `params` of the next step with every reference in it replaced.
|
||||
pub(crate) fn resolve(&self, params: &Value) -> Result<Value> {
|
||||
self.resolve_in(params, 0)
|
||||
}
|
||||
|
||||
fn resolve_in(&self, v: &Value, depth: usize) -> Result<Value> {
|
||||
let step = self.next_step();
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(bad("command.batch", format!("step {step}: params nest deeper than {MAX_DEPTH} levels")));
|
||||
}
|
||||
match v {
|
||||
Value::Object(o) if o.contains_key("$step") || o.contains_key("$ref") => self.reference(o),
|
||||
Value::Object(o) => {
|
||||
let mut out = Map::with_capacity(o.len());
|
||||
for (k, x) in o {
|
||||
out.insert(k.clone(), self.resolve_in(x, depth + 1)?);
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
Value::Array(a) => a.iter().map(|x| self.resolve_in(x, depth + 1)).collect::<Result<Vec<_>>>().map(Value::Array),
|
||||
_ => Ok(v.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The value a `{"$step": n}` or `{"$ref": "name"}` object (with an optional `field`) stands for.
|
||||
fn reference(&self, o: &Map<String, Value>) -> Result<Value> {
|
||||
let step = self.next_step();
|
||||
let fail = |msg: String| bad("command.batch", format!("step {step}: {msg}"));
|
||||
let key = if o.contains_key("$step") { "$step" } else { "$ref" };
|
||||
if let Some(k) = o.keys().find(|k| *k != key && *k != "field") {
|
||||
return Err(fail(format!("a `{key}` reference takes only `field`, not `{k}`")));
|
||||
}
|
||||
let (n, who) = if key == "$step" {
|
||||
let n = o.get("$step").and_then(Value::as_u64).ok_or_else(|| fail("`$step` must be a step index (0, 1, …)".into()))?;
|
||||
let n = usize::try_from(n).ok().filter(|n| *n < step).ok_or_else(|| {
|
||||
let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) };
|
||||
fail(format!("`$step` {n} must be an earlier step ({range})"))
|
||||
})?;
|
||||
(n, format!("step {n}"))
|
||||
} else {
|
||||
let name = o.get("$ref").and_then(Value::as_str).ok_or_else(|| fail("`$ref` must be a step name (a string)".into()))?;
|
||||
let n = *self.names.get(name).ok_or_else(|| fail(format!("`$ref` \"{name}\" names no step ({})", self.known_names())))?;
|
||||
if n >= step {
|
||||
return Err(fail(format!("`$ref` \"{name}\" is step {n}, not an earlier step")));
|
||||
}
|
||||
(n, format!("step {n} (\"{name}\")"))
|
||||
};
|
||||
let path = match o.get("field") {
|
||||
None => "id",
|
||||
Some(Value::String(f)) => f.as_str(),
|
||||
Some(_) => return Err(fail("`field` must be a string".into())),
|
||||
};
|
||||
let result = self.results.get(n).ok_or_else(|| fail(format!("{who} has no result")))?;
|
||||
lookup(result, path).cloned().map_err(|why| fail(format!("{who}'s result has no `{path}`{why}")))
|
||||
}
|
||||
|
||||
fn known_names(&self) -> String {
|
||||
let mut names: Vec<_> = self.names.iter().collect();
|
||||
names.sort_by_key(|(_, i)| **i);
|
||||
if names.is_empty() {
|
||||
return "no step has an `as` name".into();
|
||||
}
|
||||
let list: Vec<_> = names.iter().map(|(name, i)| format!("\"{name}\" is step {i}")).collect();
|
||||
list.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` in `v`: a key as written, else a slash path (`ids/0`, `bounds/x`; a leading `/` as in
|
||||
/// JSON Pointer is allowed). The error is the detail after "has no `path`".
|
||||
fn lookup<'a>(v: &'a Value, path: &str) -> std::result::Result<&'a Value, String> {
|
||||
// A key as written first, so a key holding `/` still works as it did before paths.
|
||||
if let Some(x) = v.get(path) {
|
||||
return Ok(x);
|
||||
}
|
||||
let hint = if path.contains('.') && !path.contains('/') { " (paths use `/`, as in `ids/0`)" } else { "" };
|
||||
walk(v, path.strip_prefix('/').unwrap_or(path)).map_err(|why| format!("{why}{hint}"))
|
||||
}
|
||||
|
||||
fn walk<'a>(v: &'a Value, path: &str) -> std::result::Result<&'a Value, String> {
|
||||
let mut cur = v;
|
||||
let mut walked = String::new();
|
||||
for seg in path.split('/') {
|
||||
let here = if walked.is_empty() { "the result".to_string() } else { format!("`{walked}`") };
|
||||
cur = match cur {
|
||||
Value::Object(o) => o.get(seg).ok_or_else(|| if walked.is_empty() { String::new() } else { format!(": {here} has no `{seg}`") })?,
|
||||
Value::Array(a) => match seg.parse::<usize>() {
|
||||
Ok(i) => a.get(i).ok_or_else(|| {
|
||||
let items = if a.len() == 1 { "1 item".to_string() } else { format!("{} items", a.len()) };
|
||||
format!(": {here} has {items}")
|
||||
})?,
|
||||
Err(_) => return Err(format!(": {here} is a list, so `{seg}` must be an index (0, 1, …)")),
|
||||
},
|
||||
_ => return Err(format!(": {here} is {}", kind(cur))),
|
||||
};
|
||||
if !walked.is_empty() {
|
||||
walked.push('/');
|
||||
}
|
||||
walked.push_str(seg);
|
||||
}
|
||||
Ok(cur)
|
||||
}
|
||||
|
||||
fn kind(v: &Value) -> &'static str {
|
||||
match v {
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "a boolean",
|
||||
Value::Number(_) => "a number",
|
||||
Value::String(_) => "a string",
|
||||
Value::Array(_) => "a list",
|
||||
Value::Object(_) => "an object",
|
||||
}
|
||||
}
|
||||
@@ -110,3 +110,151 @@ fn a_nested_batch_refers_to_its_own_steps() {
|
||||
let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone();
|
||||
assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own first frame, not the outer one");
|
||||
}
|
||||
|
||||
/// Runs `commands` as a batch and returns the error message, which must be `BadParams`.
|
||||
fn batch_error(s: &mut Session, commands: serde_json::Value) -> String {
|
||||
match s.execute("command.batch", &json!({"commands": commands})) {
|
||||
Err(EngineError::BadParams { msg, .. }) => msg,
|
||||
other => panic!("expected BadParams, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_step_is_referred_to_by_its_name() {
|
||||
let mut s = Session::new();
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"commands": [
|
||||
{"command": "node.create", "as": "card", "params": {"type": "frame", "width": 200, "height": 100}},
|
||||
{"command": "node.create", "as": "swatch", "params": {"type": "rectangle", "parent": {"$ref": "card"}, "width": 20, "height": 20}},
|
||||
{"command": "node.set", "params": {"ids": [{"$ref": "swatch", "field": "id"}], "props": {"name": "Swatch"}}},
|
||||
{"command": "node.create", "params": {"type": "text", "text": "hi", "parent": {"$step": 0}}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let (frame, rect, text) = (id(&results[0]), id(&results[1]), id(&results[3]));
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.node(rect).unwrap().parent, Some(frame));
|
||||
assert_eq!(doc.node(rect).unwrap().name, "Swatch");
|
||||
assert_eq!(doc.node(text).unwrap().parent, Some(frame), "`$step` and `$ref` mix in one batch");
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), 1, "still one undo step");
|
||||
}
|
||||
|
||||
/// `edit.duplicate` answers `{"ids": [...]}`: a slash path in `field` takes one of them.
|
||||
#[test]
|
||||
fn a_field_path_indexes_into_a_list_in_the_result() {
|
||||
let mut s = Session::new();
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"commands": [
|
||||
{"command": "node.create", "as": "a", "params": {"type": "frame", "name": "A", "width": 10, "height": 10}},
|
||||
{"command": "node.create", "as": "b", "params": {"type": "frame", "name": "B", "width": 10, "height": 10}},
|
||||
{"command": "edit.duplicate", "as": "copies", "params": {"ids": [{"$ref": "a"}, {"$ref": "b"}]}},
|
||||
{"command": "node.set", "params": {"ids": [{"$ref": "copies", "field": "ids/1"}], "props": {"name": "B copy"}}},
|
||||
{"command": "node.set", "params": {"ids": [{"$step": 2, "field": "/ids/0"}], "props": {"name": "A copy"}}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let copies = results[2]["ids"].as_array().unwrap().clone();
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.node(crate::params::id_of(&copies[0]).unwrap()).unwrap().name, "A copy", "a leading `/` works too");
|
||||
assert_eq!(doc.node(crate::params::id_of(&copies[1]).unwrap()).unwrap().name, "B copy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_path_walks_nested_objects_and_takes_whole_values() {
|
||||
let mut scope = crate::BatchScope::default();
|
||||
scope.push(json!({"ids": [7, 8], "bounds": {"x": 1.5, "corners": [{"r": 4}]}, "a/b": "slash key"}));
|
||||
let resolved = scope
|
||||
.resolve(&json!({
|
||||
"x": {"$step": 0, "field": "bounds/x"},
|
||||
"r": {"$step": 0, "field": "bounds/corners/0/r"},
|
||||
"all": {"$step": 0, "field": "ids"},
|
||||
"key": {"$step": 0, "field": "a/b"},
|
||||
"pointer": {"$step": 0, "field": "/ids"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(resolved, json!({"x": 1.5, "r": 4, "all": [7, 8], "key": "slash key", "pointer": [7, 8]}), "a key holding `/` is still read as a key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_names_and_paths_fail_and_roll_back() {
|
||||
let mut s = Session::new();
|
||||
let before = s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len();
|
||||
let frame = |name: &str| json!({"command": "node.create", "as": name, "params": {"type": "frame", "width": 10, "height": 10}});
|
||||
let dup = json!({"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}});
|
||||
let set = |r: serde_json::Value| json!({"command": "node.set", "params": {"ids": [r], "props": {"name": "x"}}});
|
||||
let later = json!({"command": "node.create", "as": "later", "params": {"type": "frame", "width": 10, "height": 10}});
|
||||
for (commands, needle) in [
|
||||
(json!([frame("card"), frame("card")]), "step 1: `as` \"card\" is already step 0's name"),
|
||||
(json!([frame("card"), set(json!({"$ref": "nope"}))]), "step 1: `$ref` \"nope\" names no step (\"card\" is step 0)"),
|
||||
(json!([{"command": "node.create", "params": {"type": "frame"}}, set(json!({"$ref": "nope"}))]), "no step has an `as` name"),
|
||||
(json!([frame("card"), set(json!({"$ref": "later"})), later]), "step 1: `$ref` \"later\" is step 2, not an earlier step"),
|
||||
(
|
||||
json!([frame("card"), {"command": "node.set", "as": "self", "params": {"ids": [{"$ref": "self"}], "props": {}}}]),
|
||||
"is step 1, not an earlier step",
|
||||
),
|
||||
(json!([frame("card"), set(json!({"$ref": 0}))]), "`$ref` must be a step name"),
|
||||
(json!([frame("card"), set(json!({"$ref": "card", "id": 3}))]), "a `$ref` reference takes only `field`, not `id`"),
|
||||
(json!([frame("card"), set(json!({"$ref": "card", "$step": 0}))]), "a `$step` reference takes only `field`, not `$ref`"),
|
||||
(json!([frame("card"), {"command": "node.create", "as": "", "params": {}}]), "step 1: `as` must be a name"),
|
||||
(json!([frame("card"), {"command": "node.create", "as": 3, "params": {}}]), "step 1: `as` must be a name"),
|
||||
(
|
||||
json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/1"}))]),
|
||||
"step 2: step 1 (\"copy\")'s result has no `ids/1`: `ids` has 1 item",
|
||||
),
|
||||
(json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/first"}))]), "`ids` is a list, so `first` must be an index"),
|
||||
(json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/0/x"}))]), "has no `ids/0/x`: `ids/0` is a number"),
|
||||
(json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "map/0"}))]), "step 1 (\"copy\")'s result has no `map/0`"),
|
||||
(json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids.0"}))]), "has no `ids.0` (paths use `/`, as in `ids/0`)"),
|
||||
(json!([frame("card"), set(json!({"$step": 0, "field": "id/0"}))]), "has no `id/0`: `id` is a number"),
|
||||
] {
|
||||
let msg = batch_error(&mut s, commands);
|
||||
assert!(msg.contains(needle), "{msg:?} should contain {needle:?}");
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), before, "rolled back");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_that_looks_like_a_named_reference_is_left_alone() {
|
||||
let mut s = Session::new();
|
||||
let text = "{\"$ref\": \"card\"} $ref";
|
||||
let results = s.execute("command.batch", &json!({"commands": [{"command": "node.create", "params": {"type": "text", "text": text}}]})).unwrap();
|
||||
let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone();
|
||||
assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == text));
|
||||
}
|
||||
|
||||
/// A nested batch has its own names: it can't see the outer batch's, may reuse them, and the
|
||||
/// outer batch can't see its.
|
||||
#[test]
|
||||
fn a_nested_batch_has_its_own_names() {
|
||||
let mut s = Session::new();
|
||||
let frame = |name: &str| json!({"command": "node.create", "as": "frame", "params": {"type": "frame", "name": name, "width": 100, "height": 100}});
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"commands": [
|
||||
frame("Outer"),
|
||||
{"command": "command.batch", "as": "inner", "params": {"commands": [
|
||||
frame("Inner"),
|
||||
{"command": "node.create", "params": {"type": "rectangle", "parent": {"$ref": "frame"}, "width": 10, "height": 10}}
|
||||
]}},
|
||||
{"command": "node.set", "params": {"ids": [{"$ref": "inner", "field": "1/id"}], "props": {"name": "In inner"}}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let inner = &results[1];
|
||||
let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone();
|
||||
assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own frame, not the outer one");
|
||||
assert_eq!(rect.name, "In inner", "a path indexes into a nested batch's result list");
|
||||
|
||||
let msg = batch_error(
|
||||
&mut s,
|
||||
json!([frame("Outer"), {"command": "command.batch", "params": {"commands": [
|
||||
{"command": "node.create", "params": {"type": "rectangle", "parent": {"$ref": "frame"}, "width": 10, "height": 10}}
|
||||
]}}]),
|
||||
);
|
||||
assert!(msg.contains("`$ref` \"frame\" names no step"), "the outer names aren't visible inside: {msg}");
|
||||
}
|
||||
|
||||
@@ -495,9 +495,15 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
|
||||
t.paragraph_spacing *= k;
|
||||
t.paragraph_indent *= k;
|
||||
t.list_spacing *= k;
|
||||
if let Some(tp) = &mut t.path {
|
||||
tp.scale(k, k);
|
||||
}
|
||||
}
|
||||
if let NodeKind::Vector { network } = &mut n.kind {
|
||||
network.scale(k, k);
|
||||
for v in &mut network.vertices {
|
||||
v.radius *= k;
|
||||
}
|
||||
}
|
||||
n.width *= k;
|
||||
n.height *= k;
|
||||
|
||||
@@ -8,7 +8,7 @@ use serde_json::{Map, Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
use crate::{BatchStep, EngineError, Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -342,15 +342,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("command.batch", "Run several commands", "{label?, commands: [{command, params}]} (one undo step)", false, |s, p| {
|
||||
spec!("command.batch", "Run several commands", "{label?, commands: [{command, params, as?}]} (one undo step)", false, |s, p| {
|
||||
let label = string(p, "label").unwrap_or_else(|| "Batch".into());
|
||||
let cmds: Vec<(String, Value)> = match get(p, "commands") {
|
||||
let cmds: Vec<BatchStep> = match get(p, "commands") {
|
||||
Some(Value::Array(a)) => {
|
||||
let mut out = Vec::new();
|
||||
for (i, c) in a.iter().enumerate() {
|
||||
let cmd =
|
||||
c.get("command").and_then(Value::as_str).ok_or_else(|| bad("command.batch", format!("step {i} has no `command`")))?;
|
||||
out.push((cmd.to_string(), c.get("params").cloned().unwrap_or(json!({}))));
|
||||
let params = c.get("params").cloned().unwrap_or(json!({}));
|
||||
out.push(BatchStep { name: crate::batch::step_name(i, c)?, ..BatchStep::new(cmd, params) });
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -541,6 +542,7 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<(
|
||||
s.clipboard.nodes = nodes;
|
||||
s.clipboard.images = images;
|
||||
s.clipboard.bounds = bounds;
|
||||
s.clipboard.layers_generation = s.clipboard.layers_generation.wrapping_add(1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -600,6 +602,10 @@ pub fn design_clipboard_buffer(html: &str) -> Option<Vec<u8>> {
|
||||
figcraft_format::fig::is_fig(&bytes).then_some(bytes)
|
||||
}
|
||||
|
||||
/// Paste design layers from clipboard HTML or base64 `data`: decode and reject missing,
|
||||
/// invalid or oversized data, load a temporary `.fig`, take its top-level page layers,
|
||||
/// store them as a foreign-file clipboard source with a bumped layers generation, then
|
||||
/// delegate to `paste` and append import warnings.
|
||||
fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "edit.pasteDesignLayers";
|
||||
let bytes = match (string(p, "html"), string(p, "data")) {
|
||||
@@ -616,8 +622,18 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (nodes, images, bounds) = copied(&doc, &tops);
|
||||
let props = s.clipboard.props.take();
|
||||
// No open file has id 0. So paste treats this copy as a copy from a different file.
|
||||
s.clipboard =
|
||||
crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() };
|
||||
let layers_generation = s.clipboard.layers_generation.wrapping_add(1);
|
||||
s.clipboard = crate::Clipboard {
|
||||
nodes,
|
||||
images,
|
||||
props,
|
||||
bounds,
|
||||
source_doc: 0,
|
||||
source: Some(doc),
|
||||
source_path: None,
|
||||
copied_components: BTreeSet::new(),
|
||||
layers_generation,
|
||||
};
|
||||
let mut out = paste(s, p)?;
|
||||
if !other.issues.is_empty() {
|
||||
out["warnings"] = json!(other.issues);
|
||||
|
||||
+102
-15
@@ -63,10 +63,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.saveAs",
|
||||
"Save as",
|
||||
"{path} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
|
||||
"{path | data: true (no path: returns the file as base64 `data` with its `name`, for a download)} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
|
||||
false,
|
||||
save_as
|
||||
),
|
||||
spec!(
|
||||
"file.markSaved",
|
||||
"Mark saved",
|
||||
"{} clears the unsaved changes of the active tab's file and its branch tabs: run it once the file from `file.saveAs {data: true}` was delivered (a refused download keeps them)",
|
||||
false,
|
||||
mark_saved
|
||||
),
|
||||
spec!(
|
||||
"file.export",
|
||||
"Export",
|
||||
@@ -77,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.exportLayers",
|
||||
"Export layers",
|
||||
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings)}",
|
||||
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings), data? (zip)}",
|
||||
false,
|
||||
export_layers
|
||||
),
|
||||
@@ -855,37 +862,59 @@ fn color_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool {
|
||||
let path =
|
||||
string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None });
|
||||
path.is_some_and(|path| ext(&path) != "fig")
|
||||
path.is_some_and(|path| ext(&path) != "fig") || (cmd == "file.saveAs" && wants_data(p))
|
||||
}
|
||||
|
||||
/// `file.saveAs` asked for the file's bytes instead of a path to write (the browser's download).
|
||||
fn wants_data(p: &Value) -> bool {
|
||||
p.get("data").and_then(Value::as_bool) == Some(true)
|
||||
}
|
||||
|
||||
/// Save the file of the active tab (the main file for a branch tab, with its branches), or export
|
||||
/// the active document as an experimental `.fig` copy without changing its native save state.
|
||||
fn save_to(s: &mut Session, path: String) -> Result<Value> {
|
||||
if ext(&path) == "fig" {
|
||||
/// Without a path the file comes back as base64 `data` for the caller to deliver (#969).
|
||||
fn save_to(s: &mut Session, path: Option<String>) -> Result<Value> {
|
||||
if let Some(path) = path.as_deref().filter(|p| ext(p) == "fig") {
|
||||
return export_fig(s, &json!({"path": path}));
|
||||
}
|
||||
let i = crate::branches::file_tab(s);
|
||||
let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?;
|
||||
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);
|
||||
if let Some(path) = path.as_deref()
|
||||
&& follows_path
|
||||
&& ds.path.as_deref() != Some(path)
|
||||
{
|
||||
Arc::make_mut(&mut snapshot.doc).name = file_stem(path);
|
||||
}
|
||||
let bytes = snapshot.encode()?;
|
||||
let Some(path) = path else {
|
||||
// The download can still be refused: the caller marks the file saved once it was delivered.
|
||||
let name = format!("{}.figcraft", ds.doc.name);
|
||||
return Ok(json!({"name": name, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)}));
|
||||
};
|
||||
write(&path, &bytes)?;
|
||||
ds.doc = snapshot.doc;
|
||||
ds.path = Some(path.clone());
|
||||
ds.mark_saved();
|
||||
ds.revision += 1;
|
||||
let uid = ds.uid;
|
||||
branch_tabs_saved(s, uid);
|
||||
mark_saved(s, &json!({}))?;
|
||||
remember(s, &path);
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
/// The file of the active tab (with its branch tabs) now matches what was written or delivered.
|
||||
fn mark_saved(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let i = crate::branches::file_tab(s);
|
||||
let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?;
|
||||
ds.mark_saved();
|
||||
ds.revision += 1;
|
||||
let uid = ds.uid;
|
||||
branch_tabs_saved(s, uid);
|
||||
Ok(json!({"saved": true}))
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(path) = string(p, "path") {
|
||||
return save_to(s, path);
|
||||
return save_to(s, Some(path));
|
||||
}
|
||||
match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() {
|
||||
// Earlier builds wrote native data under a .fig name: never overwrite that file with an export.
|
||||
@@ -893,7 +922,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.request_ui("dialog.saveAs", json!({}));
|
||||
Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into()))
|
||||
}
|
||||
Some(path) => save_to(s, path),
|
||||
Some(path) => save_to(s, Some(path)),
|
||||
None => {
|
||||
s.request_ui("dialog.saveAs", json!({}));
|
||||
Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into()))
|
||||
@@ -907,8 +936,9 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ext(&path).is_empty() {
|
||||
path.push_str(".figcraft");
|
||||
}
|
||||
save_to(s, path)
|
||||
save_to(s, Some(path))
|
||||
}
|
||||
None if wants_data(p) => save_to(s, None),
|
||||
None => {
|
||||
s.request_ui("dialog.saveAs", json!({}));
|
||||
Err(EngineError::Disabled("file.saveAs".into(), "pass `path`".into()))
|
||||
@@ -1159,6 +1189,20 @@ fn size_param(cmd: &str, p: &Value) -> Result<Option<ExportSize>> {
|
||||
|
||||
/// Most files one `file.exportLayers` writes.
|
||||
const MAX_EXPORT_FILES: usize = 1000;
|
||||
/// Most bytes the files of one zip export add up to: the files, the archive and its base64 copy
|
||||
/// are all in memory at once (in the browser too).
|
||||
const MAX_ZIP_BYTES: usize = 256 << 20;
|
||||
|
||||
/// Keep `bytes` as zip member `name`, unless the members would pass `limit` bytes together.
|
||||
fn add_member<'a>(members: &mut Vec<(&'a str, Vec<u8>)>, name: &'a str, bytes: Vec<u8>, limit: usize) -> Result<()> {
|
||||
let total = members.iter().map(|(_, b)| b.len()).sum::<usize>().saturating_add(bytes.len());
|
||||
if total > limit {
|
||||
let mb = limit >> 20;
|
||||
return Err(bad("file.exportLayers", format!("the files would pass {mb} MB as one zip: export fewer layers at a time, or to a folder")));
|
||||
}
|
||||
members.push((name, bytes));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One file of a layer export: the layer, its setting and the file name.
|
||||
pub struct ExportJob {
|
||||
@@ -1230,6 +1274,9 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Disabled(cmd.into(), "no export settings (add one with export.add)".into()));
|
||||
}
|
||||
let dir = string(p, "dir");
|
||||
// The browser saves one download at a time: several files go as one zip (#969).
|
||||
let zip = dir.is_none() && boolean(p, "zip").unwrap_or(false);
|
||||
let mut members = Vec::new();
|
||||
// Print options passed here stand in for every PDF setting's own.
|
||||
let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?;
|
||||
let mut files = Vec::with_capacity(jobs.len());
|
||||
@@ -1249,12 +1296,18 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
write(&path, &bytes)?;
|
||||
f["path"] = json!(path);
|
||||
}
|
||||
None if zip => add_member(&mut members, job.file.as_str(), bytes, MAX_ZIP_BYTES)?,
|
||||
None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)),
|
||||
}
|
||||
files.push(f);
|
||||
}
|
||||
let with_settings: std::collections::BTreeSet<NodeId> = jobs.iter().map(|j| j.id).collect();
|
||||
Ok(json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())}))
|
||||
let mut r = json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())});
|
||||
if zip {
|
||||
let archive = figcraft_format::zip_files(members.iter().map(|(n, b)| (*n, b.as_slice()))).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
r["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&archive));
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// A PDF with one page per group of layers (an sRGB document), titled after the file; `area`
|
||||
@@ -1601,6 +1654,25 @@ mod rest_import_tests {
|
||||
assert_eq!(s.docs.len(), open, "a refused open adds nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_data_hands_back_the_file_and_marks_it_saved_once_delivered() {
|
||||
// The browser has no paths: Save as returns the file for a download (#969).
|
||||
let mut s = Session::new();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
assert!(s.execute("file.saveAs", &json!({})).is_err(), "no path and no data: still asks for a path");
|
||||
let recent = s.prefs.recent_files.clone();
|
||||
let r = s.execute("file.saveAs", &json!({"data": true})).unwrap();
|
||||
assert_eq!(r["name"], "Untitled.figcraft");
|
||||
assert!(s.ds().unwrap().is_dirty(), "not delivered yet: a refused download must not lose the edits");
|
||||
s.execute("file.markSaved", &json!({})).unwrap();
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
assert_eq!(s.ds().unwrap().path, None, "still no path to save over");
|
||||
assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember");
|
||||
let mut t = Session::new();
|
||||
t.execute("file.open", &json!({"data": r["data"], "name": r["name"]})).unwrap();
|
||||
assert!(t.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle)), "the bytes reopen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_and_import_rest_json() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id()));
|
||||
@@ -1768,3 +1840,18 @@ mod staging_write_tests {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod zip_cap_tests {
|
||||
use super::add_member;
|
||||
|
||||
#[test]
|
||||
fn zip_members_stop_at_the_limit_and_keep_what_fit() {
|
||||
let mut members = Vec::new();
|
||||
add_member(&mut members, "a.png", vec![0; 6], 10).unwrap();
|
||||
add_member(&mut members, "b.png", vec![0; 4], 10).unwrap();
|
||||
let err = add_member(&mut members, "c.png", vec![0; 1], 10).unwrap_err().to_string();
|
||||
assert!(err.contains("as one zip"), "{err}");
|
||||
assert_eq!(members.iter().map(|(n, _)| *n).collect::<Vec<_>>(), ["a.png", "b.png"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"variable.export",
|
||||
"Export variables",
|
||||
"{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-<collection>=\"<mode>\"]; numbers in px unless layers or scopes use them as opacity or font weight), path? (write it there)} → {text}, or {path} when written",
|
||||
"{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-<collection>=\"<mode>\"]; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file), path? (write it there)} → {text}, or {path} when written",
|
||||
false,
|
||||
export
|
||||
),
|
||||
@@ -46,8 +46,9 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = match string(p, "format").as_deref().unwrap_or("json") {
|
||||
"json" => serde_json::to_string_pretty(&figcraft_format::rest::export_variables(&doc.variables, &colls))
|
||||
.map_err(|e| EngineError::Other(e.to_string()))?,
|
||||
"css" => css(doc, &colls),
|
||||
_ => return Err(bad(cmd, "format: json|css")),
|
||||
"css" => css(doc, &colls, false),
|
||||
"tailwind" => css(doc, &colls, true),
|
||||
_ => return Err(bad(cmd, "format: json|css|tailwind")),
|
||||
};
|
||||
match string(p, "path") {
|
||||
Some(path) => {
|
||||
@@ -66,16 +67,25 @@ fn css_safe(text: &str) -> String {
|
||||
/// Each collection's variables as custom properties (named as generated code names them): the
|
||||
/// default mode's values on `:root`, every other mode's on `[data-<collection>="<mode>"]`;
|
||||
/// aliases are `var(--name)`.
|
||||
fn css(doc: &Document, colls: &[&Collection]) -> String {
|
||||
fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String {
|
||||
let vars = &doc.variables;
|
||||
// Tailwind theme names, unique per file and the same ones page code reads.
|
||||
let theme = crate::tailwind::Theme::new(doc);
|
||||
let name = |v: &Variable| if tailwind { theme.name(v) } else { css_var_of(v) };
|
||||
// Lengths need a unit where CSS reads them (`border-radius: var(--radius)`); opacity and
|
||||
// font weight don't.
|
||||
let roles = crate::token_roles::Roles::new(doc);
|
||||
let mut out = String::new();
|
||||
let mut out = if tailwind { "@import \"tailwindcss\";\n\n".to_string() } else { String::new() };
|
||||
for c in colls {
|
||||
let attr = css_var_name(&c.name);
|
||||
for (i, m) in c.modes.iter().enumerate() {
|
||||
let sel = if i == 0 { ":root".to_string() } else { format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)) };
|
||||
let sel = match i {
|
||||
// `static`: Tailwind otherwise drops theme variables no utility uses, and pages also
|
||||
// read them through `var()` and `(--name)` utilities.
|
||||
0 if tailwind => "@theme static".to_string(),
|
||||
0 => ":root".to_string(),
|
||||
_ => format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)),
|
||||
};
|
||||
out.push_str(&format!("/* {} · {} */\n{sel} {{\n", css_safe(&c.name), css_safe(&m.name)));
|
||||
for v in vars.variables.iter().filter(|v| v.collection == c.id) {
|
||||
let value = match v.values.get(&m.id) {
|
||||
@@ -85,12 +95,12 @@ fn css(doc: &Document, colls: &[&Collection]) -> String {
|
||||
Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)),
|
||||
Some(VariableValue::Boolean(b)) => b.to_string(),
|
||||
Some(VariableValue::Alias(a)) => match vars.get(a) {
|
||||
Some(t) => format!("var({})", css_var_of(t)),
|
||||
Some(t) => format!("var({})", name(t)),
|
||||
None => continue,
|
||||
},
|
||||
None => continue,
|
||||
};
|
||||
out.push_str(&format!(" {}: {value};\n", css_var_of(v)));
|
||||
out.push_str(&format!(" {}: {value};\n", name(v)));
|
||||
}
|
||||
out.push_str("}\n");
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![spec!(
|
||||
"inspect.code",
|
||||
"Code for layer",
|
||||
"{id?, language?: css|html|react|leptos|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
|
||||
"{id?, language?: css|html|react|leptos|tailwind|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
|
||||
false,
|
||||
|s, p| {
|
||||
let cmd = "inspect.code";
|
||||
@@ -40,7 +40,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return Err(bad(cmd, "no such layer"));
|
||||
}
|
||||
let connected = crate::code_connect::link(doc, id, lang).is_some();
|
||||
let code = code_for(doc, id, lang);
|
||||
let mobile = match get(p, "mobile") {
|
||||
None | Some(serde_json::Value::Null) => None,
|
||||
Some(v) => Some(id_of(v).filter(|m| doc.contains(*m)).ok_or_else(|| bad(cmd, "mobile: the id of a mobile frame"))?),
|
||||
};
|
||||
let code = match (lang, mobile) {
|
||||
(CodeLang::Tailwind, Some(m)) => crate::tailwind::code(doc, id, Some(m)),
|
||||
_ => code_for(doc, id, lang),
|
||||
};
|
||||
let code = match rem {
|
||||
Some(root) if lang.is_web() => px_to_rem(&code, root),
|
||||
_ => code,
|
||||
@@ -61,18 +68,22 @@ pub enum CodeLang {
|
||||
React,
|
||||
/// A Leptos component (`view!` markup) with its stylesheet.
|
||||
Leptos,
|
||||
/// A React function component styled with Tailwind CSS v4 utilities (no stylesheet).
|
||||
Tailwind,
|
||||
SwiftUi,
|
||||
Compose,
|
||||
}
|
||||
|
||||
impl CodeLang {
|
||||
pub const ALL: [CodeLang; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose];
|
||||
pub const ALL: [CodeLang; 7] =
|
||||
[CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::Tailwind, CodeLang::SwiftUi, CodeLang::Compose];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
CodeLang::Css => "css",
|
||||
CodeLang::Html => "html",
|
||||
CodeLang::React => "react",
|
||||
CodeLang::Leptos => "leptos",
|
||||
CodeLang::Tailwind => "tailwind",
|
||||
CodeLang::SwiftUi => "swiftui",
|
||||
CodeLang::Compose => "compose",
|
||||
}
|
||||
@@ -83,11 +94,13 @@ impl CodeLang {
|
||||
CodeLang::Html => "HTML + CSS",
|
||||
CodeLang::React => "React",
|
||||
CodeLang::Leptos => "Leptos",
|
||||
CodeLang::Tailwind => "React + Tailwind",
|
||||
CodeLang::SwiftUi => "SwiftUI",
|
||||
CodeLang::Compose => "Compose",
|
||||
}
|
||||
}
|
||||
/// Languages whose lengths are CSS pixels (they can be shown in rem).
|
||||
/// Languages whose lengths are CSS pixels that can be shown in rem (not Tailwind: its lengths
|
||||
/// are utility classes).
|
||||
pub fn is_web(self) -> bool {
|
||||
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos)
|
||||
}
|
||||
@@ -199,6 +212,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
match lang {
|
||||
CodeLang::Css => css(doc, id),
|
||||
CodeLang::Html => html(doc, id),
|
||||
CodeLang::Tailwind => crate::tailwind::code(doc, id, None),
|
||||
CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang),
|
||||
}
|
||||
}
|
||||
@@ -321,25 +335,32 @@ pub(crate) fn indent(depth: usize) -> String {
|
||||
|
||||
/// The CSS custom properties generated code uses: name → fallback value, in first-use order.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Tokens(pub Vec<(String, String)>);
|
||||
pub(crate) struct Tokens {
|
||||
vars: Vec<(String, String)>,
|
||||
/// Name variables as Tailwind theme variables (`--color-accent`), not by their CSS name.
|
||||
pub tailwind: bool,
|
||||
/// The file's theme names (computed on first use).
|
||||
theme: Option<crate::tailwind::Theme>,
|
||||
}
|
||||
|
||||
impl Tokens {
|
||||
/// `var(--name, value)`, remembering the variable.
|
||||
fn var(&mut self, variable: &Variable, value: String) -> String {
|
||||
let name = css_var_of(variable);
|
||||
fn var(&mut self, doc: &Document, variable: &Variable, value: String) -> String {
|
||||
let name =
|
||||
if self.tailwind { self.theme.get_or_insert_with(|| crate::tailwind::Theme::new(doc)).name(variable) } else { css_var_of(variable) };
|
||||
let out = format!("var({name}, {value})");
|
||||
if !self.0.iter().any(|(n, _)| *n == name) {
|
||||
self.0.push((name, value));
|
||||
if !self.vars.iter().any(|(n, _)| *n == name) {
|
||||
self.vars.push((name, value));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A `:root` rule declaring the variables (empty without any).
|
||||
pub fn root_rule(&self) -> String {
|
||||
if self.0.is_empty() {
|
||||
if self.vars.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let decls: String = self.0.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect();
|
||||
let decls: String = self.vars.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect();
|
||||
format!(":root {{\n{decls}}}\n")
|
||||
}
|
||||
}
|
||||
@@ -370,7 +391,7 @@ pub fn css_var_of(v: &Variable) -> String {
|
||||
/// `value`, or a reference to the variable bound to the first of `fields` that has one.
|
||||
fn tok(doc: &Document, n: &Node, fields: &[&str], value: String, t: &mut Tokens) -> String {
|
||||
match fields.iter().find_map(|f| crate::devinspect::bound_variable(doc, n, f)) {
|
||||
Some(v) => t.var(v, value),
|
||||
Some(v) => t.var(doc, v, value),
|
||||
None => value,
|
||||
}
|
||||
}
|
||||
@@ -664,6 +685,14 @@ pub(crate) struct Markup<'c, 'a> {
|
||||
/// Each layer's path in the tree (names with sibling counts), class and declarations, so
|
||||
/// variants can be compared layer by layer.
|
||||
pub rules: Vec<(String, String, String)>,
|
||||
/// Instances as their own (overridden) layers instead of component calls, except those
|
||||
/// connected to code (page code).
|
||||
pub inline: bool,
|
||||
/// Auto layout frames and text as fluid boxes (`width: 100%` up to their width) for pages.
|
||||
pub fluid: bool,
|
||||
/// With `fluid`, other layers at least this share of the root's width are fluid too (a
|
||||
/// divider, a banner image); smaller ones (icons) keep their size. Default: none.
|
||||
pub fluid_leaves: f64,
|
||||
}
|
||||
|
||||
impl<'c, 'a> Markup<'c, 'a> {
|
||||
@@ -678,6 +707,9 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
root_class: None,
|
||||
root_attrs: String::new(),
|
||||
rules: Vec::new(),
|
||||
inline: false,
|
||||
fluid: false,
|
||||
fluid_leaves: f64::INFINITY,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,7 +791,10 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
}
|
||||
}
|
||||
// Nested instances are calls (component code), in a wrapper that places them when needed.
|
||||
if self.code() && !root && matches!(n.kind, NodeKind::Instance { .. }) {
|
||||
// Page code inlines instances, except those connected to code (codeConnect.set): their
|
||||
// snippet, e.g. a design-system `<Button>`.
|
||||
let connected = self.inline && crate::code_connect::link(doc, n.id, CodeLang::React).is_some();
|
||||
if self.code() && (!self.inline || connected) && !root && matches!(n.kind, NodeKind::Instance { .. }) {
|
||||
let at = depth + usize::from(!decls.is_empty());
|
||||
let run = if self.lang == CodeLang::Leptos { ".run()" } else { "" };
|
||||
let inner = match self.ctx.param(n, "mainComponent") {
|
||||
@@ -779,6 +814,11 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
let hugs = n.layout.is_some() || matches!(n.kind, NodeKind::Text(_));
|
||||
let hug = (hugs && n.child_layout.sizing_h == Sizing::Hug, hugs && n.child_layout.sizing_v == Sizing::Hug);
|
||||
let mut own = css_decls(doc, n, &mut self.tokens, hug);
|
||||
let page = self.ctx.doc.node(self.ctx.root).map_or(f64::INFINITY, |r| r.width);
|
||||
if self.fluid && (n.layout.is_some() || matches!(n.kind, NodeKind::Text(_)) || (!root && n.width >= page * self.fluid_leaves)) {
|
||||
// Full width, or sized by the flex row (`flex: 1`): no cap needed.
|
||||
own = fluid(&own, n.width >= page - 0.5 || decls.contains("flex: 1 1 0;"), n.layout.is_some());
|
||||
}
|
||||
if n.layout.is_none() && !n.children.is_empty() && !root && in_flow {
|
||||
own.push_str("position: relative;\n");
|
||||
}
|
||||
@@ -845,6 +885,28 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `decls` for a box that follows the page: a fixed width becomes `width: 100%` capped at that
|
||||
/// width (or just the full width for boxes as wide as the page), and a frame's fixed height a
|
||||
/// minimum, so content can wrap and grow.
|
||||
// ponytail: width-based heuristic; fixed-size leaves (icons, images, shapes) keep their pixels.
|
||||
fn fluid(decls: &str, full: bool, frame: bool) -> String {
|
||||
let mut out = String::with_capacity(decls.len() + 32);
|
||||
for line in decls.lines() {
|
||||
if let Some(w) = line.strip_prefix("width: ") {
|
||||
out.push_str("width: 100%;\n");
|
||||
if !full {
|
||||
out.push_str(&format!("max-width: {w}\n"));
|
||||
}
|
||||
} else if let Some(h) = line.strip_prefix("height: ").filter(|_| frame) {
|
||||
out.push_str(&format!("min-height: {h}\n"));
|
||||
} else {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ SwiftUI
|
||||
|
||||
fn swift_color(c: Color) -> String {
|
||||
|
||||
@@ -246,9 +246,9 @@ fn a_group_of_commands_syncs_as_one_step() {
|
||||
let (f, r) = frame_and_rect(room.server.document());
|
||||
let start = room.server.replica().clone();
|
||||
let cmds = vec![
|
||||
("node.set".to_string(), json!({"id": r, "props": {"name": "A"}})),
|
||||
("node.set".to_string(), json!({"id": f, "props": {"name": "B"}})),
|
||||
("shape.rectangle".to_string(), json!({"x": 0, "y": 0, "width": 5, "height": 5})),
|
||||
crate::BatchStep::new("node.set", json!({"id": r, "props": {"name": "A"}})),
|
||||
crate::BatchStep::new("node.set", json!({"id": f, "props": {"name": "B"}})),
|
||||
crate::BatchStep::new("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5})),
|
||||
];
|
||||
room.s(0).batch("Three", &cmds).unwrap();
|
||||
room.pump();
|
||||
|
||||
@@ -203,6 +203,18 @@ fn text_on_a_path_left_empty_puts_the_path_back_without_an_undo_step() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaling_text_scales_its_path() {
|
||||
let mut s = Session::new();
|
||||
let t = id(s.execute("text.create", &json!({"text": "Around", "x": 0, "y": 0})).unwrap());
|
||||
let v = id(s.execute("vector.create", &json!({"path": "M0 0 C50 -50 150 -50 200 0", "x": 40, "y": 100})).unwrap());
|
||||
s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap();
|
||||
let w0 = figcraft_geom::Shape::bounding_box(&text_path(&s, t).unwrap().path()).width();
|
||||
s.execute("transform.scale", &json!({"ids": [t], "factor": 2.0})).unwrap();
|
||||
let w1 = figcraft_geom::Shape::bounding_box(&text_path(&s, t).unwrap().path()).width();
|
||||
assert!((w1 - w0 * 2.0).abs() < 1.0, "path width {w0} -> {w1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_text_paths_import_as_text_on_a_path() {
|
||||
// SVG text is parsed with Inter standing in for every family (CI has no fonts).
|
||||
|
||||
@@ -64,16 +64,20 @@ pub fn missing(doc: &Document) -> Vec<Missing> {
|
||||
used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect()
|
||||
}
|
||||
|
||||
/// Every string value of variable `id` in any mode, following aliases (at most 32 deep).
|
||||
/// Every string value of variable `id` in any mode, following aliases (each variable once).
|
||||
fn string_values(vars: &Variables, id: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut todo = vec![(id.to_string(), 0)];
|
||||
while let Some((id, depth)) = todo.pop() {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut todo = vec![id.to_string()];
|
||||
while let Some(id) = todo.pop() {
|
||||
if !seen.insert(id.clone()) {
|
||||
continue;
|
||||
}
|
||||
let Some(var) = vars.get(&id) else { continue };
|
||||
for v in var.values.values() {
|
||||
match v {
|
||||
VariableValue::String(s) => out.push(s.clone()),
|
||||
VariableValue::Alias(a) if depth < 32 => todo.push((a.clone(), depth + 1)),
|
||||
VariableValue::Alias(a) => todo.push(a.clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,25 @@ fn blank_families_and_aliases_are_handled() {
|
||||
assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]);
|
||||
}
|
||||
|
||||
/// Aliases in several modes are followed once per variable: a chain that branches in every mode
|
||||
/// would otherwise grow to billions of steps (a file can hold one).
|
||||
#[test]
|
||||
fn branching_alias_chains_are_walked_once_per_variable() {
|
||||
let mut s = Session::new();
|
||||
s.execute("variable.createCollection", &json!({"name": "Type"})).unwrap();
|
||||
for i in 0..32 {
|
||||
s.execute("variable.create", &json!({"collection": "Type", "name": format!("V{i}"), "type": "string", "value": "x"})).unwrap();
|
||||
}
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let ids: Vec<String> = doc.variables.variables.iter().map(|v| v.id.clone()).collect();
|
||||
for (i, var) in doc.variables.variables.iter_mut().enumerate() {
|
||||
let next = ids.get(i + 1).cloned().unwrap_or_else(|| "FigCraft Missing H".into());
|
||||
let leaf = |v: String| if i + 1 < ids.len() { VariableValue::Alias(v) } else { VariableValue::String(v) };
|
||||
var.values = [("m1".to_string(), leaf(next.clone())), ("m2".to_string(), leaf(next))].into();
|
||||
}
|
||||
assert_eq!(string_values(&doc.variables, &ids[0]), vec!["FigCraft Missing H", "FigCraft Missing H"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_an_open_document() {
|
||||
let mut s = Session::empty();
|
||||
|
||||
+41
-59
@@ -13,6 +13,7 @@ pub mod al_keys;
|
||||
pub mod annotations;
|
||||
pub mod arc_handles;
|
||||
pub mod background;
|
||||
pub mod batch;
|
||||
pub mod board;
|
||||
pub mod booleans;
|
||||
pub mod branches;
|
||||
@@ -60,6 +61,7 @@ pub mod snap;
|
||||
pub mod style_vars;
|
||||
pub mod synthetic;
|
||||
pub mod synthetic_ds;
|
||||
pub mod tailwind;
|
||||
pub mod templates;
|
||||
pub mod textedit;
|
||||
pub mod textfit;
|
||||
@@ -69,6 +71,7 @@ pub mod tools;
|
||||
pub mod vectoredit;
|
||||
pub mod widgets;
|
||||
|
||||
pub use batch::{BatchScope, BatchStep};
|
||||
pub use cmd::{CommandSpec, all_commands, find_command};
|
||||
pub use figcraft_collab;
|
||||
pub use figcraft_doc as doc;
|
||||
@@ -120,47 +123,6 @@ pub fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.to_string(), msg: msg.into() }
|
||||
}
|
||||
|
||||
/// Deepest params nesting `resolve_step_refs` walks before giving up.
|
||||
const STEP_REF_MAX_DEPTH: usize = 64;
|
||||
|
||||
/// Replace every `{"$step": n}` / `{"$step": n, "field": "…"}` in `v` with that field (default
|
||||
/// `id`) of `results[n]`, the result of an earlier batch step.
|
||||
fn resolve_step_refs(v: &Value, results: &[Value], step: usize, depth: usize) -> Result<Value> {
|
||||
if depth > STEP_REF_MAX_DEPTH {
|
||||
return Err(bad("command.batch", format!("step {step}: params nest deeper than {STEP_REF_MAX_DEPTH} levels")));
|
||||
}
|
||||
match v {
|
||||
Value::Object(o) if o.contains_key("$step") => {
|
||||
if let Some(k) = o.keys().find(|k| *k != "$step" && *k != "field") {
|
||||
return Err(bad("command.batch", format!("step {step}: a `$step` reference takes only `field`, not `{k}`")));
|
||||
}
|
||||
let n = o
|
||||
.get("$step")
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| bad("command.batch", format!("step {step}: `$step` must be a step index (0, 1, …)")))?;
|
||||
let field = match o.get("field") {
|
||||
None => "id",
|
||||
Some(Value::String(f)) => f.as_str(),
|
||||
Some(_) => return Err(bad("command.batch", format!("step {step}: `field` must be a string"))),
|
||||
};
|
||||
let Some(result) = usize::try_from(n).ok().and_then(|n| results.get(n)) else {
|
||||
let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) };
|
||||
return Err(bad("command.batch", format!("step {step}: `$step` {n} must be an earlier step ({range})")));
|
||||
};
|
||||
result.get(field).cloned().ok_or_else(|| bad("command.batch", format!("step {step}: step {n}'s result has no `{field}`")))
|
||||
}
|
||||
Value::Object(o) => {
|
||||
let mut out = serde_json::Map::with_capacity(o.len());
|
||||
for (k, x) in o {
|
||||
out.insert(k.clone(), resolve_step_refs(x, results, step, depth + 1)?);
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
Value::Array(a) => a.iter().map(|x| resolve_step_refs(x, results, step, depth + 1)).collect::<Result<Vec<_>>>().map(Value::Array),
|
||||
_ => Ok(v.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// One undo step.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HistoryEntry {
|
||||
@@ -329,6 +291,17 @@ pub struct TextEdit {
|
||||
pub anchor: usize,
|
||||
/// The node was created by this edit (deleted if left empty).
|
||||
pub created: bool,
|
||||
/// Identity of this editing session (`textedit::begin` assigns a fresh one).
|
||||
///
|
||||
/// The IME owner is `(document uid, node)`, which stays the same through ordinary edits
|
||||
/// and caret moves; this token changes only when editing is started again, so an IME
|
||||
/// context is never silently inherited by a replaced target that reuses the same node.
|
||||
///
|
||||
/// It is deliberately not serialized: current consumers only inspect a live session. A
|
||||
/// serialized `TextEdit` restored from data would get zero here, so it must not be restored
|
||||
/// into a live editor without minting (or otherwise invalidating) a new session.
|
||||
#[serde(default, skip_serializing)]
|
||||
pub editing_session: u64,
|
||||
/// An IME composition in progress, shown inline at the caret; the document gets it on commit.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preedit: Option<Preedit>,
|
||||
@@ -407,7 +380,8 @@ pub const PREVIEW_READ_ONLY: &str = "you're viewing an earlier version: restore
|
||||
|
||||
/// Commands that first end a version preview: they act on the document itself. Saving does so
|
||||
/// only with a native destination; export copies and pending format choices keep the preview.
|
||||
const ENDS_PREVIEW: &[&str] = &["file.save", "file.saveAs", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"];
|
||||
const ENDS_PREVIEW: &[&str] =
|
||||
&["file.save", "file.saveAs", "file.markSaved", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"];
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
@@ -774,6 +748,10 @@ pub struct Clipboard {
|
||||
pub source_doc: u64,
|
||||
/// Copied component/set roots, excluding cuts and originals discarded by undo (ids are reused).
|
||||
pub copied_components: std::collections::BTreeSet<NodeId>,
|
||||
/// Monotonic generation of the copied layers (contents, images, bounds and source). A UI
|
||||
/// clipboard mirror records it, so a later programmatic copy that never touched the system
|
||||
/// clipboard is not mistaken for the user's own copy just because the text still matches.
|
||||
pub layers_generation: u64,
|
||||
/// The document copied from (pastes into other files follow its mains) and its file path.
|
||||
pub source: Option<Arc<Document>>,
|
||||
pub source_path: Option<String>,
|
||||
@@ -1259,28 +1237,30 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Run several commands as one undo step. A param value `{"$step": n}` is replaced by the `id`
|
||||
/// in the result of the earlier step `n` (`{"$step": n, "field": "…"}` picks another field),
|
||||
/// so a step can target a layer that an earlier step of the same batch created. Any object
|
||||
/// with a `$step` key in a step's params is such a reference, except inside a nested
|
||||
/// `command.batch` step: its references are to its own steps.
|
||||
pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result<Vec<Value>> {
|
||||
/// in the result of the earlier step `n`, and `{"$ref": "name"}` by the `id` in the result of
|
||||
/// the earlier step whose `as` is `name`; either takes a `field`, a key or slash path (`ids/0`)
|
||||
/// into that result. So a step can target a layer that an earlier step of the same batch
|
||||
/// created. Any object with a `$step` or `$ref` key in a step's params is such a reference,
|
||||
/// except inside a nested `command.batch` step: its references are to its own steps.
|
||||
pub fn batch(&mut self, label: &str, steps: &[BatchStep]) -> Result<Vec<Value>> {
|
||||
let mut scope = BatchScope::new(steps)?;
|
||||
self.grouped(label, |s| {
|
||||
let mut results: Vec<Value> = Vec::with_capacity(cmds.len());
|
||||
for (step, (id, p)) in cmds.iter().enumerate() {
|
||||
let v = s.batch_step(&results, step, id, p)?;
|
||||
results.push(v);
|
||||
for step in steps {
|
||||
s.batch_step(&mut scope, &step.command, &step.params)?;
|
||||
}
|
||||
Ok(results)
|
||||
Ok(scope.into_results())
|
||||
})
|
||||
}
|
||||
|
||||
/// One step of a batch: `{"$step": n}` in `params` resolves against `results` (the results of
|
||||
/// the steps before it), then the command runs. [`Session::batch`] runs a list of these as one
|
||||
/// undo step; a caller with its own list — the CLI's script runner — uses this so its steps
|
||||
/// resolve `$step` the same way, inside its own [`Session::grouped`].
|
||||
pub fn batch_step(&mut self, results: &[Value], step: usize, cmd: &str, params: &Value) -> Result<Value> {
|
||||
let p = if cmd == "command.batch" { params.clone() } else { resolve_step_refs(params, results, step, 0)? };
|
||||
self.execute(cmd, &p)
|
||||
/// The next step of a batch: the references in `params` resolve against `scope` (the steps
|
||||
/// before it), the command runs and its result joins `scope`. [`Session::batch`] runs a list
|
||||
/// of these as one undo step; a caller with its own list — the CLI's script runner — uses this
|
||||
/// so its steps resolve references the same way, inside its own [`Session::grouped`].
|
||||
pub fn batch_step(&mut self, scope: &mut BatchScope, cmd: &str, params: &Value) -> Result<()> {
|
||||
let p = if cmd == "command.batch" { params.clone() } else { scope.resolve(params)? };
|
||||
let v = self.execute(cmd, &p)?;
|
||||
scope.push(v);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
|
||||
@@ -1884,6 +1864,8 @@ mod styles_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod svg_import_tests;
|
||||
#[cfg(test)]
|
||||
mod tailwind_tests;
|
||||
#[cfg(test)]
|
||||
mod templates_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -240,3 +240,17 @@ fn the_scale_tool_only_acts_on_handles() {
|
||||
assert_rect(bounds(&s, r), [10.0, 10.0, 110.0, 60.0]);
|
||||
assert!(near(s.doc().unwrap().node(r).unwrap().width, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_node_scales_vector_point_corner_radius() {
|
||||
let mut s = Session::new();
|
||||
let v = id_of(&ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]], "closed": true}))["id"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"id": v, "props": {"cornerRadius": 10}}));
|
||||
ex(&mut s, "transform.scale", json!({"ids": [v], "factor": 2}));
|
||||
let d = s.doc().unwrap();
|
||||
let NodeKind::Vector { network } = &d.node(v).unwrap().kind else { panic!("not a vector") };
|
||||
assert!(!network.vertices.is_empty());
|
||||
for vx in &network.vertices {
|
||||
assert_eq!(vx.radius, 20.0, "corner radius scaled with the layer");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
//! React + Tailwind CSS v4 for a layer tree, ready to drop into a web project: every layer's look
|
||||
//! as utility classes instead of a stylesheet, bound variables as theme utilities (`bg-accent`,
|
||||
//! `rounded-card`) that `variable.export {format: "tailwind"}` declares in an `@theme` block, auto
|
||||
//! layout frames and text that grow with the page (`w-full max-w-[…]`) instead of fixed pixel
|
||||
//! boxes, and instances inlined with their overrides. Given a mobile frame too, the code is mobile
|
||||
//! first: the mobile frame's classes, and the desktop frame's where they differ behind `lg:`.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use figcraft_doc::{Document, NodeId, NodeKind, Variable, VariableId, VariableType};
|
||||
|
||||
use crate::codegen::{CodeLang, Ctx, Markup, css_var_name, css_var_of, num};
|
||||
use crate::component_codegen::component_type_name;
|
||||
use crate::token_roles::{NumberRole, Roles};
|
||||
|
||||
/// The breakpoint desktop classes sit behind in responsive code.
|
||||
// ponytail: one breakpoint (lg = 1024px); a tablet frame would need a second pass and `md:`.
|
||||
const BREAKPOINT: &str = "lg";
|
||||
|
||||
/// The roles and Tailwind theme names of a file's variables, computed once per export so code
|
||||
/// and the `@theme` block always agree.
|
||||
#[derive(Default)]
|
||||
pub struct Theme {
|
||||
roles: Roles,
|
||||
names: HashMap<VariableId, String>,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
pub fn new(doc: &Document) -> Self {
|
||||
let vars = &doc.variables.variables;
|
||||
let roles = Roles::new(doc);
|
||||
let mut t = Theme { roles, names: HashMap::new() };
|
||||
// Short names ("accent") where they are unique, the full path ("palette-accent") where two
|
||||
// variables would share one, and a numeric suffix as a last resort.
|
||||
let short: Vec<String> = vars.iter().map(|v| t.candidate(v, false)).collect();
|
||||
// Linear in the number of variables: counts for clashes, a set of names taken and the next
|
||||
// suffix per base, so a file with very many variables can't make this quadratic.
|
||||
let mut count: HashMap<&str, usize> = HashMap::new();
|
||||
for n in &short {
|
||||
*count.entry(n.as_str()).or_default() += 1;
|
||||
}
|
||||
let mut taken: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut next: HashMap<String, usize> = HashMap::new();
|
||||
for (v, s) in vars.iter().zip(&short) {
|
||||
let base = if count.get(s.as_str()).copied().unwrap_or(0) > 1 { t.candidate(v, true) } else { s.clone() };
|
||||
let mut name = base.clone();
|
||||
if taken.contains(&name) {
|
||||
let k = next.entry(base.clone()).or_insert(1);
|
||||
loop {
|
||||
*k += 1;
|
||||
name = format!("{base}-{k}");
|
||||
if !taken.contains(&name) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
taken.insert(name.clone());
|
||||
t.names.insert(v.id.clone(), name);
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// What a number variable measures; a number nothing tells about is a length.
|
||||
pub fn role(&self, v: &Variable) -> NumberRole {
|
||||
self.roles.role(v)
|
||||
}
|
||||
|
||||
/// The theme variable for `v`, unique in the file (see [`Theme::new`]).
|
||||
pub fn name(&self, v: &Variable) -> String {
|
||||
self.names.get(&v.id).cloned().unwrap_or_else(|| self.candidate(v, false))
|
||||
}
|
||||
|
||||
/// `--color-*` for colours, `--radius-*`, `--text-*`, `--spacing-*` for numbers by role,
|
||||
/// `--font-*` for font families, others their CSS name; from the last path segment, or the
|
||||
/// whole path (`full`). A name already in the namespace isn't prefixed twice.
|
||||
fn candidate(&self, v: &Variable, full: bool) -> String {
|
||||
let ns = match v.kind {
|
||||
VariableType::Color => Some("color"),
|
||||
VariableType::Number => match self.role(v) {
|
||||
NumberRole::Radius => Some("radius"),
|
||||
NumberRole::FontSize => Some("text"),
|
||||
NumberRole::Spacing => Some("spacing"),
|
||||
NumberRole::Length | NumberRole::Unitless => None,
|
||||
},
|
||||
VariableType::String if v.scopes.as_ref().is_some_and(|s| s.iter().any(|s| s == "FONT_FAMILY")) => Some("font"),
|
||||
_ => None,
|
||||
};
|
||||
let Some(ns) = ns else {
|
||||
return if full { css_var_name(&v.name) } else { css_var_of(v) };
|
||||
};
|
||||
let source = if full { v.name.as_str() } else { v.name.rsplit('/').next().unwrap_or(&v.name) };
|
||||
let slug = css_var_name(source);
|
||||
let slug = slug.trim_start_matches('-');
|
||||
let slug = [format!("{ns}s-"), format!("{ns}-")].iter().find_map(|p| slug.strip_prefix(p.as_str())).unwrap_or(slug);
|
||||
// A variable named after its namespace ("radius", "colors") is the namespace's base value.
|
||||
let slug = if slug == ns || slug == format!("{ns}s") { "base" } else { slug };
|
||||
format!("--{ns}-{slug}")
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ declarations → classes
|
||||
|
||||
/// An arbitrary value: spaces become underscores (Tailwind's escape), underscores are escaped.
|
||||
fn arb(v: &str) -> String {
|
||||
format!("[{}]", v.trim().replace('_', "\\_").replace(' ', "_"))
|
||||
}
|
||||
|
||||
/// The custom property a `var(--name, fallback)` value reads, when that one call is the whole
|
||||
/// value (not `var(--a, 4px) 8px`, four corners of which only the first is a variable).
|
||||
fn var_name(v: &str) -> Option<&str> {
|
||||
let inner = v.trim().strip_prefix("var(")?;
|
||||
let mut depth = 1usize;
|
||||
let close = inner.char_indices().find_map(|(i, c)| {
|
||||
match c {
|
||||
'(' => depth += 1,
|
||||
')' => depth -= 1,
|
||||
_ => {}
|
||||
}
|
||||
(depth == 0).then_some(i)
|
||||
})?;
|
||||
if close + 1 != inner.len() {
|
||||
return None;
|
||||
}
|
||||
Some(inner.split([',', ')']).next()?.trim())
|
||||
}
|
||||
|
||||
/// `util` with `value`: a theme utility when the value reads a variable of the namespace
|
||||
/// (`bg-accent`), else the variable in parentheses (`bg-(--x)`, with a type hint for `text-`
|
||||
/// sizes), else an arbitrary value.
|
||||
fn util(util: &str, ns: &str, value: &str, hint: &str) -> String {
|
||||
match var_name(value) {
|
||||
Some(name) => match name.strip_prefix(&format!("--{ns}-")) {
|
||||
Some(key) if !ns.is_empty() => format!("{util}-{key}"),
|
||||
_ => format!("{util}-({hint}{name})"),
|
||||
},
|
||||
None => format!("{util}-{}", arb(value)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A length on the spacing scale (4px steps) when it fits one, else arbitrary.
|
||||
fn spacing(prefix: &str, value: &str) -> String {
|
||||
if var_name(value).is_some() {
|
||||
return util(prefix, "spacing", value, "");
|
||||
}
|
||||
match value.strip_suffix("px").and_then(|n| n.parse::<f64>().ok()) {
|
||||
Some(0.0) => format!("{prefix}-0"),
|
||||
Some(px) if px > 0.0 && px <= 384.0 && (px / 4.0).fract() == 0.0 => format!("{prefix}-{}", num(px / 4.0)),
|
||||
Some(px) if px < 0.0 && (px / 4.0).fract() == 0.0 => format!("-{prefix}-{}", num(-px / 4.0)),
|
||||
_ => format!("{prefix}-{}", arb(value)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The values of a `padding` shorthand (top right bottom left), as utilities.
|
||||
fn padding(value: &str) -> Vec<(String, String)> {
|
||||
let mut parts = Vec::new();
|
||||
let mut depth = 0usize;
|
||||
let mut cur = String::new();
|
||||
for c in value.chars() {
|
||||
match c {
|
||||
'(' => depth += 1,
|
||||
')' => depth = depth.saturating_sub(1),
|
||||
' ' if depth == 0 => {
|
||||
parts.push(std::mem::take(&mut cur));
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
cur.push(c);
|
||||
}
|
||||
parts.push(cur);
|
||||
let [t, r, b, l] = match parts.as_slice() {
|
||||
[a] => [a, a, a, a],
|
||||
[t, r, b, l] => [t, r, b, l],
|
||||
_ => return vec![("padding".into(), format!("p-{}", arb(value)))],
|
||||
};
|
||||
let zero = |v: &str| v == "0px" || v == "0";
|
||||
let mut out = Vec::new();
|
||||
if t == r && r == b && b == l {
|
||||
if !zero(t) {
|
||||
out.push(("padding".into(), spacing("p", t)));
|
||||
}
|
||||
} else if t == b && l == r {
|
||||
for (k, p, v) in [("padding-y", "py", t), ("padding-x", "px", l)] {
|
||||
if !zero(v) {
|
||||
out.push((k.into(), spacing(p, v)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (k, p, v) in [("padding-top", "pt", t), ("padding-right", "pr", r), ("padding-bottom", "pb", b), ("padding-left", "pl", l)] {
|
||||
if !zero(v) {
|
||||
out.push((k.into(), spacing(p, v)));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn font_weight(v: &str) -> String {
|
||||
let named = match v.trim() {
|
||||
"100" => "thin",
|
||||
"200" => "extralight",
|
||||
"300" => "light",
|
||||
"400" => "normal",
|
||||
"500" => "medium",
|
||||
"600" => "semibold",
|
||||
"700" => "bold",
|
||||
"800" => "extrabold",
|
||||
"900" => "black",
|
||||
_ => return format!("font-{}", arb(v)),
|
||||
};
|
||||
format!("font-{named}")
|
||||
}
|
||||
|
||||
/// A border shorthand (`1px solid c`) on `side` ("" or "t", "r", "b", "l") as width + colour.
|
||||
fn border(side: &str, value: &str) -> Vec<(String, String)> {
|
||||
// `<width> solid <colour>`, where the width may be `var(--w, 2px)`: split at the style.
|
||||
let (width, color) = value.split_once(" solid ").unwrap_or((value, ""));
|
||||
let (width, color) = (width.trim(), color.trim());
|
||||
let b = if side.is_empty() { "border".to_string() } else { format!("border-{side}") };
|
||||
let key = format!("border{}", if side.is_empty() { String::new() } else { format!("-{side}") });
|
||||
let w = match width {
|
||||
"1px" => b.clone(),
|
||||
"0px" => format!("{b}-0"),
|
||||
w => match var_name(w) {
|
||||
// `border-(--w)` would read as a colour; the hint makes it a width.
|
||||
Some(name) => format!("{b}-(length:{name})"),
|
||||
None => format!("{b}-{}", arb(w)),
|
||||
},
|
||||
};
|
||||
let mut out = vec![(format!("{key}-width"), w)];
|
||||
if !color.is_empty() {
|
||||
out.push(("border-color".into(), util("border", "color", color, "")));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One CSS declaration as utilities, keyed by the property they set (so responsive code can tell
|
||||
/// which mobile class a desktop class replaces).
|
||||
fn decl(key: &str, value: &str) -> Vec<(String, String)> {
|
||||
let one = |c: String| vec![(key.to_string(), c)];
|
||||
let kw = |c: &str| vec![(key.to_string(), c.to_string())];
|
||||
match (key, value) {
|
||||
("display", "flex") => kw("flex"),
|
||||
("display", "block") => kw("block"),
|
||||
("flex-direction", "column") => kw("flex-col"),
|
||||
("flex-direction", _) => kw("flex-row"),
|
||||
("flex-wrap", "wrap") => kw("flex-wrap"),
|
||||
// Defaults flexbox already has.
|
||||
("justify-content", "flex-start") | ("gap", "0px") => Vec::new(),
|
||||
("justify-content", v) => one(format!("justify-{}", v.trim_start_matches("flex-").trim_start_matches("space-"))),
|
||||
("align-items", v) => one(format!("items-{}", v.trim_start_matches("flex-"))),
|
||||
("align-content", v) => one(format!("content-{}", v.trim_start_matches("flex-").trim_start_matches("space-"))),
|
||||
("align-self", "stretch") => kw("self-stretch"),
|
||||
("flex", "1 1 0") => kw("flex-1"),
|
||||
("position", v) => one(v.to_string()),
|
||||
("left" | "top" | "right" | "bottom", v) => one(spacing(key, v)),
|
||||
("width", "100%") => kw("w-full"),
|
||||
("width", v) => one(spacing("w", v)),
|
||||
("height", v) => one(spacing("h", v)),
|
||||
("max-width", v) => one(format!("max-w-{}", arb(v))),
|
||||
("min-height", v) => one(format!("min-h-{}", arb(v))),
|
||||
("margin-inline", "auto") => kw("mx-auto"),
|
||||
("gap", v) => one(spacing("gap", v)),
|
||||
("padding", v) => padding(v),
|
||||
("border-radius", "50%") => kw("rounded-full"),
|
||||
("border-radius", v) if v.strip_suffix("px").and_then(|n| n.parse::<f64>().ok()).is_some_and(|r| r >= 999.0) => kw("rounded-full"),
|
||||
("border-radius", v) if v.contains(' ') && var_name(v).is_none() => one(format!("rounded-{}", arb(v))),
|
||||
("border-radius", v) => one(util("rounded", "radius", v, "")),
|
||||
("opacity", v) => match v.parse::<f64>() {
|
||||
Ok(o) if ((o * 100.0).round() - o * 100.0).abs() < 1e-9 => one(format!("opacity-{}", num(o * 100.0))),
|
||||
_ => one(util("opacity", "", v, "")),
|
||||
},
|
||||
("background", v) if v.contains("gradient(") => one(format!("bg-{}", arb(v))),
|
||||
// An image shorthand (`url(…) center / cover`) is a whole `background`, not a bg colour.
|
||||
("background", v) if v.starts_with("url(") => one(format!("[background:{}]", arb(v).trim_matches(['[', ']']))),
|
||||
("background", v) => one(util("bg", "color", v, "color:")),
|
||||
("color", v) => one(util("text", "color", v, "color:")),
|
||||
("border", v) => border("", v),
|
||||
("border-top", v) => border("t", v),
|
||||
("border-right", v) => border("r", v),
|
||||
("border-bottom", v) => border("b", v),
|
||||
("border-left", v) => border("l", v),
|
||||
("box-shadow", v) => one(format!("shadow-{}", arb(v))),
|
||||
("filter", v) => one(match v.strip_prefix("blur(").and_then(|r| r.strip_suffix(')')) {
|
||||
Some(r) => format!("blur-{}", arb(r)),
|
||||
None => format!("[filter:{}]", arb(v).trim_matches(['[', ']'])),
|
||||
}),
|
||||
("backdrop-filter", v) => one(match v.strip_prefix("blur(").and_then(|r| r.strip_suffix(')')) {
|
||||
Some(r) => format!("backdrop-blur-{}", arb(r)),
|
||||
None => format!("[backdrop-filter:{}]", arb(v).trim_matches(['[', ']'])),
|
||||
}),
|
||||
("mix-blend-mode", v) => one(format!("mix-blend-{v}")),
|
||||
("transform", v) => match v.strip_prefix("rotate(").and_then(|r| r.strip_suffix(')')) {
|
||||
Some(r) => one(format!("rotate-{}", arb(r))),
|
||||
None => one(format!("[transform:{}]", arb(v).trim_matches(['[', ']']))),
|
||||
},
|
||||
("transform-origin", "top left" | "0 0") => kw("origin-top-left"),
|
||||
// `font-(--x)` would read as a weight; the hint makes an un-namespaced variable a family.
|
||||
("font-family", v) if var_name(v).is_some() => one(util("font", "font", v, "family-name:")),
|
||||
("font-family", v) => one(format!("font-{}", arb(&format!("'{}'", v.trim_matches('"'))))),
|
||||
("font-size", v) => one(util("text", "text", v, "length:")),
|
||||
("font-weight", v) => one(font_weight(v)),
|
||||
("font-style", "italic") => kw("italic"),
|
||||
("line-height", v) => match v.strip_suffix('%').and_then(|p| p.parse::<f64>().ok()) {
|
||||
Some(p) => one(format!("leading-{}", arb(&num(p / 100.0)))),
|
||||
None => one(util("leading", "", v, "")),
|
||||
},
|
||||
("letter-spacing", v) => one(util("tracking", "", v, "")),
|
||||
("text-align", v) => one(format!("text-{v}")),
|
||||
("text-transform", "uppercase") => kw("uppercase"),
|
||||
("text-transform", "lowercase") => kw("lowercase"),
|
||||
("text-transform", "capitalize") => kw("capitalize"),
|
||||
("text-decoration", "underline") => kw("underline"),
|
||||
("text-decoration", "line-through") => kw("line-through"),
|
||||
_ => one(format!("[{key}:{}]", arb(value).trim_matches(['[', ']']))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A rule's declarations (`key: value;` lines) as (property, utility) pairs in order.
|
||||
pub fn classes(decls: &str) -> Vec<(String, String)> {
|
||||
decls
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let (k, v) = l.trim().trim_end_matches(';').split_once(':')?;
|
||||
Some(decl(k.trim(), v.trim()))
|
||||
})
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The utility that undoes a mobile class at the breakpoint when desktop sets nothing for it.
|
||||
fn reset(key: &str) -> Option<String> {
|
||||
// Border widths by side: `border-t-width` → `border-t-0`.
|
||||
if let Some(side) = key.strip_prefix("border").and_then(|k| k.strip_suffix("-width")) {
|
||||
return Some(format!("border{side}-0"));
|
||||
}
|
||||
Some(String::from(match key {
|
||||
"display" => "block",
|
||||
"flex-direction" => "flex-row",
|
||||
"flex-wrap" => "flex-nowrap",
|
||||
"flex" => "flex-initial",
|
||||
"align-self" => "self-auto",
|
||||
"width" => "w-auto",
|
||||
"height" => "h-auto",
|
||||
"max-width" => "max-w-none",
|
||||
"min-height" => "min-h-0",
|
||||
"text-align" => "text-left",
|
||||
"justify-content" => "justify-start",
|
||||
"gap" => "gap-0",
|
||||
"position" => "static",
|
||||
"text-transform" => "normal-case",
|
||||
"opacity" => "opacity-100",
|
||||
"border-radius" => "rounded-none",
|
||||
"box-shadow" => "shadow-none",
|
||||
"font-style" => "not-italic",
|
||||
// The whole background: a mobile image or gradient would survive a colour-only reset.
|
||||
"background" => "bg-none bg-transparent",
|
||||
"border-color" => "border-current",
|
||||
"letter-spacing" => "tracking-normal",
|
||||
"line-height" => "leading-normal",
|
||||
"text-decoration" => "no-underline",
|
||||
"align-items" => "items-stretch",
|
||||
"filter" => "filter-none",
|
||||
"backdrop-filter" => "backdrop-filter-none",
|
||||
"transform" => "rotate-0",
|
||||
"left" | "top" | "right" | "bottom" => return Some(format!("{key}-auto")),
|
||||
"mix-blend-mode" => "mix-blend-normal",
|
||||
_ => return None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The sides (top, right, bottom, left) a padding utility's property sets.
|
||||
fn padding_sides(key: &str) -> [bool; 4] {
|
||||
match key {
|
||||
"padding" => [true; 4],
|
||||
"padding-x" => [false, true, false, true],
|
||||
"padding-y" => [true, false, true, false],
|
||||
"padding-top" => [true, false, false, false],
|
||||
"padding-right" => [false, true, false, false],
|
||||
"padding-bottom" => [false, false, true, false],
|
||||
"padding-left" => [false, false, false, true],
|
||||
_ => [false; 4],
|
||||
}
|
||||
}
|
||||
|
||||
/// Utilities zeroing `sides` (as few as cover them: `py-0`, `px-0`, else per side).
|
||||
fn zero_padding(sides: [bool; 4]) -> Vec<&'static str> {
|
||||
let [t, r, b, l] = sides;
|
||||
let mut out = Vec::new();
|
||||
match (t, b) {
|
||||
(true, true) => out.push("py-0"),
|
||||
(true, false) => out.push("pt-0"),
|
||||
(false, true) => out.push("pb-0"),
|
||||
_ => {}
|
||||
}
|
||||
match (r, l) {
|
||||
(true, true) => out.push("px-0"),
|
||||
(true, false) => out.push("pr-0"),
|
||||
(false, true) => out.push("pl-0"),
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Mobile classes plus, behind the breakpoint, the desktop ones that differ (and resets for what
|
||||
/// desktop leaves unset). A layer missing on mobile is hidden there.
|
||||
pub(crate) fn merge(mobile: Option<&[(String, String)]>, desktop: &[(String, String)]) -> Vec<String> {
|
||||
let hidden = [("display".to_string(), "hidden".to_string())];
|
||||
// A mobile frame's widths are the phone's, not caps: below the breakpoint boxes fill the page.
|
||||
let fluid: Vec<(String, String)>;
|
||||
let mobile = match mobile {
|
||||
Some(m) => {
|
||||
fluid = m.iter().filter(|(k, _)| k != "max-width").cloned().collect();
|
||||
fluid.as_slice()
|
||||
}
|
||||
None => &hidden,
|
||||
};
|
||||
let mut out: Vec<String> = mobile.iter().map(|(_, c)| c.clone()).collect();
|
||||
let bp = |c: &str| format!("{BREAKPOINT}:{c}");
|
||||
for (_, c) in desktop {
|
||||
if !mobile.iter().any(|(_, m)| m == c) {
|
||||
out.push(bp(c));
|
||||
}
|
||||
}
|
||||
for (k, _) in mobile.iter().filter(|(k, _)| !k.starts_with("padding")) {
|
||||
if !desktop.iter().any(|(d, _)| d == k)
|
||||
&& let Some(r) = reset(k)
|
||||
{
|
||||
out.extend(r.split_whitespace().map(bp));
|
||||
}
|
||||
}
|
||||
// Padding utilities overlap (p, px/py, pt…): compare side by side, and zero on desktop the
|
||||
// sides mobile pads and desktop doesn't.
|
||||
let cover = |list: &[(String, String)]| {
|
||||
list.iter().fold([false; 4], |mut acc, (k, _)| {
|
||||
for (a, s) in acc.iter_mut().zip(padding_sides(k)) {
|
||||
*a |= s;
|
||||
}
|
||||
acc
|
||||
})
|
||||
};
|
||||
let (m, d) = (cover(mobile), cover(desktop));
|
||||
let missing = [m[0] && !d[0], m[1] && !d[1], m[2] && !d[2], m[3] && !d[3]];
|
||||
out.extend(zero_padding(missing).into_iter().map(bp));
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// The markup walk for `id` in Tailwind mode: (markup, rules by path).
|
||||
fn walk(doc: &Document, id: NodeId) -> (String, Vec<(String, String, String)>) {
|
||||
let ctx = Ctx::new(doc, id);
|
||||
let mut m = Markup::new(&ctx, CodeLang::React);
|
||||
m.inline = true;
|
||||
m.fluid = true;
|
||||
m.fluid_leaves = 0.5;
|
||||
m.tokens.tailwind = true;
|
||||
m.node(id, None, 2, "");
|
||||
(m.markup, m.rules)
|
||||
}
|
||||
|
||||
/// React + Tailwind for `id` (with `mobile`, mobile first with desktop classes behind `lg:`).
|
||||
pub fn code(doc: &Document, id: NodeId, mobile: Option<NodeId>) -> String {
|
||||
let Some(n) = doc.node(id) else { return String::new() };
|
||||
let (mut markup, rules) = walk(doc, id);
|
||||
let small: Option<BTreeMap<String, Vec<(String, String)>>> =
|
||||
mobile.filter(|m| doc.contains(*m) && *m != id).map(|m| walk(doc, m).1.into_iter().map(|(p, _, d)| (p, classes(&d))).collect());
|
||||
// ponytail: layers only on mobile are listed, not merged in; their place in the tree is ambiguous.
|
||||
let only_mobile: Vec<String> = small
|
||||
.iter()
|
||||
.flat_map(BTreeMap::keys)
|
||||
.filter(|p| !rules.iter().any(|(d, _, _)| d == *p))
|
||||
.map(|p| p.trim_start_matches('/').split('/').map(|seg| seg.rsplit_once('#').map_or(seg, |(n, _)| n)).collect::<Vec<_>>().join(" › "))
|
||||
.take(20)
|
||||
.collect();
|
||||
// Longest class names first, so "card" never rewrites inside "card-2"'s attribute.
|
||||
let mut rules = rules;
|
||||
rules.sort_by_key(|(_, c, _)| std::cmp::Reverse(c.len()));
|
||||
let paths: Vec<String> = rules.iter().map(|(p, _, _)| p.clone()).collect();
|
||||
for (path, class, decls) in &rules {
|
||||
let own = classes(decls);
|
||||
// Layer names may contain "/": the parent is the longest other path this one extends.
|
||||
let parent =
|
||||
paths.iter().filter(|p| p.len() < path.len() && path.starts_with(&format!("{p}/"))).max_by_key(|p| p.len()).map_or("", String::as_str);
|
||||
let list = match &small {
|
||||
// Inside a layer hidden on mobile: desktop's classes as they are.
|
||||
Some(m) if !path.is_empty() && !m.contains_key(parent) => own.into_iter().map(|(_, c)| c).collect(),
|
||||
Some(m) => merge(m.get(path).map(Vec::as_slice), &own),
|
||||
None => own.into_iter().map(|(_, c)| c).collect(),
|
||||
};
|
||||
markup = markup.replace(&format!("className=\"{class}\""), &format!("className=\"{}\"", list.join(" ")));
|
||||
}
|
||||
// Imports of the instances written as their connected code component, once each.
|
||||
let mut imports: Vec<String> = Vec::new();
|
||||
for i in doc.subtree(id).into_iter().take(20_000) {
|
||||
if i != id
|
||||
&& doc.node(i).is_some_and(|k| matches!(k.kind, NodeKind::Instance { .. }))
|
||||
&& let Some(l) = crate::code_connect::link(doc, i, CodeLang::React)
|
||||
{
|
||||
let line = crate::code_connect::import_line(l, CodeLang::React);
|
||||
if !line.is_empty() && !imports.contains(&line) {
|
||||
imports.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
let imports = if imports.is_empty() { String::new() } else { format!("{}\n\n", imports.join("\n")) };
|
||||
let name = component_type_name(&n.name);
|
||||
let mut mode = if small.is_some() { format!(", mobile first; desktop from {BREAKPOINT}") } else { String::new() };
|
||||
if !only_mobile.is_empty() {
|
||||
mode.push_str(&format!(". Only in the mobile frame, add by hand with {BREAKPOINT}:hidden: {}", only_mobile.join(", ")));
|
||||
}
|
||||
let header = comment_safe(&format!("{} as React + Tailwind CSS v4{mode}. Theme: variable.export {{format: \"tailwind\"}}.", n.name));
|
||||
format!("// {header}\n{imports}export function {name}() {{\n return (\n{markup} );\n}}\n")
|
||||
}
|
||||
|
||||
/// `text` for one `//` comment line: every JS line terminator (control characters, U+2028,
|
||||
/// U+2029) becomes a space, so layer names in it can't end the comment and inject code.
|
||||
fn comment_safe(text: &str) -> String {
|
||||
text.chars().take(2_000).map(|c| if c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') { ' ' } else { c }).collect()
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
//! React + Tailwind code for pages: utilities instead of a stylesheet, theme utilities for bound
|
||||
//! variables, fluid boxes, inlined instances, per-side borders, mobile-first responsive merges and
|
||||
//! the Tailwind theme export.
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{ex, idv};
|
||||
use crate::tailwind::{classes, merge};
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::json;
|
||||
|
||||
fn code(s: &mut Session, id: NodeId, extra: serde_json::Value) -> String {
|
||||
let mut p = json!({"id": id, "language": "tailwind"});
|
||||
if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) {
|
||||
p.extend(e.clone());
|
||||
}
|
||||
ex(s, "inspect.code", p)["code"].as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// A 1440 "Page" (vertical auto layout, bg bound to Brand/Accent) holding a 960 "Hero" text and a
|
||||
/// horizontal "Row" of two "Card" frames that fill it. Returns (page, row).
|
||||
fn page(s: &mut Session, name: &str, width: f64, row_dir: &str) -> (NodeId, NodeId) {
|
||||
let p = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": width, "height": 900, "name": name})));
|
||||
ex(s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
|
||||
ex(s, "node.set", json!({"id": p, "props": {"gap": 24, "padding": 32}}));
|
||||
let t = idv(&ex(s, "text.create", json!({"text": "Seu site em 7 dias", "x": 0, "y": 0, "parent": p, "props": {"name": "Hero"}})));
|
||||
ex(s, "node.set", json!({"id": t, "props": {"width": 960, "textAutoResize": "height"}}));
|
||||
let row = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 800, "height": 200, "parent": p, "name": "Row"})));
|
||||
ex(s, "layout.add", json!({"ids": [row], "direction": row_dir}));
|
||||
for _ in 0..2 {
|
||||
let c = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "parent": row, "name": "Card"})));
|
||||
ex(s, "node.set", json!({"id": c, "props": {"sizingH": "fill"}}));
|
||||
}
|
||||
(p, row)
|
||||
}
|
||||
|
||||
fn brand(s: &mut Session) {
|
||||
ex(s, "variable.createCollection", json!({"name": "Brand"}));
|
||||
ex(s, "variable.create", json!({"collection": "Brand", "name": "Brand/Accent", "type": "color", "value": "#3DDC97"}));
|
||||
ex(s, "variable.create", json!({"collection": "Brand", "name": "card", "type": "number", "value": 12}));
|
||||
ex(s, "variable.create", json!({"collection": "Brand", "name": "fade", "type": "number", "value": 0.5}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utilities_replace_the_stylesheet_and_variables_become_theme_utilities() {
|
||||
let mut s = Session::new();
|
||||
brand(&mut s);
|
||||
let (p, row) = page(&mut s, "Landing", 1440.0, "horizontal");
|
||||
ex(&mut s, "variable.bind", json!({"ids": [p], "field": "fill", "variable": "Brand/Accent"}));
|
||||
ex(&mut s, "node.set", json!({"id": row, "props": {"cornerRadius": 12}}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [row], "field": "cornerRadius", "variable": "card"}));
|
||||
let out = code(&mut s, p, json!({}));
|
||||
assert!(out.contains("export function Landing() {"), "{out}");
|
||||
assert!(!out.contains("import") && !out.contains("<style") && !out.contains(".css"), "no stylesheet: {out}");
|
||||
for want in ["flex flex-col", "gap-6", "p-8", "bg-accent", "rounded-card", "flex-1", "w-full"] {
|
||||
assert!(out.contains(want), "{want} in {out}");
|
||||
}
|
||||
// The page is as wide as itself: just full width; the 960 text is capped, not fixed.
|
||||
assert!(out.contains("max-w-[960px]") && !out.contains("w-[1440px]") && !out.contains("max-w-[1440px]"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_side_strokes_are_borders_on_those_sides() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 60, "name": "Nav"})));
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"strokes": ["#262A31"], "strokeWeight": 1, "strokeSides": [0, 0, 1, 0]}}));
|
||||
let css = ex(&mut s, "inspect.code", json!({"id": f, "language": "css"}))["code"].as_str().unwrap_or_default().to_string();
|
||||
assert!(css.contains("border-bottom: 1px solid") && !css.contains("border:"), "{css}");
|
||||
let tw = code(&mut s, f, json!({}));
|
||||
assert!(tw.contains("border-b ") && (tw.contains("border-[#262a31]") || tw.contains("border-[#262A31]")), "{tw}");
|
||||
// Without per-side weights it is one border.
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"strokeSides": null, "strokeWeight": 2}}));
|
||||
let css = ex(&mut s, "inspect.code", json!({"id": f, "language": "css"}))["code"].as_str().unwrap_or_default().to_string();
|
||||
assert!(css.contains("border: 2px solid"), "{css}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instances_are_inlined_with_their_overrides() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Benefit"})));
|
||||
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "vertical"}));
|
||||
ex(&mut s, "text.create", json!({"text": "Original", "x": 0, "y": 0, "parent": f, "props": {"name": "Title"}}));
|
||||
let comp = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
let holder = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 600, "height": 200, "name": "Benefits"})));
|
||||
ex(&mut s, "layout.add", json!({"ids": [holder], "direction": "horizontal"}));
|
||||
let inst = idv(&ex(&mut s, "instance.create", json!({"component": comp, "x": 0, "y": 0, "parent": holder})));
|
||||
let doc = s.doc().unwrap();
|
||||
let title = doc.node(inst).unwrap().children[0];
|
||||
ex(&mut s, "text.set", json!({"id": title, "characters": "Agenda sozinha"}));
|
||||
let out = code(&mut s, holder, json!({}));
|
||||
assert!(out.contains("Agenda sozinha") && !out.contains("<Benefit"), "inlined with override: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mobile_frame_makes_the_code_mobile_first() {
|
||||
let mut s = Session::new();
|
||||
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
|
||||
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
|
||||
// A layer only on desktop is hidden on mobile.
|
||||
ex(&mut s, "text.create", json!({"text": "Só no desktop", "x": 0, "y": 0, "parent": desk, "props": {"name": "Aside"}}));
|
||||
let out = code(&mut s, desk, json!({"mobile": mob}));
|
||||
assert!(out.contains("mobile first"), "{out}");
|
||||
assert!(out.contains("flex flex-col") && out.contains("lg:flex-row"), "row stacks on mobile: {out}");
|
||||
assert!(out.contains("hidden") && out.contains("lg:block"), "desktop-only layer: {out}");
|
||||
assert!(s.execute("inspect.code", &json!({"id": desk, "language": "tailwind", "mobile": 999_999})).is_err(), "a missing mobile frame is refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declarations_map_to_idiomatic_utilities() {
|
||||
let got: Vec<String> = classes(
|
||||
"display: flex;\njustify-content: space-between;\nalign-items: center;\npadding: 8px 16px 8px 16px;\nfont-weight: 600;\nfont-size: 18px;\nline-height: 120%;\nopacity: 0.5;\ncolor: var(--color-muted, #9aa3ae);\nbox-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.25);\nwhite-space: nowrap;\n",
|
||||
)
|
||||
.into_iter()
|
||||
.map(|(_, c)| c)
|
||||
.collect();
|
||||
let want = [
|
||||
"flex",
|
||||
"justify-between",
|
||||
"items-center",
|
||||
"py-2",
|
||||
"px-4",
|
||||
"font-semibold",
|
||||
"text-[18px]",
|
||||
"leading-[1.2]",
|
||||
"opacity-50",
|
||||
"text-muted",
|
||||
"shadow-[0px_4px_12px_0px_rgba(0,_0,_0,_0.25)]",
|
||||
"[white-space:nowrap]",
|
||||
];
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_export_with_units_and_as_a_tailwind_theme() {
|
||||
let mut s = Session::new();
|
||||
brand(&mut s);
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"cornerRadius": 12, "opacity": 0.5}}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "cornerRadius", "variable": "card"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "opacity", "variable": "fade"}));
|
||||
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
|
||||
assert!(css.contains("--card: 12px;") && css.contains("--fade: 0.5;"), "{css}");
|
||||
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_string();
|
||||
for want in ["@import \"tailwindcss\";", "@theme static {", "--color-accent: #3ddc97;", "--radius-card: 12px;", "--fade: 0.5;"] {
|
||||
assert!(tw.to_lowercase().contains(&want.to_lowercase()), "{want} in {tw}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_padding_on_other_sides_is_not_reset_away() {
|
||||
let mut s = Session::new();
|
||||
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
|
||||
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
|
||||
ex(&mut s, "node.set", json!({"id": desk, "props": {"paddingTop": 24, "paddingBottom": 24, "paddingLeft": 64, "paddingRight": 64}}));
|
||||
ex(&mut s, "node.set", json!({"id": mob, "props": {"padding": 24}}));
|
||||
let out = code(&mut s, desk, json!({"mobile": mob}));
|
||||
assert!(out.contains("p-6 ") && out.contains("lg:py-6 lg:px-16"), "{out}");
|
||||
assert!(!out.contains("lg:p-0"), "a reset would undo lg:px-16: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_name_cant_break_out_of_the_header_comment() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "A\rB\u{2028}alert(1)\u{2029}C\nD"})));
|
||||
let out = code(&mut s, f, json!({}));
|
||||
let header = out.lines().next().unwrap_or_default();
|
||||
assert!(header.starts_with("// ") && header.contains("alert(1)"), "{out}");
|
||||
assert!(!out.contains(['\r', '\u{2028}', '\u{2029}']), "{out:?}");
|
||||
let name = out.lines().nth(1).and_then(|l| l.strip_prefix("export function ")).and_then(|l| l.strip_suffix("() {")).unwrap_or_default();
|
||||
assert!(!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'), "an identifier: {name:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slashed_layer_names_and_phone_widths_merge_right() {
|
||||
let mut s = Session::new();
|
||||
let mut frames = Vec::new();
|
||||
for (name, width, dir, sizing) in [("Landing", 1440.0, "horizontal", "hug"), ("Landing mobile", 390.0, "vertical", "fill")] {
|
||||
let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": width, "height": 600, "name": name})));
|
||||
ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
|
||||
let ctas = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 342, "height": 120, "parent": p, "name": "CTAs"})));
|
||||
ex(&mut s, "layout.add", json!({"ids": [ctas], "direction": dir}));
|
||||
let b = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 48, "parent": ctas, "name": "Button/Primary"})));
|
||||
ex(&mut s, "layout.add", json!({"ids": [b], "direction": "horizontal"}));
|
||||
ex(&mut s, "node.set", json!({"id": b, "props": {"sizingH": sizing}}));
|
||||
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": width * 0.8, "height": 1, "parent": p, "props": {"name": "Divider"}}));
|
||||
frames.push(p);
|
||||
}
|
||||
// A layer only the mobile frame has.
|
||||
ex(&mut s, "text.create", json!({"text": "7 dias", "x": 0, "y": 0, "parent": frames[1], "props": {"name": "Tag"}}));
|
||||
let out = code(&mut s, frames[0], json!({"mobile": frames[1]}));
|
||||
let button =
|
||||
out.lines().find(|l| l.contains("lg:") && l.contains("rounded") || l.contains("self-stretch") && l.contains("flex-row")).unwrap_or_default();
|
||||
assert!(button.contains("self-stretch") && button.contains("lg:self-auto"), "the slashed name keeps its mobile classes: {out}");
|
||||
assert!(!out.contains("max-w-[342px] ") || out.contains("lg:max-w-[342px]"), "no phone-width cap below lg: {out}");
|
||||
assert!(out.contains("Only in the mobile frame") && out.contains("Tag"), "{out}");
|
||||
let divider = out.lines().filter(|l| l.contains("<div") && (l.contains("h-[1px]") || l.contains("h-px"))).count();
|
||||
assert!(out.contains("w-full") && divider <= 2, "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_variable_named_after_its_namespace_is_its_base() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "radius", "type": "number", "value": 12}));
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"cornerRadius": 12}}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "cornerRadius", "variable": "radius"}));
|
||||
assert!(code(&mut s, f, json!({})).contains("rounded-base"));
|
||||
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_string();
|
||||
assert!(tw.contains("--radius-base: 12px;") && !tw.contains("--radius-radius"), "{tw}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_only_layer_names_cant_break_out_of_the_header_comment() {
|
||||
let mut s = Session::new();
|
||||
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
|
||||
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
|
||||
ex(&mut s, "text.create", json!({"text": "x", "x": 0, "y": 0, "parent": mob, "props": {"name": "Tag\nalert(1)\u{2028}y"}}));
|
||||
let out = code(&mut s, desk, json!({"mobile": mob}));
|
||||
let header = out.lines().next().unwrap_or_default();
|
||||
assert!(header.starts_with("// ") && header.contains("alert(1)"), "listed inside the comment: {out}");
|
||||
assert!(out.lines().nth(1).is_some_and(|l| l.starts_with("export function ")), "{out}");
|
||||
assert!(!out.contains(['\r', '\u{2028}', '\u{2029}']), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_code_leaves_resets_to_tailwind_preflight() {
|
||||
let mut s = Session::new();
|
||||
let (p, _) = page(&mut s, "Landing", 1440.0, "horizontal");
|
||||
let out = code(&mut s, p, json!({}));
|
||||
for leak in ["box-sizing", "[margin:0]", "text-decoration", "cursor"] {
|
||||
assert!(!out.contains(leak), "{leak} in {out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instances_connected_to_code_are_their_snippet_with_one_import() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 160, "height": 44, "name": "Button"})));
|
||||
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "horizontal"}));
|
||||
let label = idv(&ex(&mut s, "text.create", json!({"text": "Botão", "x": 0, "y": 0, "parent": f, "props": {"name": "Label"}})));
|
||||
let comp = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
ex(&mut s, "component.addProperty", json!({"id": comp, "name": "Label", "type": "text", "layer": label}));
|
||||
ex(
|
||||
&mut s,
|
||||
"codeConnect.set",
|
||||
json!({"id": comp, "language": "react", "importPath": "@/components/ui/button", "name": "Button", "template": "<Button size=\"lg\">{Label}</Button>"}),
|
||||
);
|
||||
let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 1440, "height": 400, "name": "Landing"})));
|
||||
ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
|
||||
for text in ["Agendar diagnóstico", "Ver exemplos"] {
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": comp, "x": 0, "y": 0, "parent": p})));
|
||||
ex(&mut s, "instance.setProperties", json!({"id": i, "properties": {"Label": text}}));
|
||||
}
|
||||
let out = code(&mut s, p, json!({}));
|
||||
assert_eq!(out.matches("import { Button } from \"@/components/ui/button\";").count(), 1, "{out}");
|
||||
assert!(out.contains("<Button size=\"lg\">") && out.contains("Agendar diagnóstico") && out.contains("Ver exemplos"), "{out}");
|
||||
assert!(!out.contains(">Botão<"), "the component's own layers aren't inlined: {out}");
|
||||
// Unconnected instances are still inlined.
|
||||
ex(&mut s, "codeConnect.remove", json!({"id": comp, "language": "react"}));
|
||||
let out = code(&mut s, p, json!({}));
|
||||
assert!(!out.contains("import ") && out.contains(">Agendar diagnóstico<"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_same_named_variables_keep_distinct_theme_names() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Palette/Accent", "type": "color", "value": "#3DDC97"}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Semantic/Accent", "type": "color", "value": "#7C5CFF"}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Muted", "type": "color", "value": "#9AA3AE"}));
|
||||
let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 200, "name": "Page"})));
|
||||
ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
|
||||
let a = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 50, "parent": p, "name": "A"})));
|
||||
let b = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 50, "parent": p, "name": "B"})));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [a], "field": "fill", "variable": "Palette/Accent"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "Semantic/Accent"}));
|
||||
let out = code(&mut s, p, json!({}));
|
||||
assert!(out.contains("bg-palette-accent") && out.contains("bg-semantic-accent"), "{out}");
|
||||
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_lowercase();
|
||||
assert!(tw.contains("--color-palette-accent: #3ddc97;") && tw.contains("--color-semantic-accent: #7c5cff;"), "{tw}");
|
||||
assert!(tw.contains("--color-muted:"), "unique short names stay short: {tw}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_an_alias_target_takes_its_readers_role() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "half", "type": "number", "value": 0.5}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "fade", "type": "number", "value": 1}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "fade", "value": {"alias": "half"}}));
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "opacity", "variable": "fade"}));
|
||||
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
|
||||
assert!(css.contains("--half: 0.5;") && !css.contains("0.5px"), "{css}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_borders_images_and_fonts_from_variables_are_valid_utilities() {
|
||||
let got: Vec<String> = classes(
|
||||
"border: var(--stroke, 2px) solid #000000;\nborder-bottom: var(--hair, 1px) solid var(--color-border, #262a31);\nbackground: url(image.png) center / cover;\nfont-family: var(--brand-font, \"Inter\");\n",
|
||||
)
|
||||
.into_iter()
|
||||
.map(|(_, c)| c)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
got,
|
||||
[
|
||||
"border-(length:--stroke)",
|
||||
"border-[#000000]",
|
||||
"border-b-(length:--hair)",
|
||||
"border-border",
|
||||
"[background:url(image.png)_center_/_cover]",
|
||||
"font-(family-name:--brand-font)"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_desktop_defaults_reset_mobile_values_and_padding_per_side() {
|
||||
let mut s = Session::new();
|
||||
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
|
||||
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
|
||||
ex(&mut s, "node.set", json!({"id": mob, "props": {"opacity": 0.5, "padding": 24}}));
|
||||
ex(&mut s, "node.set", json!({"id": desk, "props": {"paddingTop": 0, "paddingBottom": 0, "paddingLeft": 64, "paddingRight": 64}}));
|
||||
let out = code(&mut s, desk, json!({"mobile": mob}));
|
||||
let root = out.lines().nth(3).unwrap_or_default();
|
||||
assert!(root.contains("opacity-50") && root.contains("lg:opacity-100"), "{root}");
|
||||
assert!(root.contains("p-6") && root.contains("lg:px-16") && root.contains("lg:py-0"), "{root}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_desktop_without_fill_clears_a_mobile_image_or_gradient() {
|
||||
let got = merge(Some(&[("background".to_string(), "[background:url(image.png)_center_/_cover]".to_string())]), &[]);
|
||||
assert!(got.contains(&"lg:bg-none".to_string()) && got.contains(&"lg:bg-transparent".to_string()), "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_corner_radii_with_a_variable_first_keep_every_corner() {
|
||||
let got: Vec<String> =
|
||||
classes("border-radius: var(--radius-a, 4px) 8px 8px 8px;\nborder-radius: var(--radius-card, 4px);\n").into_iter().map(|(_, c)| c).collect();
|
||||
assert_eq!(got, ["rounded-[var(--radius-a,_4px)_8px_8px_8px]", "rounded-card"]);
|
||||
}
|
||||
@@ -448,6 +448,11 @@ fn every_command_survives_junk_params() {
|
||||
// would write to it.)
|
||||
json!({"ids": [2, 3], "type": "vector", "path": [5], "width": -1e308, "props": {"path": "M0 0 L1e308 0 L-1e308 1e308", "height": "NaN", "x": 1e308}}),
|
||||
json!({"id": 2, "type": "vector", "path": {"d": 1}, "props": {"path": "M NaN 0 Z", "width": 1e-308}}),
|
||||
// Batch step names and reference paths as junk.
|
||||
json!({"commands": [{"command": "select.none", "as": "a"}, {"command": "select.none", "as": [1]}, {"command": "select.none", "as": "a"}]}),
|
||||
json!({"commands": [{"command": "select.all", "as": "\u{0}"}, {"command": "node.set", "params": {"ids": [
|
||||
{"$ref": "\u{0}", "field": "ids/18446744073709551616/x"}, {"$ref": {"x": 1}}, {"$step": 0, "field": "//"}, {"$ref": "\u{0}", "field": "/"}],
|
||||
"props": {"name": {"$ref": "nope", "field": 5}}}}]}),
|
||||
];
|
||||
for spec in all_commands() {
|
||||
if matches!(
|
||||
|
||||
@@ -137,6 +137,63 @@ fn ime_composition_with_cjk() {
|
||||
assert!(s.ds().unwrap().text_edit.as_ref().unwrap().preedit.is_none());
|
||||
}
|
||||
|
||||
/// Asserts `delete_surrounding_bytes` removes an exact UTF-8 byte range across CJK and a
|
||||
/// supplementary emoji, leaves nothing past the start, and the whole edit is one undo step.
|
||||
#[test]
|
||||
fn ime_delete_surrounding_bytes_is_exact_and_one_undo_step() {
|
||||
// 0.37's `DeleteSurroundingBytes` counts UTF-8 bytes around the selection, before the paired
|
||||
// commit. Multibyte CJK and a supplementary emoji must be removed exactly, not as tofu or
|
||||
// extended to grapheme clusters, and the whole edit remains one undo step.
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "ab\u{4F60}\u{597D}c\u{1F600}d", json!({"fontSize": 24}));
|
||||
begin(&mut s, t, Some(4), false).unwrap();
|
||||
assert!(crate::textedit::delete_surrounding_bytes(&mut s, 6, 1).unwrap(), "你好 before and c after");
|
||||
assert_eq!(chars(&s, t), "ab\u{1F600}d");
|
||||
assert_eq!(caret(&s), (2, 2));
|
||||
crate::textedit::set_caret(&mut s, 3, false).unwrap();
|
||||
assert!(crate::textedit::delete_surrounding_bytes(&mut s, 4, 0).unwrap(), "the emoji is four bytes");
|
||||
assert_eq!(chars(&s, t), "abd");
|
||||
assert_eq!(caret(&s), (2, 2));
|
||||
// Past the start of the text: nothing more to delete, not an error.
|
||||
assert!(!crate::textedit::delete_surrounding_bytes(&mut s, 0, 0).unwrap());
|
||||
crate::textedit::end(&mut s).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(chars(&s, t), "ab\u{4F60}\u{597D}c\u{1F600}d", "one undo restores the whole session");
|
||||
}
|
||||
|
||||
/// Asserts a byte range that only partly covers a character leaves it in place, while an
|
||||
/// exact range deletes it, and a selection survives for the paired commit with its
|
||||
/// direction preserved.
|
||||
#[test]
|
||||
fn ime_delete_surrounding_bytes_keeps_partial_characters_and_selection() {
|
||||
// The fork deletes the byte range exactly and leaves a character the range only partly
|
||||
// covers. FigCraft's editor counts characters with the same rule (mirroring egui's
|
||||
// `surrounding_bytes_to_chars`), including around a selection.
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "\u{e9}x\u{1F600}", json!({"fontSize": 24}));
|
||||
let del = |s: &mut Session, b: usize, a: usize| crate::textedit::delete_surrounding_bytes(s, b, a).unwrap();
|
||||
begin(&mut s, t, Some(1), false).unwrap();
|
||||
assert!(!del(&mut s, 1, 0), "one byte ends inside 'é' (two bytes): leave it");
|
||||
assert_eq!(chars(&s, t), "\u{e9}x\u{1F600}");
|
||||
crate::textedit::set_caret(&mut s, 2, false).unwrap();
|
||||
assert!(!del(&mut s, 0, 2), "two bytes start inside the four-byte emoji: leave it");
|
||||
assert_eq!(chars(&s, t), "\u{e9}x\u{1F600}");
|
||||
assert!(del(&mut s, 0, 4), "its four bytes cover it exactly");
|
||||
assert_eq!(chars(&s, t), "\u{e9}x");
|
||||
assert_eq!(caret(&s), (2, 2));
|
||||
// Complete bytes before the selection go; a character the range only partly covers stays,
|
||||
// and the selection itself is kept for the paired commit.
|
||||
crate::textedit::set_caret(&mut s, 1, false).unwrap();
|
||||
crate::textedit::set_caret(&mut s, 1, true).unwrap();
|
||||
crate::textedit::set_caret(&mut s, 2, true).unwrap();
|
||||
assert!(del(&mut s, 2, 2), "é before (two bytes); two bytes start inside the four-byte emoji");
|
||||
// The selected 'x' is kept for the paired commit, as in stock `TextEdit`; the caret moves left
|
||||
// with the deleted prefix and the selection direction is preserved.
|
||||
assert_eq!(chars(&s, t), "x");
|
||||
assert_eq!(caret(&s), (0, 1));
|
||||
crate::textedit::end(&mut s).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_trim_through_node_set() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -1,9 +1,85 @@
|
||||
//! On-canvas text editing: caret, selection, typing. A whole editing session is one undo step.
|
||||
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use figcraft_doc::{Document, ListStyle, NodeId, NodeKind, TextData};
|
||||
|
||||
use crate::{EngineError, Preedit, Result, Session, TextEdit};
|
||||
|
||||
/// Most committed UTF-8 bytes an outgoing IME snapshot may describe.
|
||||
///
|
||||
/// This mirrors the fork's `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`; the engine keeps its
|
||||
/// own constant so this layer has no egui dependency. The query below returns only this bounded
|
||||
/// window around the selection, never a copy of the whole document.
|
||||
pub const MAX_IME_SURROUNDING_BYTES: usize = 4000;
|
||||
|
||||
/// Stable identity of the on-canvas text edit an IME context belongs to.
|
||||
///
|
||||
/// `document` and `node` do not change through ordinary text or caret edits. `editing_session`
|
||||
/// changes when [`begin`] starts a new session for an equal `(document, node)` pair (leaving and
|
||||
/// re-entering the same layer), so a replaced target never inherits an old composition.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ImeEditOwner {
|
||||
/// The document tab's uid.
|
||||
pub document: u64,
|
||||
/// The edited text node.
|
||||
pub node: NodeId,
|
||||
/// [`TextEdit::editing_session`] of the current edit.
|
||||
pub editing_session: u64,
|
||||
}
|
||||
|
||||
/// Why the committed text around the IME caret cannot be described.
|
||||
///
|
||||
/// These are the only reasons this custom editor reports
|
||||
/// `egui::output::IMESurroundingText::Unavailable`; an empty text layer is available with empty
|
||||
/// text and a collapsed selection.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ImeSurroundingUnavailable {
|
||||
/// No text layer is being edited.
|
||||
NotEditing,
|
||||
/// The editing target is not a text node any more.
|
||||
NotText,
|
||||
/// The selection alone is wider than [`MAX_IME_SURROUNDING_BYTES`], so no bounded snapshot
|
||||
/// can contain both selection ends.
|
||||
SelectionTooWide,
|
||||
/// Defensive: the computed window was not a character-boundary slice. The rounding above
|
||||
/// makes this unreachable for a well-formed text model.
|
||||
InvalidWindow,
|
||||
}
|
||||
|
||||
/// One bounded, committed logical-text observation for the outgoing IME snapshot.
|
||||
///
|
||||
/// `text` is never display/bidi order and never contains the pre-edit (which is UI-only in this
|
||||
/// editor). `cursor` and `anchor` are UTF-8 byte offsets into `text`; `anchor > cursor` is a
|
||||
/// valid reversed selection.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ImeSurrounding {
|
||||
/// Identity of the edit this observation belongs to.
|
||||
pub owner: ImeEditOwner,
|
||||
/// At most [`MAX_IME_SURROUNDING_BYTES`] bytes of committed text around the selection.
|
||||
pub text: String,
|
||||
/// UTF-8 byte offset of the primary caret in [`Self::text`].
|
||||
pub cursor: usize,
|
||||
/// UTF-8 byte offset of the selection anchor in [`Self::text`].
|
||||
pub anchor: usize,
|
||||
}
|
||||
|
||||
/// Every editing session gets its own token; zero is valid and never reused deliberately.
|
||||
///
|
||||
/// A mutex (rather than an atomic) also works on wasm32 targets without 64-bit atomics.
|
||||
static EDITING_SESSION: LazyLock<Mutex<u64>> = LazyLock::new(|| Mutex::new(0));
|
||||
|
||||
/// Return the next editing-session token from a mutex-protected counter (`wrapping_add`).
|
||||
/// Zero is a valid token; the mutex also works on wasm32 without 64-bit atomics.
|
||||
fn next_editing_session() -> u64 {
|
||||
let mut session = match EDITING_SESSION.lock() {
|
||||
Ok(session) => session,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
*session = session.wrapping_add(1);
|
||||
*session
|
||||
}
|
||||
|
||||
fn text_of(s: &Session, id: NodeId) -> Result<TextData> {
|
||||
match &s.doc()?.get(id)?.kind {
|
||||
NodeKind::Text(t) => Ok(t.clone()),
|
||||
@@ -23,7 +99,7 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option<usize>, created: bool) -
|
||||
Some(c) => (c.min(n), c.min(n)),
|
||||
None => (n, 0),
|
||||
};
|
||||
ds.text_edit = Some(TextEdit { id, caret, anchor, created, preedit: None });
|
||||
ds.text_edit = Some(TextEdit { id, caret, anchor, created, editing_session: next_editing_session(), preedit: None });
|
||||
ds.selection = vec![id];
|
||||
ds.revision += 1;
|
||||
s.live_text_notice(id);
|
||||
@@ -69,14 +145,33 @@ fn range(te: &TextEdit) -> (usize, usize) {
|
||||
(te.caret.min(te.anchor), te.caret.max(te.anchor))
|
||||
}
|
||||
|
||||
/// Apply an unconditional text-model mutation through `edit_if`, whose success clears the
|
||||
/// composition, clamps caret and anchor to the character count, marks a `characters` override
|
||||
/// when the node is source-backed, normalizes the document and bumps the revision.
|
||||
fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result<()> {
|
||||
edit_if(s, |t, te| {
|
||||
f(t, te);
|
||||
true
|
||||
})
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// [`edit`] for a mutation that can still turn out to be a no-op.
|
||||
///
|
||||
/// The closure returns whether it changed the model; return `false` only before mutating (the
|
||||
/// text is handed over directly). A no-op leaves the composition (`preedit`), the revision and the
|
||||
/// document untouched, so an IME deletion that finds nothing to delete does not cancel an
|
||||
/// in-progress composition.
|
||||
fn edit_if(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit) -> bool) -> Result<bool> {
|
||||
let ds = s.ds_mut()?;
|
||||
let Some(mut te) = ds.text_edit.clone() else { return Err(EngineError::Other("not editing text".into())) };
|
||||
let before = ds.doc.clone();
|
||||
let doc = ds.doc_mut();
|
||||
let n = doc.get_mut(te.id)?;
|
||||
let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("not a text layer".into())) };
|
||||
f(t, &mut te);
|
||||
if !f(t, &mut te) {
|
||||
return Ok(false);
|
||||
}
|
||||
// Any edit ends a composition (a commit replaces it with the final text).
|
||||
te.preedit = None;
|
||||
let len = t.char_count();
|
||||
@@ -88,16 +183,83 @@ fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result
|
||||
crate::normalize(doc, &before);
|
||||
ds.text_edit = Some(te);
|
||||
ds.revision += 1;
|
||||
Ok(())
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// What an accepted IME `Commit` did to the committed model.
|
||||
///
|
||||
/// An IME receipt may label the snapshot `InputMethod` only for [`Self::Plain`]. AutoFormat
|
||||
/// (typing "- " at a line start turns the paragraph into a list) is an application
|
||||
/// transformation in the same command, so the frame's change must be reported as `Other`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ImeInsertion {
|
||||
/// Nothing was inserted (an empty, control-only, or bare-newline commit).
|
||||
Nothing,
|
||||
/// The committed text replaced the selection exactly.
|
||||
Plain,
|
||||
/// The editor transformed the commit before applying it: paragraph AutoFormat, or line-ending
|
||||
/// normalisation/control-character removal. The frame's change is application-origin.
|
||||
Transformed,
|
||||
}
|
||||
|
||||
/// Type (or paste) `text` replacing the selection. Line endings are normalised and other control
|
||||
/// characters dropped, so pasted plain text from any platform lands cleanly.
|
||||
pub fn insert(s: &mut Session, text: &str) -> Result<()> {
|
||||
let text: String = crate::cmd::props::normalize_line_endings(text).chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect();
|
||||
if text.is_empty() {
|
||||
insert_model(s, text, false).map(|_| ())
|
||||
}
|
||||
|
||||
/// End the composition without changing the document, for an empty IME commit.
|
||||
///
|
||||
/// Returns without touching the session when no composition is active (or nothing is being
|
||||
/// edited): an empty commit is otherwise a no-op, never an error.
|
||||
fn clear_composition(s: &mut Session) -> Result<()> {
|
||||
let composing = s.ds().ok().and_then(|d| d.text_edit.as_ref()).is_some_and(|te| te.preedit.is_some());
|
||||
if !composing {
|
||||
return Ok(());
|
||||
}
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(te) = ds.text_edit.as_mut() {
|
||||
te.preedit = None;
|
||||
}
|
||||
ds.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply an IME `Commit`, reporting whether it was a plain insertion.
|
||||
///
|
||||
/// The UI calls this instead of [`insert`] so only a commit that the editor applied as a plain
|
||||
/// insertion can earn an `InputMethod` receipt. AutoFormat, line-ending normalisation and
|
||||
/// control-character removal are application transformations and are reported as
|
||||
/// [`ImeInsertion::Transformed`]. Like stock `TextEdit`, a bare `"\n"`/`"\r"` commit is ignored.
|
||||
pub fn insert_ime_commit(s: &mut Session, text: &str) -> Result<ImeInsertion> {
|
||||
insert_model(s, text, true)
|
||||
}
|
||||
|
||||
/// Insert committed text, optionally as an IME commit: normalize line endings, drop control
|
||||
/// characters except `\n`/`\t`, ignore a bare newline/CR IME commit, clear an active
|
||||
/// composition for an empty or control-only IME commit, and turn a committed space after a
|
||||
/// line-start `-`, `*` or `1.` into a bulleted/numbered list. Reports whether the insertion was
|
||||
/// plain, transformed, or a no-op.
|
||||
fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result<ImeInsertion> {
|
||||
// Stock `TextEdit` deliberately ignores these two commits (they are not a mutation); only a
|
||||
// real commit may do so, since typing Enter goes through `insert`.
|
||||
if ime_commit && matches!(text, "\n" | "\r") {
|
||||
return Ok(ImeInsertion::Nothing);
|
||||
}
|
||||
let normalized: String =
|
||||
crate::cmd::props::normalize_line_endings(text).chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect();
|
||||
if normalized.is_empty() {
|
||||
// An empty commit is how some integrations (Safari's egui web integration when the last
|
||||
// composition character is deleted) end the composition. It inserts nothing, but it must
|
||||
// still clear the pre-edit; stock `TextEdit` does the same in its non-ignored `Commit` arm.
|
||||
if ime_commit {
|
||||
clear_composition(s)?;
|
||||
}
|
||||
return Ok(ImeInsertion::Nothing);
|
||||
}
|
||||
// A commit the editor had to transform is an application edit, not a plain insertion.
|
||||
let mut outcome = if normalized == text { ImeInsertion::Plain } else { ImeInsertion::Transformed };
|
||||
let text = normalized;
|
||||
edit(s, |t, te| {
|
||||
let (a, b) = range(te);
|
||||
// "- ", "* " or "1. " typed at the start of a plain paragraph starts a list.
|
||||
@@ -114,13 +276,15 @@ pub fn insert(s: &mut Session, text: &str) -> Result<()> {
|
||||
t.replace_range(start, a, "");
|
||||
t.set_para_lists(start, start, |pl| pl.list = list);
|
||||
(te.caret, te.anchor) = (start, start);
|
||||
outcome = ImeInsertion::Transformed;
|
||||
return;
|
||||
}
|
||||
}
|
||||
t.replace_range(a, b, &text);
|
||||
te.caret = a + text.chars().count();
|
||||
te.anchor = te.caret;
|
||||
})
|
||||
})?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// List keys: Tab nests the selected list items a level deeper; Shift+Tab, Backspace at an item's
|
||||
@@ -345,6 +509,160 @@ fn select(s: &mut Session, anchor: usize, caret: usize) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete the text surrounding the caret/selection that an IME reports with
|
||||
/// [`egui::ImeEvent::DeleteSurroundingBytes`]: `before_bytes` UTF-8 bytes end where the selection
|
||||
/// starts and `after_bytes` start where it ends. As in stock `TextEdit`, the selected text itself
|
||||
/// is kept for the paired `Commit`; only whole characters covered by the byte counts beyond the
|
||||
/// selection are removed (a character the range only partly covers stays). Returns whether
|
||||
/// anything was deleted.
|
||||
pub fn delete_surrounding_bytes(s: &mut Session, before_bytes: usize, after_bytes: usize) -> Result<bool> {
|
||||
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(false) };
|
||||
let t = text_of(s, te.id)?;
|
||||
let (a, b) = range(&te);
|
||||
let (before, after) = surrounding_chars(&t.characters, a, b, before_bytes, after_bytes);
|
||||
if before == 0 && after == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
edit_if(s, |t, te| {
|
||||
let (a, b) = range(te);
|
||||
let (before, after) = surrounding_chars(&t.characters, a, b, before_bytes, after_bytes);
|
||||
if before == 0 && after == 0 {
|
||||
return false;
|
||||
}
|
||||
// Delete after the selection first so the before range's indices stay valid.
|
||||
if after > 0 {
|
||||
t.replace_range(b, b + after, "");
|
||||
}
|
||||
if before > 0 {
|
||||
t.replace_range(a - before, a, "");
|
||||
}
|
||||
// Keep the selection: both ends move left by the deleted prefix, reversed or not.
|
||||
te.caret = te.caret.saturating_sub(before);
|
||||
te.anchor = te.anchor.saturating_sub(before);
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete surrounding text counted in characters (the older `DeleteSurrounding` event). The
|
||||
/// selection is kept, as in stock `TextEdit`; only text before and after it is removed.
|
||||
pub fn delete_surrounding_chars(s: &mut Session, before_chars: usize, after_chars: usize) -> Result<bool> {
|
||||
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(false) };
|
||||
let t = text_of(s, te.id)?;
|
||||
let (a, b) = range(&te);
|
||||
let (_, b_byte) = char_byte_offsets(&t.characters, a, b);
|
||||
let before = before_chars.min(a);
|
||||
let after = t.characters.get(b_byte..).map_or(0, |rest| rest.chars().take(after_chars).count());
|
||||
if before == 0 && after == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
edit_if(s, |t, te| {
|
||||
let (a, b) = range(te);
|
||||
let (_, b_byte) = char_byte_offsets(&t.characters, a, b);
|
||||
let before = before_chars.min(a);
|
||||
let after = t.characters.get(b_byte..).map_or(0, |rest| rest.chars().take(after_chars).count());
|
||||
if before == 0 && after == 0 {
|
||||
return false;
|
||||
}
|
||||
if after > 0 {
|
||||
t.replace_range(b, b + after, "");
|
||||
}
|
||||
if before > 0 {
|
||||
t.replace_range(a - before, a, "");
|
||||
}
|
||||
te.caret = te.caret.saturating_sub(before);
|
||||
te.anchor = te.anchor.saturating_sub(before);
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
/// The UTF-8 byte offsets of character indices `a` and `b` in one forward pass.
|
||||
///
|
||||
/// An index at or past the end maps to `text.len()`. Walking once (instead of twice) matters on
|
||||
/// every IME event over a large text layer; the walk is still O(max(a, b)) characters, and callers
|
||||
/// that need the end still pay O(n). See [`ime_surrounding`].
|
||||
fn char_byte_offsets(text: &str, a: usize, b: usize) -> (usize, usize) {
|
||||
let (mut a_byte, mut b_byte) = (None, None);
|
||||
for (i, (byte, _)) in text.char_indices().enumerate() {
|
||||
if a_byte.is_none() && i == a {
|
||||
a_byte = Some(byte);
|
||||
}
|
||||
if b_byte.is_none() && i == b {
|
||||
b_byte = Some(byte);
|
||||
}
|
||||
if a_byte.is_some() && b_byte.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(a_byte.unwrap_or(text.len()), b_byte.unwrap_or(text.len()))
|
||||
}
|
||||
|
||||
/// `(before, after)` characters covered by the IME's byte counts around the selection `[a, b)`
|
||||
/// (character indices): mirror of egui's own `surrounding_bytes_to_chars`.
|
||||
fn surrounding_chars(text: &str, a: usize, b: usize, before_bytes: usize, after_bytes: usize) -> (usize, usize) {
|
||||
let (a_byte, b_byte) = char_byte_offsets(text, a, b);
|
||||
let from = a_byte.saturating_sub(before_bytes);
|
||||
let before = text[..a_byte].char_indices().rev().take_while(|(i, _)| *i >= from).count();
|
||||
let after = text[b_byte..].char_indices().take_while(|(i, c)| i + c.len_utf8() <= after_bytes).count();
|
||||
(before, after)
|
||||
}
|
||||
|
||||
/// The committed text around the IME caret for the outgoing surrounding-text snapshot.
|
||||
///
|
||||
/// This is the edited node's logical model text (never the bidi display order), with the
|
||||
/// pre-edit excluded because a composition is UI-only in this editor until it is committed.
|
||||
/// Character caret/anchor indices become independent UTF-8 byte offsets, so a reversed
|
||||
/// selection stays reversed. At most [`MAX_IME_SURROUNDING_BYTES`] bytes are copied: the window
|
||||
/// is centred on the selection and rounded inward to character boundaries. An empty text layer
|
||||
/// is `Ok` with `text == ""` and both offsets zero; the result is [`ImeSurroundingUnavailable`]
|
||||
/// only when there is no text edit, the target is not a text node, or the selection alone is
|
||||
/// wider than the budget.
|
||||
///
|
||||
/// Cost: one character walk up to the later of the two cursor indices (to turn char indices into
|
||||
/// UTF-8 bytes) plus the bounded copy. The caret is a character index, so reaching a caret near
|
||||
/// the end of a very large layer is still O(n); the UI calls this once per pass and twice around
|
||||
/// each text-affecting event (`ime_stamp`), so such a layer pays O(caret/anchor) per event. The
|
||||
/// document is never cloned here.
|
||||
pub fn ime_surrounding(s: &Session) -> std::result::Result<ImeSurrounding, ImeSurroundingUnavailable> {
|
||||
let ds = s.ds().map_err(|_| ImeSurroundingUnavailable::NotEditing)?;
|
||||
let te = ds.text_edit.as_ref().ok_or(ImeSurroundingUnavailable::NotEditing)?;
|
||||
let node = ds.doc.node(te.id).ok_or(ImeSurroundingUnavailable::NotText)?;
|
||||
let NodeKind::Text(t) = &node.kind else { return Err(ImeSurroundingUnavailable::NotText) };
|
||||
let text = t.characters.as_str();
|
||||
// One pass for both offsets (the caret and anchor are already clamped by `edit`/`select`).
|
||||
let (cursor, anchor) = char_byte_offsets(text, te.caret, te.anchor);
|
||||
let (selection_start, selection_end) = (cursor.min(anchor), cursor.max(anchor));
|
||||
let selection_len = selection_end - selection_start;
|
||||
if selection_len > MAX_IME_SURROUNDING_BYTES {
|
||||
return Err(ImeSurroundingUnavailable::SelectionTooWide);
|
||||
}
|
||||
// Spend the budget on both sides, giving room unused on one side to the other.
|
||||
let budget = MAX_IME_SURROUNDING_BYTES - selection_len;
|
||||
let before_available = selection_start;
|
||||
let after_available = text.len() - selection_end;
|
||||
let mut before = (budget / 2).min(before_available);
|
||||
let mut after = (budget - before).min(after_available);
|
||||
before = (budget - after).min(before_available);
|
||||
after = (budget - before).min(after_available);
|
||||
let mut window_start = selection_start - before;
|
||||
let mut window_end = selection_end + after;
|
||||
// Round inward so the window stays within the byte budget and on character boundaries.
|
||||
while window_start < window_end && !text.is_char_boundary(window_start) {
|
||||
window_start += 1;
|
||||
}
|
||||
while window_end > window_start && !text.is_char_boundary(window_end) {
|
||||
window_end -= 1;
|
||||
}
|
||||
let Some(window) = text.get(window_start..window_end) else {
|
||||
return Err(ImeSurroundingUnavailable::InvalidWindow);
|
||||
};
|
||||
Ok(ImeSurrounding {
|
||||
owner: ImeEditOwner { document: ds.uid, node: te.id, editing_session: te.editing_session },
|
||||
text: window.to_owned(),
|
||||
cursor: cursor.saturating_sub(window_start),
|
||||
anchor: anchor.saturating_sub(window_start),
|
||||
})
|
||||
}
|
||||
|
||||
/// Show an IME composition inline at the caret (`cursor` = the IME's cursor inside it, in chars;
|
||||
/// default its end). The document doesn't change until the composition is committed with
|
||||
/// [`insert`]; an empty `text` cancels it. Starting to compose over a selection deletes it, as
|
||||
@@ -596,6 +914,9 @@ mod tests {
|
||||
e
|
||||
}
|
||||
|
||||
/// Asserts double-clicking a text layer selects all text, a second double-click selects the
|
||||
/// word without changing the editing session, and drag and Shift+click extend the
|
||||
/// selection.
|
||||
#[test]
|
||||
fn pointer_double_click_drag_and_shift_click() {
|
||||
let (mut s, id) = setup("Hello world");
|
||||
@@ -612,10 +933,16 @@ mod tests {
|
||||
s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap();
|
||||
s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap();
|
||||
assert_eq!(sel(&s), (0, 11));
|
||||
let epoch = s.ds().unwrap().text_edit.as_ref().unwrap().editing_session;
|
||||
s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap();
|
||||
s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap();
|
||||
assert_eq!(sel(&s), (6, 11));
|
||||
assert_eq!(
|
||||
s.ds().unwrap().text_edit.as_ref().unwrap().editing_session,
|
||||
epoch,
|
||||
"a double-click inside the edit is an ordinary edit: same IME replacement epoch"
|
||||
);
|
||||
// Drag-select from the start of "Hello" to the middle.
|
||||
s.pointer(&ev(PointerKind::Down, x_of(0) + 0.5, y, false)).unwrap();
|
||||
assert_eq!(sel(&s), (0, 0));
|
||||
@@ -827,4 +1154,203 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asserts surrounding text is logical UTF-8 text with byte offsets and keeps a reversed
|
||||
/// selection reversed.
|
||||
#[test]
|
||||
fn ime_surrounding_uses_logical_utf8_bytes_and_keeps_selection_direction() {
|
||||
let (mut s, id) = setup("café😀e\u{301}x");
|
||||
begin(&mut s, id, Some(8), false).unwrap();
|
||||
set_caret(&mut s, 8, false).unwrap();
|
||||
set_caret(&mut s, 5, true).unwrap(); // anchor after the emoji, caret before it
|
||||
let ime = ime_surrounding(&s).unwrap();
|
||||
assert_eq!(ime.text, "café😀e\u{301}x");
|
||||
assert_eq!((ime.cursor, ime.anchor), (9, 13), "char 5 and char 8 as UTF-8 bytes");
|
||||
assert!(ime.cursor < ime.anchor, "the reversed selection stays reversed");
|
||||
assert_eq!(ime.owner.node, id);
|
||||
assert_eq!(ime.owner.document, s.ds().unwrap().uid);
|
||||
}
|
||||
|
||||
/// Asserts an active composition is excluded from surrounding text and an empty text layer
|
||||
/// reports an available, empty payload.
|
||||
#[test]
|
||||
fn ime_surrounding_excludes_the_composition_and_empty_text_is_available() {
|
||||
let (mut s, id) = setup("Hi");
|
||||
begin(&mut s, id, Some(2), false).unwrap();
|
||||
preedit(&mut s, "にほ", Some(1)).unwrap();
|
||||
let ime = ime_surrounding(&s).unwrap();
|
||||
assert_eq!(ime.text, "Hi", "the composition is not committed text");
|
||||
assert_eq!((ime.cursor, ime.anchor), (2, 2));
|
||||
end(&mut s).unwrap();
|
||||
|
||||
let (mut s, id) = setup("");
|
||||
begin(&mut s, id, Some(0), false).unwrap();
|
||||
let ime = ime_surrounding(&s).unwrap();
|
||||
assert_eq!((ime.text.as_str(), ime.cursor, ime.anchor), ("", 0, 0), "an empty layer is available, not unavailable");
|
||||
}
|
||||
|
||||
/// Asserts surrounding text is capped at `MAX_IME_SURROUNDING_BYTES`, an exactly budget-sized
|
||||
/// selection is describable, and one byte more is `SelectionTooWide`.
|
||||
#[test]
|
||||
fn ime_surrounding_is_bounded_and_a_too_wide_selection_is_explicitly_unavailable() {
|
||||
let long = "a".repeat(5000);
|
||||
let (mut s, id) = setup(&long);
|
||||
begin(&mut s, id, Some(5000), false).unwrap();
|
||||
let ime = ime_surrounding(&s).unwrap();
|
||||
assert_eq!(ime.text.len(), MAX_IME_SURROUNDING_BYTES);
|
||||
assert_eq!((ime.cursor, ime.anchor), (MAX_IME_SURROUNDING_BYTES, MAX_IME_SURROUNDING_BYTES));
|
||||
|
||||
// Exactly the budget is still describable; one more byte is not.
|
||||
set_caret(&mut s, 1000, false).unwrap();
|
||||
set_caret(&mut s, 5000, true).unwrap();
|
||||
let ime = ime_surrounding(&s).unwrap();
|
||||
assert_eq!(ime.text.len(), 4000);
|
||||
assert_eq!((ime.cursor, ime.anchor), (4000, 0));
|
||||
set_caret(&mut s, 999, false).unwrap();
|
||||
set_caret(&mut s, 5000, true).unwrap();
|
||||
assert_eq!(ime_surrounding(&s), Err(ImeSurroundingUnavailable::SelectionTooWide));
|
||||
}
|
||||
|
||||
/// Asserts edits and caret moves keep the same IME owner, while ending and beginning a new
|
||||
/// edit session changes the generation.
|
||||
#[test]
|
||||
fn ime_owner_is_stable_through_edits_and_the_session_changes_on_begin() {
|
||||
let (mut s, id) = setup("Hello");
|
||||
begin(&mut s, id, Some(0), false).unwrap();
|
||||
let first = ime_surrounding(&s).unwrap().owner;
|
||||
insert(&mut s, "x").unwrap();
|
||||
set_caret(&mut s, 2, false).unwrap();
|
||||
assert_eq!(ime_surrounding(&s).unwrap().owner, first, "edits and caret moves keep the owner");
|
||||
end(&mut s).unwrap();
|
||||
begin(&mut s, id, Some(0), false).unwrap();
|
||||
let replaced = ime_surrounding(&s).unwrap().owner;
|
||||
assert_eq!(replaced.node, id);
|
||||
assert_ne!(replaced.editing_session, first.editing_session, "a new edit session is a replacement epoch");
|
||||
}
|
||||
|
||||
/// Asserts byte deletion around a reversed selection removes whole characters on both
|
||||
/// sides, keeps the selected text and direction, and a final no-op leaves the text alone.
|
||||
#[test]
|
||||
fn ime_delete_surrounding_bytes_keeps_the_reversed_selection_and_deletes_around_it() {
|
||||
let (mut s, id) = setup("ab你好cd");
|
||||
begin(&mut s, id, Some(4), false).unwrap();
|
||||
set_caret(&mut s, 4, false).unwrap();
|
||||
set_caret(&mut s, 2, true).unwrap(); // reversed: caret before 你, anchor after 好
|
||||
assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap());
|
||||
// "b" (whole character before the selection) and "c" (whole character after it) go; the
|
||||
// selected "你好" stays for the paired commit, and the reversed direction is preserved.
|
||||
assert_eq!(data(&s, id).characters, "a你好d");
|
||||
assert_eq!(sel(&s), (3, 1)); // (anchor, caret), still reversed
|
||||
assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap());
|
||||
assert_eq!(data(&s, id).characters, "你好");
|
||||
assert_eq!(sel(&s), (2, 0)); // (anchor, caret), still reversed
|
||||
assert!(!delete_surrounding_bytes(&mut s, 1, 1).unwrap(), "nothing is left around the selection");
|
||||
assert_eq!(data(&s, id).characters, "你好", "a no-op delete does not touch the text");
|
||||
}
|
||||
|
||||
/// Asserts character deletion keeps and shifts the selection, and a no-op delete leaves an
|
||||
/// active composition and the revision untouched.
|
||||
#[test]
|
||||
fn ime_delete_surrounding_chars_keeps_the_selection_and_ignores_a_no_op() {
|
||||
let (mut s, id) = setup("ab你好cd");
|
||||
begin(&mut s, id, Some(2), false).unwrap();
|
||||
set_caret(&mut s, 4, true).unwrap(); // "你好"
|
||||
assert!(delete_surrounding_chars(&mut s, 1, 1).unwrap());
|
||||
assert_eq!(data(&s, id).characters, "a你好d");
|
||||
assert_eq!(sel(&s), (1, 3), "the selection is kept, shifted by the deleted prefix");
|
||||
set_caret(&mut s, 0, false).unwrap();
|
||||
// A composition in progress survives a delete that finds nothing to remove.
|
||||
preedit(&mut s, "x", None).unwrap();
|
||||
let revision = s.ds().unwrap().revision;
|
||||
assert!(!delete_surrounding_chars(&mut s, 1, 0).unwrap());
|
||||
assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x"));
|
||||
assert_eq!(s.ds().unwrap().revision, revision, "a no-op does not bump the revision");
|
||||
assert!(!delete_surrounding_bytes(&mut s, 1, 0).unwrap());
|
||||
assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x"));
|
||||
assert_eq!(s.ds().unwrap().revision, revision);
|
||||
}
|
||||
|
||||
/// Asserts a text layer in another document has a different IME owner.
|
||||
#[test]
|
||||
fn ime_owner_identity_changes_with_the_document() {
|
||||
let (mut s, id) = setup("Hi");
|
||||
begin(&mut s, id, Some(2), false).unwrap();
|
||||
let first = ime_surrounding(&s).unwrap().owner;
|
||||
end(&mut s).unwrap();
|
||||
s.execute("file.new", &json!({"name": "Second"})).unwrap();
|
||||
let r = s.execute("text.create", &json!({"text": "There", "x": 0, "y": 0})).unwrap();
|
||||
let other = crate::params::id_of(&r["id"]).unwrap();
|
||||
begin(&mut s, other, Some(5), false).unwrap();
|
||||
let second = ime_surrounding(&s).unwrap().owner;
|
||||
assert_ne!(second.document, first.document, "another document is another IME owner");
|
||||
assert_eq!(second.node, other);
|
||||
}
|
||||
|
||||
/// Asserts a space committed after a line-start `-` becomes a bulleted list and reports
|
||||
/// `Transformed`; a plain `"x"` commit reports `Plain`, and an empty commit reports `Nothing`.
|
||||
#[test]
|
||||
fn ime_commit_reports_list_autoformat_as_an_application_transformation() {
|
||||
let (mut s, id) = setup("-");
|
||||
begin(&mut s, id, Some(1), false).unwrap();
|
||||
assert_eq!(insert_ime_commit(&mut s, " ").unwrap(), ImeInsertion::Transformed);
|
||||
assert_eq!(data(&s, id).characters, "");
|
||||
assert_eq!(data(&s, id).para_list(0).list, ListStyle::Bulleted);
|
||||
assert_eq!(insert_ime_commit(&mut s, "x").unwrap(), ImeInsertion::Plain);
|
||||
assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing);
|
||||
}
|
||||
|
||||
/// Asserts CRLF normalization reports `Transformed`, control-only input reports `Nothing`,
|
||||
/// and bare newline/CR commits are ignored while a live composition remains.
|
||||
#[test]
|
||||
fn ime_commit_normalisation_is_a_transformation_and_a_bare_newline_is_ignored() {
|
||||
let (mut s, id) = setup("Hi");
|
||||
begin(&mut s, id, Some(2), false).unwrap();
|
||||
// CRLF is inserted as LF, but the editor changed the commit: application-origin.
|
||||
assert_eq!(insert_ime_commit(&mut s, "a\r\nb").unwrap(), ImeInsertion::Transformed);
|
||||
assert_eq!(data(&s, id).characters, "Hia\nb");
|
||||
// Control characters are dropped; the commit itself is a no-op.
|
||||
assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing);
|
||||
assert_eq!(data(&s, id).characters, "Hia\nb");
|
||||
// Stock `TextEdit` ignores a bare newline/CR commit; typing Enter still inserts one.
|
||||
// (It also leaves a live composition alone, unlike the non-ignored commit below.)
|
||||
preedit(&mut s, "x", None).unwrap();
|
||||
assert_eq!(insert_ime_commit(&mut s, "\n").unwrap(), ImeInsertion::Nothing);
|
||||
assert_eq!(insert_ime_commit(&mut s, "\r").unwrap(), ImeInsertion::Nothing);
|
||||
assert_eq!(preedit_state(&s).map(|p| p.text), Some("x".into()));
|
||||
preedit(&mut s, "", None).unwrap();
|
||||
assert_eq!(data(&s, id).characters, "Hia\nb");
|
||||
insert(&mut s, "\n").unwrap();
|
||||
assert_eq!(data(&s, id).characters, "Hia\nb\n");
|
||||
}
|
||||
|
||||
/// Asserts an empty or control-only IME commit ends a live composition (bumping the revision
|
||||
/// once), inserts nothing, and is a harmless no-op when no composition is active.
|
||||
#[test]
|
||||
fn an_empty_ime_commit_ends_the_composition_without_inserting_text() {
|
||||
let (mut s, id) = setup("Hello");
|
||||
begin(&mut s, id, Some(5), false).unwrap();
|
||||
preedit(&mut s, "にほ", None).unwrap();
|
||||
assert_eq!(preedit_state(&s).map(|p| p.text), Some("にほ".into()));
|
||||
let revision = s.ds().unwrap().revision;
|
||||
assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing);
|
||||
assert_eq!(s.ds().unwrap().revision, revision + 1, "ending a live composition bumps the revision");
|
||||
assert!(preedit_state(&s).is_none(), "an empty commit ends the composition");
|
||||
assert!(preedit_doc(&s).is_none());
|
||||
assert_eq!(data(&s, id).characters, "Hello", "and inserts nothing");
|
||||
|
||||
// A control-only commit normalises to empty and ends the composition too.
|
||||
preedit(&mut s, "x", None).unwrap();
|
||||
let revision = s.ds().unwrap().revision;
|
||||
assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing);
|
||||
assert_eq!(s.ds().unwrap().revision, revision + 1, "clearing a live composition bumps the revision");
|
||||
assert!(preedit_state(&s).is_none());
|
||||
assert_eq!(data(&s, id).characters, "Hello");
|
||||
|
||||
// With no composition (and not even while editing) an empty commit stays a harmless no-op.
|
||||
let revision = s.ds().unwrap().revision;
|
||||
assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing);
|
||||
assert_eq!(s.ds().unwrap().revision, revision);
|
||||
end(&mut s).unwrap();
|
||||
assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use serde_json::json;
|
||||
|
||||
/// A fixed-width (auto-height) headline of `width` px at 64px. Returns its id.
|
||||
fn headline(s: &mut Session, text: &str, width: f64) -> NodeId {
|
||||
figcraft_text::scan_system_fonts();
|
||||
let t = idv(&ex(s, "text.create", json!({"text": text, "x": 0, "y": 0, "props": {"name": "Headline", "fontSize": 64}})));
|
||||
ex(s, "node.set", json!({"id": t, "props": {"width": width, "textAutoResize": "height"}}));
|
||||
t
|
||||
@@ -63,8 +64,11 @@ fn a_one_word_last_line_is_avoided() {
|
||||
assert!(l.lines.len() == 1 || words.split_whitespace().count() > 1 || words.contains('\u{a0}'), "{words:?}");
|
||||
}
|
||||
|
||||
/// Asserts `data.fill` keeps a short copy's font size, shrinks a long copy, and leaves the
|
||||
/// source text layer untouched.
|
||||
#[test]
|
||||
fn data_fill_sizes_each_copys_text() {
|
||||
figcraft_text::scan_system_fonts();
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 1080, "height": 1350, "name": "Post"})));
|
||||
let t = idv(&ex(&mut s, "text.create", json!({"text": "Curto", "x": 80, "y": 80, "parent": f, "props": {"name": "Headline", "fontSize": 96}})));
|
||||
|
||||
@@ -2020,6 +2020,10 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle a double-click gesture: finish a pen path, rename or click labels/widgets,
|
||||
/// cancel an in-progress move/resize/draw/marquee, handle board double-clicks, enter text
|
||||
/// editing (select-all on entry, word-select inside the running edit), enter vector editing
|
||||
/// for selected shapes, and step groups/frames one level deeper.
|
||||
fn double_click(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
let ds = self.ds()?;
|
||||
let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page);
|
||||
@@ -2069,13 +2073,15 @@ impl Session {
|
||||
&& doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))
|
||||
&& (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target)
|
||||
{
|
||||
// Entering a text selects all of it; in the text being edited, the word.
|
||||
// Entering a text selects all of it; in the text being edited, the word. A
|
||||
// double-click inside the running edit is an ordinary edit: starting a new session
|
||||
// here would replace the IME owner's epoch and drop a composition on a plain gesture.
|
||||
let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| te.id == target);
|
||||
let idx = self.text_index_at(target, ev.p())?;
|
||||
textedit::begin(self, target, Some(idx), false)?;
|
||||
if editing {
|
||||
textedit::select_word(self, idx)?;
|
||||
} else {
|
||||
textedit::begin(self, target, Some(idx), false)?;
|
||||
textedit::key(self, "SelectAll", false, false, false)?;
|
||||
}
|
||||
return Ok(());
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Large `.fig` files at the layer cap (#981): the budget a design-system file gets, the library
|
||||
//! copies its instances need kept ahead of the pages, honest notes for what the cap kept out, and
|
||||
//! one note for the cap itself.
|
||||
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
|
||||
use super::kiwi::{Kv, Schema};
|
||||
use super::{MAX_FIG_NODES, export, import, layer_budget, map};
|
||||
|
||||
type Fields = Vec<(&'static str, Kv<'static>)>;
|
||||
|
||||
fn o(fields: Fields) -> Kv<'static> {
|
||||
Kv::Object(fields)
|
||||
}
|
||||
|
||||
fn g(id: u64) -> Kv<'static> {
|
||||
o(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(id))])
|
||||
}
|
||||
|
||||
fn xy(x: f64, y: f64) -> Kv<'static> {
|
||||
o(vec![("x", Kv::Float(x as f32)), ("y", Kv::Float(y as f32))])
|
||||
}
|
||||
|
||||
/// A layer under `parent`; siblings come in id order.
|
||||
fn node(id: u64, parent: Option<u64>, ty: &'static str, name: &str, more: Fields) -> Kv<'static> {
|
||||
let mut fields = vec![("guid", g(id)), ("phase", Kv::Enum("CREATED")), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))];
|
||||
if let Some(parent) = parent {
|
||||
fields.push(("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str(format!("{id:08}")))])));
|
||||
}
|
||||
fields.extend(more);
|
||||
o(fields)
|
||||
}
|
||||
|
||||
fn instance(id: u64, page: u64, main: u64) -> Kv<'static> {
|
||||
node(id, Some(page), "INSTANCE", "Instance", vec![("symbolData", o(vec![("symbolID", g(main))]))])
|
||||
}
|
||||
|
||||
/// A main `id` of `layers` rectangles under `parent`.
|
||||
fn main_component(id: u64, parent: u64, name: &str, layers: u64) -> Vec<Kv<'static>> {
|
||||
let mut out = vec![node(id, Some(parent), "SYMBOL", name, vec![("size", xy(10.0, 10.0))])];
|
||||
out.extend((1..=layers).map(|i| node(id * 10_000 + i, Some(id), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))])));
|
||||
out
|
||||
}
|
||||
|
||||
/// A page (2) of `count` instances of a main kept on the file's hidden library canvas (3), as a
|
||||
/// design system's file carries the library components it uses.
|
||||
fn library_message(count: u64, main_layers: u64) -> Kv<'static> {
|
||||
let mut nodes = vec![
|
||||
node(1, None, "DOCUMENT", "Document", vec![]),
|
||||
node(2, Some(1), "CANVAS", "Screens", vec![]),
|
||||
node(3, Some(1), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]),
|
||||
];
|
||||
nodes.extend(main_component(20, 3, "Library main", main_layers));
|
||||
nodes.extend((0..count).map(|i| instance(1_000 + i, 2, 20)));
|
||||
o(vec![("nodeChanges", Kv::Array(nodes))])
|
||||
}
|
||||
|
||||
/// The `.fig` import's two stages at layer cap `cap`.
|
||||
fn import_at(msg: &Kv, cap: usize) -> (Document, Vec<String>) {
|
||||
let (json, mut warnings, networks) = map::to_rest(msg, "Capped", cap);
|
||||
let (doc, more) = crate::rest::import_with_networks(&json, &|_: &str| None, networks, cap).unwrap();
|
||||
warnings.extend(more);
|
||||
(doc, warnings)
|
||||
}
|
||||
|
||||
fn linked_to(doc: &Document, main_name: &str) -> usize {
|
||||
doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { component, .. } if doc.node(component).is_some_and(|c| c.name == main_name))).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_design_system_sized_canvas_gets_room() {
|
||||
// The file in #981 stopped at 244 248 layers: a 7.8 MB canvas at 32 bytes a layer.
|
||||
let reported = 244_248 * 32;
|
||||
assert!(layer_budget(reported) >= 4 * 244_248, "{}", layer_budget(reported));
|
||||
// Small files still can't claim more than the REST import's cap, nor any file the ceiling.
|
||||
assert_eq!(layer_budget(1 << 20), crate::rest::MAX_NODES);
|
||||
assert_eq!(layer_budget(usize::MAX), MAX_FIG_NODES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_mains_come_before_the_pages_at_the_cap() {
|
||||
// 40 instances of an 11-layer library main on a page, with room for 100 layers: mapped after
|
||||
// the pages, the main found no room and every instance became a frame of "a component that
|
||||
// isn't in the file".
|
||||
let (doc, warnings) = import_at(&library_message(40, 10), 100);
|
||||
assert!(warnings.iter().any(|w| w.contains("more than 100 layers")), "{warnings:?}");
|
||||
assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}");
|
||||
let linked = linked_to(&doc, "Library main");
|
||||
assert!(linked >= 5, "{linked} instances linked to the library main: {warnings:?}");
|
||||
assert!(doc.nodes().any(|n| n.name == "Library main" && doc.is_remote(n.id)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instances_of_a_main_the_cap_kept_out_say_so() {
|
||||
// The page's instances come before their main: at the cap, the main is skipped though the
|
||||
// file has it.
|
||||
let mut nodes = vec![node(1, None, "DOCUMENT", "Document", vec![]), node(2, Some(1), "CANVAS", "Page", vec![])];
|
||||
nodes.extend((0..20).map(|i| instance(100 + i, 2, 900)));
|
||||
nodes.extend(main_component(900, 2, "Local main", 10));
|
||||
let (doc, warnings) = import_at(&o(vec![("nodeChanges", Kv::Array(nodes))]), 60);
|
||||
assert!(warnings.iter().any(|w| w.starts_with("instances whose component was past the layer limit were imported as frames")), "{warnings:?}");
|
||||
assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}");
|
||||
assert!(!doc.nodes().any(|n| n.name == "Local main"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_defaults_the_cap_kept_out_say_so() {
|
||||
// A kept main's instance swap default names a main with no instances, which comes after
|
||||
// enough layers to be skipped at the cap.
|
||||
let swap = o(vec![
|
||||
("id", g(70)),
|
||||
("name", Kv::Str("Icon".into())),
|
||||
("type", Kv::Enum("INSTANCE_SWAP")),
|
||||
("initialValue", o(vec![("guidValue", g(900))])),
|
||||
]);
|
||||
let mut nodes = vec![node(1, None, "DOCUMENT", "Document", vec![]), node(2, Some(1), "CANVAS", "Page", vec![])];
|
||||
nodes.push(node(50, Some(2), "SYMBOL", "Card", vec![("size", xy(10.0, 10.0)), ("componentPropDefs", Kv::Array(vec![swap]))]));
|
||||
nodes.extend(main_component(60, 2, "Filler", 40));
|
||||
nodes.extend(main_component(900, 2, "Icon main", 10));
|
||||
let (doc, warnings) = import_at(&o(vec![("nodeChanges", Kv::Array(nodes))]), 30);
|
||||
assert!(doc.nodes().any(|n| n.name == "Card"));
|
||||
assert!(!doc.nodes().any(|n| n.name == "Icon main"));
|
||||
assert!(warnings.iter().any(|w| w.starts_with("instance swap properties whose default component was past the layer limit")), "{warnings:?}");
|
||||
assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_file_past_the_cap_keeps_its_library_links_and_says_so_once() {
|
||||
// A 1 001-layer library main used 210 times: about 210 000 layers in a file far too small to
|
||||
// lift the cap above the REST import's 200 000.
|
||||
let file = super::tests::canvas(&Schema::parse(export::SCHEMA).unwrap(), &library_message(210, 1_000));
|
||||
assert_eq!(layer_budget(file.len()), crate::rest::MAX_NODES);
|
||||
let (doc, warnings) = import(&file, "Design system.fig").unwrap();
|
||||
let cap: Vec<_> = warnings.iter().filter(|w| w.contains("layers; the rest were skipped")).collect();
|
||||
assert_eq!(cap.len(), 1, "{warnings:?}");
|
||||
assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}");
|
||||
assert!(linked_to(&doc, "Library main") >= 190, "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_main_larger_than_the_cap_leaves_the_pages_room() {
|
||||
// A main of 300 layers with room for 100: mapped first, it took the whole budget and the page
|
||||
// came out empty.
|
||||
let (doc, warnings) = import_at(&library_message(5, 300), 100);
|
||||
let page = doc.nodes().find(|n| n.name == "Screens").expect("the page");
|
||||
let top = page.children.len();
|
||||
assert!(top >= 1, "the page kept {top} layers: {warnings:?}");
|
||||
}
|
||||
@@ -42,6 +42,14 @@ pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec<Str
|
||||
if !m.library_contexts.is_empty() {
|
||||
out[crate::rest::FIG_LIBRARY_CONTEXTS_FIELD] = Value::Array(std::mem::take(&mut m.library_contexts));
|
||||
}
|
||||
// Mains in the file that the layer cap kept out: their instances are told apart from those of
|
||||
// components the file doesn't have (#981).
|
||||
if m.count >= m.max_nodes {
|
||||
let capped: Vec<Value> = m.referenced_mains.difference(&m.mapped_mains).map(|id| json!(id)).collect();
|
||||
if !capped.is_empty() {
|
||||
out[crate::rest::FIG_CAPPED_MAINS_FIELD] = Value::Array(capped);
|
||||
}
|
||||
}
|
||||
if !m.styles.is_empty() {
|
||||
out["styles"] = Value::Object(std::mem::take(&mut m.styles));
|
||||
}
|
||||
@@ -58,7 +66,7 @@ pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec<Str
|
||||
if !styles.is_empty() {
|
||||
out["styleDefinitions"] = Value::Array(styles);
|
||||
}
|
||||
(out, m.warnings, m.networks)
|
||||
(out, m.warnings.finish(), m.networks)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -387,11 +395,18 @@ struct Mapper<'a> {
|
||||
library_contexts: Vec<Value>,
|
||||
/// Layers visited while scanning internal canvases, across all of them.
|
||||
library_scan_count: usize,
|
||||
warnings: Vec<String>,
|
||||
warnings: crate::rest::Warnings,
|
||||
count: usize,
|
||||
/// The mains in the file that instances or instance swap defaults name, and the mains mapped
|
||||
/// as layers: the difference is what the layer cap kept out.
|
||||
referenced_mains: BTreeSet<String>,
|
||||
mapped_mains: BTreeSet<String>,
|
||||
/// Most layers produced or scanned, instance expansion included; the one place the cap comes
|
||||
/// from (see [`super::layer_budget`]).
|
||||
max_nodes: usize,
|
||||
/// Layers kept back for the visible pages' top level while library copies are mapped, so the
|
||||
/// copies can't take the whole budget and leave the pages empty.
|
||||
reserved: usize,
|
||||
/// Vector networks, each built once: the layers that show one (every instance of a main
|
||||
/// holding it) name it by `vectorNetworkRef`. Kept out of the JSON, where a large file's
|
||||
/// networks took gigabytes.
|
||||
@@ -464,9 +479,12 @@ impl<'a> Mapper<'a> {
|
||||
override_scope_entries: 0,
|
||||
library_contexts: Vec::new(),
|
||||
library_scan_count: 0,
|
||||
warnings: Vec::new(),
|
||||
warnings: crate::rest::Warnings::default(),
|
||||
count: 0,
|
||||
referenced_mains: BTreeSet::new(),
|
||||
mapped_mains: BTreeSet::new(),
|
||||
max_nodes,
|
||||
reserved: 0,
|
||||
networks: Vec::new(),
|
||||
network_ids: HashMap::new(),
|
||||
}
|
||||
@@ -493,12 +511,14 @@ impl<'a> Mapper<'a> {
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// A note about what this file's mapping left out or changed. Counted, as the REST importer
|
||||
/// counts its own: these notes are met once per layer, so the kinds were named once each with
|
||||
/// no sign of how much of the file they covered -- a file whose every text layer lost a
|
||||
/// setting read the same as one where a single layer did (#467). The collector keeps the
|
||||
/// first-seen order and the same cap, since some notes name fields of the file (image
|
||||
/// filters).
|
||||
fn warn(&mut self, w: impl Into<String>) {
|
||||
let w = w.into();
|
||||
// Capped like the REST importer's list: some warnings name fields of the file (image filters).
|
||||
if self.warnings.len() < crate::rest::MAX_WARNINGS && !self.warnings.contains(&w) {
|
||||
self.warnings.push(w);
|
||||
}
|
||||
self.warnings.add(w.into());
|
||||
}
|
||||
|
||||
fn children(&self, id: &str) -> Vec<String> {
|
||||
@@ -549,6 +569,11 @@ impl<'a> Mapper<'a> {
|
||||
scope
|
||||
}
|
||||
|
||||
/// How many layers may be produced now: the cap, less what is kept for the visible pages.
|
||||
fn budget(&self) -> usize {
|
||||
self.max_nodes.saturating_sub(self.reserved)
|
||||
}
|
||||
|
||||
/// The DOCUMENT with its pages.
|
||||
fn document(&mut self) -> Value {
|
||||
let doc_id = self.nodes.iter().find(|(_, n)| n.get("type").and_then(Kv::str) == Some("DOCUMENT")).map(|(id, _)| id.clone());
|
||||
@@ -556,9 +581,8 @@ impl<'a> Mapper<'a> {
|
||||
Some(d) => self.children(d),
|
||||
None => self.nodes.iter().filter(|(_, n)| n.get("type").and_then(Kv::str) == Some("CANVAS")).map(|(id, _)| id.clone()).collect(),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut internal = Vec::new();
|
||||
let mut visible_pages = Vec::new();
|
||||
let mut visible = Vec::new();
|
||||
for p in pages {
|
||||
let Some(&n) = self.nodes.get(&p) else { continue };
|
||||
if n.get("type").and_then(Kv::str) != Some("CANVAS") {
|
||||
@@ -568,17 +592,24 @@ impl<'a> Mapper<'a> {
|
||||
// The hidden page that holds styles, variables and library copies.
|
||||
if n.get("internalOnly").and_then(Kv::bool) == Some(true) {
|
||||
internal.push((p, n));
|
||||
continue;
|
||||
} else {
|
||||
visible.push((p, n));
|
||||
}
|
||||
visible_pages.push(p.clone());
|
||||
out.push(self.page(&p, n));
|
||||
}
|
||||
// The library copies the pages use come first: in a file past the layer cap, the pages'
|
||||
// last layers are skipped rather than the mains every instance of them needs (#981).
|
||||
let visible_pages: Vec<String> = visible.iter().map(|(p, _)| p.clone()).collect();
|
||||
let required_mains = self.referenced_component_mains(&visible_pages);
|
||||
// The pages and their top-level layers, up to half the cap, stay for the pages.
|
||||
let top: usize = visible_pages.iter().map(|p| 1 + self.children(p).len()).sum();
|
||||
self.reserved = top.min(self.max_nodes / 2);
|
||||
for (id, canvas) in internal {
|
||||
if let Some(context) = self.library_context(&id, canvas, &required_mains) {
|
||||
self.library_contexts.push(context);
|
||||
}
|
||||
}
|
||||
self.reserved = 0;
|
||||
let out: Vec<Value> = visible.into_iter().map(|(p, n)| self.page(&p, n)).collect();
|
||||
if out.is_empty() {
|
||||
self.warn("the file has no pages");
|
||||
}
|
||||
@@ -822,7 +853,7 @@ impl<'a> Mapper<'a> {
|
||||
self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped"));
|
||||
return None;
|
||||
}
|
||||
if self.count >= self.max_nodes {
|
||||
if self.count >= self.budget() {
|
||||
let max = self.max_nodes;
|
||||
self.warn(format!("the file has more than {max} layers; the rest were skipped"));
|
||||
return None;
|
||||
@@ -859,7 +890,7 @@ impl<'a> Mapper<'a> {
|
||||
let connector_snapshot = own == "CONNECTOR" && self.connector_snapshot_supported(&view);
|
||||
// Only a connector with a saved label needs the frame wrapper; an unlabelled one stays a single vector.
|
||||
let connector_terminal =
|
||||
connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.max_nodes);
|
||||
connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.budget());
|
||||
// A supported connector snapshot has its own note below instead of the generic stand-in one.
|
||||
let stand = stand_in(own).filter(|_| !connector_snapshot);
|
||||
let ty = match own {
|
||||
@@ -879,6 +910,9 @@ impl<'a> Mapper<'a> {
|
||||
self.warn("connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained");
|
||||
}
|
||||
self.count += 1;
|
||||
if own == "SYMBOL" && inst.is_none() {
|
||||
self.mapped_mains.insert(id.to_string());
|
||||
}
|
||||
let mut o = Obj::new();
|
||||
o.insert("id".into(), json!(rest_id));
|
||||
// Private provenance for the .fig importer; ordinary REST import ignores it.
|
||||
@@ -1004,7 +1038,7 @@ impl<'a> Mapper<'a> {
|
||||
fn shape_with_text(&mut self, o: &mut Obj, v: &View<'a>, own: &str, rest_id: &str) -> Option<Value> {
|
||||
let shape = v.str("shapeWithTextType").unwrap_or("SQUARE");
|
||||
let outlined = !v.list("fillGeometry").is_empty() || !v.list("strokeGeometry").is_empty();
|
||||
if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.max_nodes {
|
||||
if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.budget() {
|
||||
return None;
|
||||
}
|
||||
let mut s = Obj::new();
|
||||
@@ -1036,7 +1070,7 @@ impl<'a> Mapper<'a> {
|
||||
/// layer filling it, inset a little; centred unless it's a note.
|
||||
fn inner_text(&mut self, o: &Obj, v: &View<'a>, rest_id: &str, note: bool) -> Option<Value> {
|
||||
let chars = own_text(v)?;
|
||||
if self.count >= self.max_nodes {
|
||||
if self.count >= self.budget() {
|
||||
return None;
|
||||
}
|
||||
self.count += 1;
|
||||
@@ -1171,7 +1205,7 @@ impl<'a> Mapper<'a> {
|
||||
out.insert("opacity".into(), json!(root_opacity * line_opacity));
|
||||
}
|
||||
if connector_has_saved_label(v) {
|
||||
let reason = if self.count >= self.max_nodes { "node budget" } else { "depth limit" };
|
||||
let reason = if self.count >= self.budget() { "node budget" } else { "depth limit" };
|
||||
self.warn(format!("connector label was skipped: {reason} prevents nested snapshot children"));
|
||||
}
|
||||
}
|
||||
@@ -1201,7 +1235,7 @@ impl<'a> Mapper<'a> {
|
||||
|
||||
/// Snapshot the straight, manual connector contract carried by its generated children.
|
||||
fn connector_snapshot_children(&mut self, v: &View<'a>, rest_id: &str) -> Vec<Value> {
|
||||
if self.count >= self.max_nodes {
|
||||
if self.count >= self.budget() {
|
||||
self.warn(format!("the file has more than {} layers; connector snapshot children were skipped", self.max_nodes));
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -1264,7 +1298,7 @@ impl<'a> Mapper<'a> {
|
||||
};
|
||||
let background_view = View::of(background).with([background_style]);
|
||||
if !background_view.list("fillPaints").is_empty() {
|
||||
if self.count >= self.max_nodes {
|
||||
if self.count >= self.budget() {
|
||||
self.warn(format!("the file has more than {} layers; connector label background was skipped", self.max_nodes));
|
||||
} else {
|
||||
let mut rect = Obj::new();
|
||||
@@ -1292,7 +1326,7 @@ impl<'a> Mapper<'a> {
|
||||
transform: Affine,
|
||||
view: &View<'a>,
|
||||
) {
|
||||
if self.count >= self.max_nodes {
|
||||
if self.count >= self.budget() {
|
||||
self.warn(format!("the file has more than {} layers; connector label was skipped", self.max_nodes));
|
||||
return;
|
||||
}
|
||||
@@ -1850,7 +1884,12 @@ impl<'a> Mapper<'a> {
|
||||
"BOOL" => json!({"type": "BOOLEAN", "defaultValue": init.and_then(|i| i.get("boolValue")).and_then(Kv::bool).unwrap_or(true)}),
|
||||
"TEXT" => json!({"type": "TEXT", "defaultValue": init.and_then(prop_text).unwrap_or("")}),
|
||||
"INSTANCE_SWAP" => match init.and_then(|i| i.get("guidValue")).and_then(guid) {
|
||||
Some(g) => json!({"type": "INSTANCE_SWAP", "defaultValue": g}),
|
||||
Some(g) => {
|
||||
if self.nodes.contains_key(&g) {
|
||||
self.referenced_mains.insert(g.clone());
|
||||
}
|
||||
json!({"type": "INSTANCE_SWAP", "defaultValue": g})
|
||||
}
|
||||
None => continue,
|
||||
},
|
||||
"VARIANT" => json!({"type": "VARIANT"}),
|
||||
@@ -1994,6 +2033,9 @@ impl<'a> Mapper<'a> {
|
||||
self.warn("instances of components that aren't in the file show no layers");
|
||||
return None;
|
||||
}
|
||||
if !self.referenced_mains.contains(&sym) {
|
||||
self.referenced_mains.insert(sym.clone());
|
||||
}
|
||||
if outer.is_some_and(|i| i.symbols.contains(&sym)) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -115,14 +115,14 @@ fn connector_snapshot_respects_last_and_zero_child_slots() {
|
||||
assert!(!children[0]["fillGeometry"].as_array().unwrap().is_empty(), "saved outline survives the budget limit");
|
||||
assert_eq!(children[0]["fillGeometry"][0]["path"], "M 0 0 L 20 10");
|
||||
assert_eq!(mapper.count, MAX_NODES);
|
||||
assert!(mapper.warnings.iter().any(|warning| warning == &format!("the file has more than {MAX_NODES} layers; connector label was skipped")));
|
||||
assert!(mapper.warnings.seen().any(|warning| warning == format!("the file has more than {MAX_NODES} layers; connector label was skipped")));
|
||||
|
||||
let mut mapper = Mapper::new(&message, MAX_NODES);
|
||||
mapper.count = MAX_NODES;
|
||||
let children = mapper.connector_snapshot_children(&view, "1:123");
|
||||
assert!(children.is_empty());
|
||||
assert_eq!(mapper.count, MAX_NODES, "the node budget is never exceeded");
|
||||
assert!(mapper.warnings.iter().any(|warning| warning.contains("connector snapshot children were skipped")));
|
||||
assert!(mapper.warnings.seen().any(|warning| warning.contains("connector snapshot children were skipped")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -133,7 +133,7 @@ fn connector_snapshot_flattens_vector_at_depth_and_node_limits() {
|
||||
assert_eq!(depth_limited["type"], "VECTOR");
|
||||
assert!(!depth_limited["fillGeometry"].as_array().unwrap().is_empty());
|
||||
assert!(depth_limited.get("children").is_none());
|
||||
assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children"));
|
||||
assert!(mapper.warnings.seen().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children"));
|
||||
|
||||
let mut mapper = Mapper::new(&message, MAX_NODES);
|
||||
mapper.count = MAX_NODES - 1;
|
||||
@@ -142,5 +142,37 @@ fn connector_snapshot_flattens_vector_at_depth_and_node_limits() {
|
||||
assert!(!budget_limited["fillGeometry"].as_array().unwrap().is_empty());
|
||||
assert!(budget_limited.get("children").is_none());
|
||||
assert_eq!(mapper.count, MAX_NODES);
|
||||
assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: node budget prevents nested snapshot children"));
|
||||
assert!(mapper.warnings.seen().any(|warning| warning == "connector label was skipped: node budget prevents nested snapshot children"));
|
||||
}
|
||||
|
||||
/// A mapper note is met once per layer, so the kinds were named once each with no sign of how much
|
||||
/// of the file they covered: a file whose every layer lost a setting read the same as one where a
|
||||
/// single layer did (#467). They are counted now, with the `(×n)` the REST importer already uses
|
||||
/// and the import report already reads back off before translating a note.
|
||||
#[test]
|
||||
fn a_note_met_more_than_once_carries_its_count() {
|
||||
let message = message();
|
||||
|
||||
let mut mapper = Mapper::new(&message, MAX_NODES);
|
||||
for _ in 0..2 {
|
||||
mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector");
|
||||
}
|
||||
let notes = std::mem::take(&mut mapper.warnings).finish();
|
||||
let counted: Vec<&String> = notes.iter().filter(|n| n.contains("(×")).collect();
|
||||
assert!(
|
||||
counted.iter().any(|n| n.ends_with("(×2)") && n.contains("connector label was skipped: depth limit")),
|
||||
"the note met twice says so: {notes:?}"
|
||||
);
|
||||
assert!(notes.iter().all(|n| !n.ends_with("(×1)")), "a count of one is left off: {notes:?}");
|
||||
|
||||
// One layer, and the same notes have no count at all.
|
||||
let mut mapper = Mapper::new(&message, MAX_NODES);
|
||||
mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector");
|
||||
let once = std::mem::take(&mut mapper.warnings).finish();
|
||||
assert!(once.iter().all(|n| !n.contains("(×")), "{once:?}");
|
||||
assert_eq!(
|
||||
once.len(),
|
||||
counted.len() + notes.iter().filter(|n| !n.contains("(×")).count(),
|
||||
"the same kinds, counted or not: {once:?} vs {notes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ mod export_tests;
|
||||
#[cfg(test)]
|
||||
mod interop_tests;
|
||||
pub mod kiwi;
|
||||
#[cfg(test)]
|
||||
mod layer_cap_tests;
|
||||
mod map;
|
||||
mod network;
|
||||
#[cfg(test)]
|
||||
@@ -64,8 +66,11 @@ const MAX_CHUNKS: usize = 16;
|
||||
/// compressed canvas, at least the REST import's cap and at most [`MAX_FIG_NODES`]. Instances
|
||||
/// expand into a layer per layer of their component, and chunks inflate up to [`MAX_CHUNK`], so the
|
||||
/// budget follows what the file takes, not what it unpacks to: a small file can't claim a large
|
||||
/// count, however it's built; a large one gets room.
|
||||
const PACKED_BYTES_PER_LAYER: usize = 32;
|
||||
/// count, however it's built; a large one gets room. Design systems are mostly instances, which
|
||||
/// pack tightly: a 7.8 MB canvas stopped at 244 248 layers at 32 bytes a layer (#981). At 8, files
|
||||
/// under about 1.6 MB keep the floor and the ceiling comes at a 16 MB canvas (about 11 GB and 40 s
|
||||
/// to import 2 million synthetic layers; memory grows with the count, about 5.4 KB a layer).
|
||||
const PACKED_BYTES_PER_LAYER: usize = 8;
|
||||
const MAX_FIG_NODES: usize = 2_000_000;
|
||||
|
||||
/// Most layers imported from a `.fig` file whose compressed canvas is `packed` bytes.
|
||||
@@ -108,7 +113,13 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), Forma
|
||||
drop(msg);
|
||||
let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).cloned());
|
||||
let (doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?;
|
||||
warnings.extend(more.into_iter().filter_map(map::adapt_warning));
|
||||
// Both stages stop at the same cap and say so in the same words: one note (#981).
|
||||
for w in more.into_iter().filter_map(map::adapt_warning) {
|
||||
let base = w.rsplit_once(" (×").map_or(w.as_str(), |(b, _)| b);
|
||||
if !warnings.iter().any(|seen| seen == base) {
|
||||
warnings.push(w);
|
||||
}
|
||||
}
|
||||
Ok((doc, warnings))
|
||||
}
|
||||
|
||||
|
||||
@@ -343,6 +343,20 @@ pub(crate) fn put<W: Write + Seek>(
|
||||
z.write_all(bytes).map_err(io_err)
|
||||
}
|
||||
|
||||
/// `files` (name, bytes; a `/` in a name makes a folder) as one zip archive: several exports
|
||||
/// delivered as a single download (#969).
|
||||
pub fn zip_files<'a>(files: impl IntoIterator<Item = (&'a str, &'a [u8])>) -> Result<Vec<u8>, FormatError> {
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
{
|
||||
let mut z = zip::ZipWriter::new(&mut buf);
|
||||
for (name, bytes) in files {
|
||||
put(&mut z, name, zip::write::SimpleFileOptions::default(), bytes)?;
|
||||
}
|
||||
z.finish().map_err(io_err)?;
|
||||
}
|
||||
Ok(buf.into_inner())
|
||||
}
|
||||
|
||||
/// Add `doc` as a `document.json`-style member, serialized straight into the compressor.
|
||||
fn put_json<W: Write + Seek>(z: &mut zip::ZipWriter<W>, name: &str, doc: &Document) -> Result<(), FormatError> {
|
||||
z.start_file(name, json_options()).map_err(io_err)?;
|
||||
|
||||
@@ -65,6 +65,8 @@ const MAX_DEPTH: usize = 256;
|
||||
pub(crate) const FIG_SOURCE_FIELD: &str = "_figSource";
|
||||
/// Hidden .fig library context; accepted only by the `.fig` import ([`import_with_networks`]).
|
||||
pub(crate) const FIG_LIBRARY_CONTEXTS_FIELD: &str = "_figLibraryContexts";
|
||||
/// Ids of mains in a `.fig` file that its layer cap kept out; read only by the `.fig` import.
|
||||
pub(crate) const FIG_CAPPED_MAINS_FIELD: &str = "_figCappedMains";
|
||||
/// Longest SVG path string read from one geometry entry.
|
||||
const MAX_PATH_BYTES: usize = 4 << 20;
|
||||
/// Most path elements in one vector.
|
||||
@@ -215,6 +217,14 @@ fn import_parts(
|
||||
cx.style_definitions(v);
|
||||
cx.networks = networks;
|
||||
cx.max_nodes = max_nodes;
|
||||
// The `.fig` mapper already kept the layers, library copies included, within the cap; each
|
||||
// library copy's wrapper frame is the one layer here it doesn't count (#981).
|
||||
let contexts = if fig_sources { v.get(FIG_LIBRARY_CONTEXTS_FIELD).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() } else { &[] };
|
||||
if fig_sources {
|
||||
cx.capped_mains =
|
||||
v.get(FIG_CAPPED_MAINS_FIELD).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect();
|
||||
cx.max_nodes = max_nodes.saturating_add(contexts.len());
|
||||
}
|
||||
for e in entries {
|
||||
let Some(root) = e.get("document") else { continue };
|
||||
match text(root, "type") {
|
||||
@@ -236,11 +246,9 @@ fn import_parts(
|
||||
None => cx.warn("entries without a node type were skipped"),
|
||||
}
|
||||
}
|
||||
if fig_sources {
|
||||
for context in v.get(FIG_LIBRARY_CONTEXTS_FIELD).and_then(Value::as_array).into_iter().flatten() {
|
||||
let store = cx.doc.ensure_library_store();
|
||||
cx.build(context, store, Space::default(), None, 0, false);
|
||||
}
|
||||
for context in contexts {
|
||||
let store = cx.doc.ensure_library_store();
|
||||
cx.build(context, store, Space::default(), None, 0, false);
|
||||
}
|
||||
// Drop the empty page the new document started with once real pages exist.
|
||||
if let Some(p) = blank
|
||||
@@ -329,15 +337,16 @@ fn children(v: &Value) -> &[Value] {
|
||||
v.get("children").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Distinct warnings in first-seen order, with repeat counts.
|
||||
/// Distinct warnings in first-seen order, with repeat counts. Shared with the `.fig` mapper
|
||||
/// (`crate::fig::map`), whose notes repeat per layer in the same way.
|
||||
#[derive(Default)]
|
||||
struct Warnings {
|
||||
pub(crate) struct Warnings {
|
||||
list: Vec<(String, usize)>,
|
||||
index: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Warnings {
|
||||
fn add(&mut self, msg: String) {
|
||||
pub(crate) fn add(&mut self, msg: String) {
|
||||
if let Some(i) = self.index.get(&msg) {
|
||||
if let Some(e) = self.list.get_mut(*i) {
|
||||
e.1 += 1;
|
||||
@@ -348,7 +357,17 @@ impl Warnings {
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(self) -> Vec<String> {
|
||||
/// The notes met so far, without their counts, for the tests that ask whether one was met
|
||||
/// (the product reads them through [`Self::finish`], with the counts).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn seen(&self) -> impl Iterator<Item = &str> {
|
||||
self.list.iter().map(|(msg, _)| msg.as_str())
|
||||
}
|
||||
|
||||
/// The notes, each with `(×n)` after it where it was met more than once. The import report
|
||||
/// reads that suffix off before it looks the note up in the catalogue, so a counted note is
|
||||
/// still translated (`ui-egui`'s `note_text`).
|
||||
pub(crate) fn finish(self) -> Vec<String> {
|
||||
self.list.into_iter().map(|(m, n)| if n > 1 { format!("{m} (×{n})") } else { m }).collect()
|
||||
}
|
||||
}
|
||||
@@ -404,6 +423,8 @@ pub(crate) struct Ctx<'a> {
|
||||
networks: Vec<Option<VectorNetwork>>,
|
||||
/// Most layers imported: [`MAX_NODES`], or the `.fig` import's own cap.
|
||||
max_nodes: usize,
|
||||
/// Mains the `.fig` file has but its layer cap kept out ([`FIG_CAPPED_MAINS_FIELD`]).
|
||||
capped_mains: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
@@ -432,6 +453,7 @@ impl<'a> Ctx<'a> {
|
||||
var_modes: BTreeMap::new(),
|
||||
networks: Vec::new(),
|
||||
max_nodes: MAX_NODES,
|
||||
capped_mains: BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -998,7 +1020,11 @@ impl<'a> Ctx<'a> {
|
||||
fn resolve_swap_defaults(&mut self) {
|
||||
for (owner, name, rest_id) in std::mem::take(&mut self.swap_defaults) {
|
||||
let Some(c) = self.component(&rest_id) else {
|
||||
self.warn("instance swap properties whose default component isn't in the file were skipped");
|
||||
if self.capped_mains.contains(&rest_id) {
|
||||
self.warn("instance swap properties whose default component was past the layer limit were skipped");
|
||||
} else {
|
||||
self.warn("instance swap properties whose default component isn't in the file were skipped");
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let def = PropDef::InstanceSwap { default: c, preferred: Vec::new() };
|
||||
@@ -1017,7 +1043,11 @@ impl<'a> Ctx<'a> {
|
||||
let frames: BTreeSet<NodeId> = self.instances.iter().map(|(i, _)| *i).collect();
|
||||
let mut links: Vec<(NodeId, NodeId, &'a Value)> = Vec::new();
|
||||
for (inst, j) in std::mem::take(&mut self.instances) {
|
||||
match text(j, "componentId").and_then(|r| self.component(r)) {
|
||||
let main = text(j, "componentId");
|
||||
match main.and_then(|r| self.component(r)) {
|
||||
None if main.is_some_and(|r| self.capped_mains.contains(r)) => {
|
||||
self.warn("instances whose component was past the layer limit were imported as frames");
|
||||
}
|
||||
None => self.warn("instances of components that aren't in the file were imported as frames"),
|
||||
Some(c) if self.doc.is_ancestor(c, inst) => self.warn("instances inside their own main component were imported as frames"),
|
||||
Some(c) => links.push((inst, c, j)),
|
||||
|
||||
+18
-17
@@ -128,10 +128,10 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"batch",
|
||||
"Run commands as one step",
|
||||
"Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. A later step can use a layer an earlier step created: anywhere in its params, {\"$step\": n} stands for the `id` in step n's result (zero-based; {\"$step\": n, \"field\": \"…\"} picks another field). Example: [{\"command\":\"node.create\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$step\":0}}}] builds a frame and a text inside it in one call.",
|
||||
"Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. A later step can use what an earlier step returned: name a step with \"as\": \"card\", then anywhere in a later step's params {\"$ref\": \"card\"} stands for the `id` in its result ({\"$step\": n} does the same by zero-based position). Add \"field\" for another part of the result: a key or a slash path, e.g. {\"$ref\": \"copy\", \"field\": \"ids/0\"} for the first id edit.duplicate returned. Any object with a `$ref` or `$step` key is a reference. Example: [{\"command\":\"node.create\",\"as\":\"card\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$ref\":\"card\"}}}] builds a frame and a text inside it in one call.",
|
||||
obj(
|
||||
json!({
|
||||
"commands": {"type": "array", "minItems": 1, "description": "Steps to run", "items": obj(json!({"command": string("Command id"), "params": {"type": "object"}}), &["command"])},
|
||||
"commands": {"type": "array", "minItems": 1, "description": "Steps to run", "items": obj(json!({"command": string("Command id"), "params": {"type": "object"}, "as": string("Name for this step, unique in the batch; later steps use its result with {\"$ref\": name}")}), &["command"])},
|
||||
"label": string("Undo history label (default \"Batch\")"),
|
||||
}),
|
||||
&["commands"],
|
||||
@@ -513,7 +513,11 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, Stri
|
||||
};
|
||||
let mut params = params;
|
||||
friendly_params(&mut params);
|
||||
steps.push(json!({"command": cmd, "params": params}));
|
||||
let mut step = json!({"command": cmd, "params": params});
|
||||
if let (Some(name), Some(o)) = (c.get("as"), step.as_object_mut()) {
|
||||
o.insert("as".into(), name.clone());
|
||||
}
|
||||
steps.push(step);
|
||||
}
|
||||
let label = s_arg(a, "label")?.unwrap_or("Batch");
|
||||
exec(b, "command.batch", json!({"label": label, "commands": steps})).map(|v| ToolResult::json(&v))
|
||||
@@ -742,7 +746,7 @@ fn get_node(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, S
|
||||
}
|
||||
}
|
||||
if used_wire + MARKER_RESERVE <= budget {
|
||||
return Ok(ToolResult::json(&with_marker(value, depth, used, wire, used_wire, max_bytes, true, None)));
|
||||
return Ok(ToolResult::json(&with_marker(value, Cut { asked: depth, used, asked_wire: wire, used_wire }, max_bytes, true, None)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,10 +771,7 @@ fn get_node(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, S
|
||||
let over = wire > budget || next.is_some();
|
||||
let mut out = with_marker(
|
||||
value,
|
||||
depth,
|
||||
used,
|
||||
wire,
|
||||
used_wire,
|
||||
Cut { asked: depth, used, asked_wire: wire, used_wire },
|
||||
max_bytes,
|
||||
over,
|
||||
Some(json!({
|
||||
@@ -812,19 +813,19 @@ fn children_window(value: &mut Value, offset: usize, budget: usize) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// `value` with the `truncated` object saying how it was cut. `over`: the subtree asked for passed
|
||||
/// the limit (else the window is only the `offset` asked for). `children`: the window, when the
|
||||
/// children were cut as well as (or instead of) the depth.
|
||||
fn with_marker(
|
||||
mut value: Value,
|
||||
/// How a subtree was cut: the depth asked for and the depth reported, with their sizes on the wire.
|
||||
struct Cut {
|
||||
asked: u64,
|
||||
used: u64,
|
||||
asked_wire: usize,
|
||||
used_wire: usize,
|
||||
max_bytes: usize,
|
||||
over: bool,
|
||||
children: Option<Value>,
|
||||
) -> Value {
|
||||
}
|
||||
|
||||
/// `value` with the `truncated` object saying how it was cut. `over`: the subtree asked for passed
|
||||
/// the limit (else the window is only the `offset` asked for). `children`: the window, when the
|
||||
/// children were cut as well as (or instead of) the depth.
|
||||
fn with_marker(mut value: Value, cut: Cut, max_bytes: usize, over: bool, children: Option<Value>) -> Value {
|
||||
let Cut { asked, used, asked_wire, used_wire } = cut;
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
let mut marker = json!({
|
||||
"requestedDepth": asked,
|
||||
|
||||
@@ -343,3 +343,32 @@ fn task_card_in_one_batch() {
|
||||
);
|
||||
assert!(e.contains("step 0") && e.contains("earlier step"), "{e}");
|
||||
}
|
||||
|
||||
/// Named steps and a path into a list result: duplicate a frame, then rename the copy through
|
||||
/// `{"$ref": "copy", "field": "ids/0"}` instead of counting steps.
|
||||
#[test]
|
||||
fn task_named_steps_and_a_duplicate_in_one_batch() {
|
||||
let mut c = Client::new();
|
||||
let out = c.ok(
|
||||
"batch",
|
||||
json!({"label": "Copy", "commands": [
|
||||
{"command": "node.create", "as": "card", "params": {"type": "frame", "name": "Card", "width": 100, "height": 50}},
|
||||
{"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}},
|
||||
{"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": "ids/0"}], "props": {"name": "Card copy"}}},
|
||||
]}),
|
||||
);
|
||||
let results = ids(&out);
|
||||
let copy = results[1]["ids"][0].clone();
|
||||
assert_eq!(c.node(©)["name"], "Card copy");
|
||||
assert_eq!(c.node(&results[0]["id"])["name"], "Card", "the original keeps its name");
|
||||
assert_eq!(c.doc()["history"]["undo"][0], "Copy", "one undo step");
|
||||
// An unknown name fails the whole batch, names the step and lists the names there are.
|
||||
let e = c.err(
|
||||
"batch",
|
||||
json!({"commands": [
|
||||
{"command": "node.create", "as": "card", "params": {"type": "frame"}},
|
||||
{"command": "node.set", "params": {"ids": [{"$ref": "crad"}], "props": {"name": "x"}}},
|
||||
]}),
|
||||
);
|
||||
assert!(e.contains("step 1") && e.contains("\"crad\" names no step") && e.contains("\"card\" is step 0"), "{e}");
|
||||
}
|
||||
|
||||
+98
-27
@@ -292,7 +292,9 @@ pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath
|
||||
.with_miter_limit(props.miter_limit.clamp(1.0, 100.0))
|
||||
.with_start_cap(kcap(props.cap_start))
|
||||
.with_end_cap(kcap(props.cap_end));
|
||||
if let Some(pat) = width::dash_pattern(props) {
|
||||
// Each subpath restarts the pattern, so it can add up to one more pattern's worth of dashes.
|
||||
let subpaths = path.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count() as f64;
|
||||
if let Some(pat) = width::dash_pattern(props).filter(|p| width::dashes_fit(path.perimeter(1.0) + subpaths * p.iter().sum::<f64>(), p)) {
|
||||
style = style.with_dashes(0.0, pat);
|
||||
}
|
||||
kurbo::stroke(path.iter(), &style, &kurbo::StrokeOpts::default(), 0.05)
|
||||
@@ -359,36 +361,35 @@ pub fn stroke_outline_of(path: &BezPath, props: &StrokeProps, closed_shape: bool
|
||||
|
||||
/// End point and outward direction of an open path at its start or end.
|
||||
fn path_end(path: &BezPath, at_end: bool) -> Option<(Point, Vec2)> {
|
||||
let segs: Vec<PathSeg> = path.segments().collect();
|
||||
let seg = if at_end { segs.last()? } else { segs.first()? };
|
||||
use kurbo::ParamCurve;
|
||||
use kurbo::ParamCurveDeriv;
|
||||
let (p, d) = if at_end {
|
||||
let d = match seg {
|
||||
PathSeg::Line(l) => l.p1 - l.p0,
|
||||
PathSeg::Quad(q) => q.deriv().eval(1.0).to_vec2(),
|
||||
PathSeg::Cubic(c) => {
|
||||
let d = c.deriv().eval(1.0).to_vec2();
|
||||
if d.hypot() < 1e-9 { c.p3 - c.p2 } else { d }
|
||||
}
|
||||
let dir_of = |seg: &PathSeg| -> Option<(Point, Vec2)> {
|
||||
let (p, d) = if at_end {
|
||||
let d = match seg {
|
||||
PathSeg::Line(l) => l.p1 - l.p0,
|
||||
PathSeg::Quad(q) => q.deriv().eval(1.0).to_vec2(),
|
||||
PathSeg::Cubic(c) => {
|
||||
let d = c.deriv().eval(1.0).to_vec2();
|
||||
if d.hypot() < 1e-9 { c.p3 - c.p2 } else { d }
|
||||
}
|
||||
};
|
||||
(seg.end(), d)
|
||||
} else {
|
||||
let d = match seg {
|
||||
PathSeg::Line(l) => l.p0 - l.p1,
|
||||
PathSeg::Quad(q) => -q.deriv().eval(0.0).to_vec2(),
|
||||
PathSeg::Cubic(c) => {
|
||||
let d = -c.deriv().eval(0.0).to_vec2();
|
||||
if d.hypot() < 1e-9 { c.p0 - c.p1 } else { d }
|
||||
}
|
||||
};
|
||||
(seg.start(), d)
|
||||
};
|
||||
(seg.end(), d)
|
||||
} else {
|
||||
let d = match seg {
|
||||
PathSeg::Line(l) => l.p0 - l.p1,
|
||||
PathSeg::Quad(q) => -q.deriv().eval(0.0).to_vec2(),
|
||||
PathSeg::Cubic(c) => {
|
||||
let d = -c.deriv().eval(0.0).to_vec2();
|
||||
if d.hypot() < 1e-9 { c.p0 - c.p1 } else { d }
|
||||
}
|
||||
};
|
||||
(seg.start(), d)
|
||||
let len = d.hypot();
|
||||
(len >= 1e-12).then(|| (p, d / len))
|
||||
};
|
||||
let len = d.hypot();
|
||||
if len < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
Some((p, d / len))
|
||||
// Skip zero-length segments (repeated vertices) and use the nearest one with a direction.
|
||||
if at_end { path.segments().collect::<Vec<_>>().iter().rev().find_map(dir_of) } else { path.segments().find_map(|s| dir_of(&s)) }
|
||||
}
|
||||
|
||||
/// The filled arrowhead shape for `cap` at one end of an open path.
|
||||
@@ -693,6 +694,29 @@ mod tests {
|
||||
assert_ne!(explicit, layer_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_end_arrow_is_drawn_when_a_closed_run_follows_the_open_one() {
|
||||
let verts = [(0.0, 0.0), (50.0, 0.0), (0.0, 100.0), (50.0, 100.0), (25.0, 150.0)];
|
||||
let net = VectorNetwork {
|
||||
vertices: verts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &(x, y))| figcraft_doc::Vertex { cap: (i == 0).then_some(StrokeCap::Round), ..figcraft_doc::Vertex::new(x, y) })
|
||||
.collect(),
|
||||
segments: vec![
|
||||
figcraft_doc::Segment::line(0, 1),
|
||||
figcraft_doc::Segment::line(2, 3),
|
||||
figcraft_doc::Segment::line(3, 4),
|
||||
figcraft_doc::Segment::line(4, 2),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let path = BezPath::new();
|
||||
let plain = StrokeProps { weight: 2.0, ..Default::default() };
|
||||
let arrow = StrokeProps { cap_end: StrokeCap::TriangleArrow, ..plain.clone() };
|
||||
assert_ne!(stroke_outline_of(&path, &arrow, false, Some(&net)), stroke_outline_of(&path, &plain, false, Some(&net)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_joins_do_not_break_closed_stroke_runs() {
|
||||
let path = sq(0.0, 0.0, 20.0);
|
||||
@@ -719,6 +743,35 @@ mod tests {
|
||||
assert!((s.area().abs() - 100.0).abs() < 2.0, "{}", s.area());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dash_pattern_too_fine_for_its_path_strokes_solid() {
|
||||
// ~500k dashes on this line (#1060): drawn solid, as a variable width stroke is past
|
||||
// `MAX_DASHES`, rather than one outline per dash.
|
||||
let mut line = BezPath::new();
|
||||
line.move_to((0.0, 0.0));
|
||||
line.line_to((10_000.0, 0.0));
|
||||
let props = StrokeProps { weight: 2.0, dashes: vec![0.01, 0.01], align: StrokeAlign::Center, ..Default::default() };
|
||||
let s = stroke_center(&line, 2.0, &props);
|
||||
assert!(s.elements().len() < 100, "{} elements", s.elements().len());
|
||||
assert!((s.area().abs() - 20_000.0).abs() < 10.0, "{}", s.area());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_subpath_restarting_the_dash_pattern_counts_toward_the_limit() {
|
||||
// The pattern restarts at every subpath: on 10,000 short lines its 199 tiny dashes come
|
||||
// round each time (~1M dashes), though the length alone suggests a few hundred.
|
||||
let mut path = BezPath::new();
|
||||
for i in 0..10_000 {
|
||||
path.move_to((0.0, f64::from(i) * 4.0));
|
||||
path.line_to((2.0, f64::from(i) * 4.0));
|
||||
}
|
||||
let mut dashes = vec![0.01; 199];
|
||||
dashes.push(10_000.0);
|
||||
let props = StrokeProps { weight: 1.0, dashes, align: StrokeAlign::Center, ..Default::default() };
|
||||
let s = stroke_center(&path, 1.0, &props);
|
||||
assert!(s.elements().len() < 100_000, "{} elements", s.elements().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_and_outside_strokes_respect_an_even_odd_hole() {
|
||||
// A ring: both loops run the same way, so only the even-odd rule leaves the hole open.
|
||||
@@ -742,4 +795,22 @@ mod tests {
|
||||
let s = stroke_outline_of(&ring, &inside, true, Some(&nz));
|
||||
assert!(s.winding(material) != 0 && s.winding(hole) != 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrowhead_skips_zero_length_end_segments() {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.line_to((100.0, 0.0));
|
||||
assert!(arrowhead(&p, StrokeCap::TriangleArrow, 2.0, true).is_some());
|
||||
p.line_to((100.0, 0.0));
|
||||
let (tip, dir) = path_end(&p, true).expect("end direction");
|
||||
assert_eq!((tip, dir), (Point::new(100.0, 0.0), Vec2::new(1.0, 0.0)));
|
||||
assert!(arrowhead(&p, StrokeCap::TriangleArrow, 2.0, true).is_some());
|
||||
let mut q = BezPath::new();
|
||||
q.move_to((0.0, 0.0));
|
||||
q.line_to((0.0, 0.0));
|
||||
q.line_to((100.0, 0.0));
|
||||
let (tip, dir) = path_end(&q, false).expect("start direction");
|
||||
assert_eq!((tip, dir), (Point::new(0.0, 0.0), Vec2::new(-1.0, 0.0)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,8 +86,8 @@ impl Styled {
|
||||
/// and last end (as without point styles).
|
||||
pub(crate) fn arrowheads(&self, weight: f64, props: &StrokeProps) -> BezPath {
|
||||
let mut out = BezPath::new();
|
||||
let last = self.runs.len().saturating_sub(1);
|
||||
for (k, run) in self.runs.iter().enumerate().filter(|(_, r)| !r.closed) {
|
||||
let last = self.runs.iter().filter(|r| !r.closed).count().saturating_sub(1);
|
||||
for (k, run) in self.runs.iter().filter(|r| !r.closed).enumerate() {
|
||||
let start = self.cap_at(run.start, if k == 0 { props.cap_start } else { StrokeCap::None });
|
||||
let end = self.cap_at(run.end, if k == last { props.cap_end } else { StrokeCap::None });
|
||||
for (at_end, cap) in [(false, start), (true, end)] {
|
||||
|
||||
@@ -17,7 +17,7 @@ pub use figcraft_geom::measure::{Polyline, polylines};
|
||||
/// Every subpath gets at least this many width samples, so the width varies smoothly along long
|
||||
/// straight runs.
|
||||
const SAMPLES_PER_SUBPATH: f64 = 128.0;
|
||||
/// The most dashes a dashed variable width stroke makes.
|
||||
/// The most dashes a dashed stroke makes ([`dashes_fit`]).
|
||||
const MAX_DASHES: usize = 20_000;
|
||||
|
||||
/// The nearest point of the stroke path to `p`: (subpath index, t by length, distance).
|
||||
@@ -242,6 +242,12 @@ pub fn dash_pattern(props: &StrokeProps) -> Option<Vec<f64>> {
|
||||
Some(if dashes.len() % 2 == 1 { [dashes.clone(), dashes].concat() } else { dashes })
|
||||
}
|
||||
|
||||
/// Whether `pattern` (from [`dash_pattern`]) makes at most [`MAX_DASHES`] dashes along `len`;
|
||||
/// past that a stroke draws solid instead of one outline per dash (#1060).
|
||||
pub(crate) fn dashes_fit(len: f64, pattern: &[f64]) -> bool {
|
||||
len / pattern.iter().sum::<f64>() * (pattern.len() as f64) < MAX_DASHES as f64
|
||||
}
|
||||
|
||||
/// Resample a polyline so no step is longer than `max_step`; returns the points and the length at
|
||||
/// each, or `None` past the sample budget.
|
||||
fn resample(line: &Polyline, max_step: f64, budget: &mut usize) -> Option<(Vec<Point>, Vec<f64>)> {
|
||||
@@ -294,7 +300,7 @@ pub fn variable_outline(path: &BezPath, width: f64, props: &StrokeProps, profile
|
||||
})
|
||||
.collect();
|
||||
match &pattern {
|
||||
Some(pat) if len / pat.iter().sum::<f64>() * (pat.len() as f64) < MAX_DASHES as f64 => {
|
||||
Some(pat) if dashes_fit(len, pat) => {
|
||||
for (p, h) in dashes(&pts, &hw, pat) {
|
||||
outline_piece(&mut out, &p, &h, false, style);
|
||||
}
|
||||
|
||||
@@ -153,3 +153,123 @@ fn deep_boolean_nesting_stops_at_the_outline_depth_limit() {
|
||||
d.create(inner, NodeKind::Rectangle, "R", 40.0, 10.0, 20.0, 20.0).unwrap();
|
||||
assert!((outline(&d, b).area().abs() - 2.0 * 20.0 * 20.0).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_only_boolean_operands_keep_their_holes() {
|
||||
use figcraft_doc::StrokeAlign;
|
||||
use figcraft_geom::Point;
|
||||
|
||||
for kind in [NodeKind::Polygon { count: 6 }, NodeKind::Rectangle, NodeKind::Line] {
|
||||
for align in [StrokeAlign::Inside, StrokeAlign::Center, StrokeAlign::Outside] {
|
||||
let mut d = Document::new("stroke operands");
|
||||
let page = d.first_page().unwrap();
|
||||
let b = d.create(page, NodeKind::BooleanOp { op: BoolOp::Subtract }, "B", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
d.get_mut(b).unwrap().fills = vec![Paint::solid(Color::BLACK)];
|
||||
// A nested group exercises the same path as a direct operand.
|
||||
let g = d.create(b, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let id = d.create(g, kind.clone(), "stroke", 10.0, 10.0, 80.0, 80.0).unwrap();
|
||||
let n = d.get_mut(id).unwrap();
|
||||
let mut hidden_fill = Paint::solid(Color::BLACK);
|
||||
hidden_fill.visible = false;
|
||||
n.fills = vec![hidden_fill];
|
||||
n.strokes = vec![Paint::solid(Color::BLACK)];
|
||||
n.stroke.weight = 6.0;
|
||||
n.stroke.align = align;
|
||||
let path = outline(&d, b);
|
||||
assert_eq!(path.winding(Point::new(50.0, 45.0)), 0, "{kind:?} {align:?}: empty interior");
|
||||
assert!(path.area().abs() > 100.0, "{kind:?} {align:?}: stroke survives");
|
||||
assert!(!dark(&render(&d, page), 50, 45));
|
||||
if !matches!(kind, NodeKind::Line) {
|
||||
// Changing only stroke weight invalidates the boolean result.
|
||||
let area = path.area().abs();
|
||||
d.get_mut(id).unwrap().stroke.weight = 3.0;
|
||||
assert!(outline(&d, b).area().abs() < area);
|
||||
d.get_mut(id).unwrap().fills[0].visible = true;
|
||||
assert_ne!(outline(&d, b).winding(Point::new(50.0, 45.0)), 0, "visible fills keep the interior");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boolean_operands_preserve_side_borders_and_container_children() {
|
||||
use figcraft_doc::StrokeAlign;
|
||||
use figcraft_geom::Point;
|
||||
for kind in [NodeKind::Rectangle, NodeKind::Frame] {
|
||||
let mut d = Document::new("borders");
|
||||
let page = d.first_page().unwrap();
|
||||
let b = d.create(page, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let id = d.create(b, kind.clone(), "operand", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let n = d.get_mut(id).unwrap();
|
||||
n.fills.clear();
|
||||
n.strokes = vec![Paint::solid(Color::BLACK)];
|
||||
n.stroke.weight = 8.0;
|
||||
n.stroke.align = StrokeAlign::Inside;
|
||||
n.stroke.sides = Some([8.0, 0.0, 0.0, 0.0]);
|
||||
if matches!(kind, NodeKind::Frame) {
|
||||
d.create(id, NodeKind::Rectangle, "child", 40.0, 40.0, 20.0, 20.0).unwrap();
|
||||
}
|
||||
let p = outline(&d, b);
|
||||
assert_ne!(p.winding(Point::new(50.0, 4.0)), 0, "top border");
|
||||
assert_eq!(p.winding(Point::new(98.0, 50.0)), 0, "no right border");
|
||||
assert_eq!(p.winding(Point::new(50.0, 98.0)), 0, "no bottom border");
|
||||
if matches!(kind, NodeKind::Frame) {
|
||||
assert_ne!(p.winding(Point::new(50.0, 50.0)), 0, "child retained");
|
||||
}
|
||||
d.get_mut(id).unwrap().stroke.sides = Some([0.0, 8.0, 0.0, 0.0]);
|
||||
let p = outline(&d, b);
|
||||
assert_eq!(p.winding(Point::new(50.0, 4.0)), 0, "side edit removes top");
|
||||
assert_ne!(p.winding(Point::new(98.0, 50.0)), 0, "side edit adds right");
|
||||
d.get_mut(id).unwrap().stroke.sides = None;
|
||||
assert_ne!(outline(&d, b).winding(Point::new(50.0, 4.0)), 0, "uniform container border");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_only_text_operands_use_glyphs_instead_of_the_text_box() {
|
||||
use figcraft_doc::{StrokeAlign, TextData};
|
||||
let mut d = Document::new("text strokes");
|
||||
let page = d.first_page().unwrap();
|
||||
let b = d.create(page, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 400.0, 100.0).unwrap();
|
||||
let mut text = TextData::new("Hi");
|
||||
text.style.font_size = 32.0;
|
||||
let id = d.create(b, NodeKind::Text(text), "text", 0.0, 0.0, 400.0, 100.0).unwrap();
|
||||
let n = d.get_mut(id).unwrap();
|
||||
n.fills.clear();
|
||||
n.strokes = vec![Paint::solid(Color::BLACK)];
|
||||
n.stroke.weight = 2.0;
|
||||
n.stroke.align = StrokeAlign::Center;
|
||||
let glyphs = outline(&d, id);
|
||||
let result = outline(&d, b);
|
||||
assert_eq!(result.elements().is_empty(), glyphs.elements().is_empty());
|
||||
if !glyphs.elements().is_empty() {
|
||||
let expected = glyphs.bounding_box().inflate(3.0, 3.0);
|
||||
let actual = result.bounding_box();
|
||||
assert!(actual.x0 >= expected.x0 && actual.x1 <= expected.x1);
|
||||
assert!(actual.y0 >= expected.y0 && actual.y1 <= expected.y1);
|
||||
assert!(actual.x1 < 200.0, "the unused text box must not contribute a border");
|
||||
}
|
||||
let NodeKind::Text(text) = &mut d.get_mut(id).unwrap().kind else { panic!() };
|
||||
text.characters = " ".into();
|
||||
assert!(outline(&d, b).elements().is_empty(), "spaces have no stroked glyphs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_side_borders_follow_their_corners_in_booleans() {
|
||||
use figcraft_doc::StrokeAlign;
|
||||
use figcraft_geom::Point;
|
||||
let mut d = Document::new("rounded borders");
|
||||
let page = d.first_page().unwrap();
|
||||
let b = d.create(page, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let id = d.create(b, NodeKind::Rectangle, "operand", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let n = d.get_mut(id).unwrap();
|
||||
n.fills.clear();
|
||||
n.strokes = vec![Paint::solid(Color::BLACK)];
|
||||
n.stroke.align = StrokeAlign::Inside;
|
||||
n.stroke.sides = Some([8.0; 4]);
|
||||
n.corners.radii = [30.0; 4];
|
||||
let p = outline(&d, b);
|
||||
assert_ne!(p.winding(Point::new(50.0, 4.0)), 0, "top border");
|
||||
assert_eq!(p.winding(Point::new(3.0, 3.0)), 0, "the rounded corner leaves the square's tip out");
|
||||
assert_eq!(p.winding(Point::new(50.0, 50.0)), 0, "hollow inside");
|
||||
}
|
||||
|
||||
+47
-14
@@ -156,19 +156,26 @@ pub(crate) fn hash_path(p: &BezPath, h: &mut impl Hasher) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-side strokes on rectangles and frames (a ring under non-zero winding too), if `n` has them.
|
||||
fn side_strokes(n: &Node) -> Option<Arc<BezPath>> {
|
||||
let props = &n.stroke;
|
||||
let sides = props.sides?;
|
||||
if !matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. }) {
|
||||
return None;
|
||||
}
|
||||
let c = &n.corners;
|
||||
Some(Arc::new(if c.uniform() == Some(0.0) {
|
||||
figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align)
|
||||
} else {
|
||||
figcraft_pathops::rounded_side_strokes(n.width, n.height, c.radii, c.smoothing, sides, props.align)
|
||||
}))
|
||||
}
|
||||
|
||||
/// The stroke area of a node in local coordinates.
|
||||
pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
|
||||
let props = &n.stroke;
|
||||
// Per-side strokes on rectangles/frames.
|
||||
if let Some(sides) = props.sides
|
||||
&& matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. })
|
||||
{
|
||||
let c = &n.corners;
|
||||
return Arc::new(if c.uniform() == Some(0.0) {
|
||||
figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align)
|
||||
} else {
|
||||
figcraft_pathops::rounded_side_strokes(n.width, n.height, c.radii, c.smoothing, sides, props.align)
|
||||
});
|
||||
if let Some(p) = side_strokes(n) {
|
||||
return p;
|
||||
}
|
||||
// Rect/ellipse inside/outside strokes without booleans: an even-odd ring of two outlines.
|
||||
if props.dashes.is_empty() && props.profile().is_none() && props.brush.is_none() && closed && props.align != StrokeAlign::Center {
|
||||
@@ -209,6 +216,18 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
|
||||
return Arc::new(o);
|
||||
}
|
||||
}
|
||||
cached_stroke_outline(n, path, closed)
|
||||
}
|
||||
|
||||
/// The stroke a boolean operand contributes: like [`stroke_area`], but never an even-odd ring
|
||||
/// (boolean folding uses non-zero winding).
|
||||
fn operand_stroke(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
|
||||
side_strokes(n).unwrap_or_else(|| cached_stroke_outline(n, path, closed))
|
||||
}
|
||||
|
||||
/// A non-zero stroke outline (cached), shared by painting and boolean operands.
|
||||
fn cached_stroke_outline(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
|
||||
let props = &n.stroke;
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
hash_path(path, &mut h);
|
||||
(props.weight.to_bits(), props.align as u8, props.cap_start as u8, props.cap_end as u8, props.join as u8, props.miter_limit.to_bits(), closed)
|
||||
@@ -281,7 +300,7 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F
|
||||
.filter_map(|c| doc.node(*c))
|
||||
.filter(|c| c.visible)
|
||||
.filter_map(|c| {
|
||||
let (mut p, _) = subtree_outline(doc, c, depth + 1)?;
|
||||
let (mut p, _) = subtree_outline_at(doc, c, depth + 1, true)?;
|
||||
p.apply_affine(c.transform);
|
||||
Some((p, c.bool_op.unwrap_or(*op)))
|
||||
})
|
||||
@@ -305,6 +324,11 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F
|
||||
|
||||
/// The union outline of a node and its descendants (for booleans of groups/frames).
|
||||
pub fn subtree_outline(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, FillRule)> {
|
||||
subtree_outline_at(doc, n, depth, false)
|
||||
}
|
||||
|
||||
/// Boolean operands with only a stroke contribute the painted band, not their interior.
|
||||
fn subtree_outline_at(doc: &Document, n: &Node, depth: usize, stroke_only: bool) -> Option<(BezPath, FillRule)> {
|
||||
if depth > 64 {
|
||||
return None;
|
||||
}
|
||||
@@ -313,21 +337,30 @@ pub fn subtree_outline(doc: &Document, n: &Node, depth: usize) -> Option<(BezPat
|
||||
let mut out = BezPath::new();
|
||||
if n.kind.is_frame_like()
|
||||
&& let Some(p) = figcraft_doc::geometry::shape_path(n)
|
||||
&& n.fills.iter().any(|f| f.visible)
|
||||
{
|
||||
out.extend(p.elements().iter().copied());
|
||||
if n.fills.iter().any(|f| f.visible) {
|
||||
out.extend(p.elements().iter().copied());
|
||||
} else if stroke_only && n.has_visible_stroke() {
|
||||
out.extend(operand_stroke(n, &p, true).elements().iter().copied());
|
||||
}
|
||||
}
|
||||
for c in n.children.iter().filter_map(|c| doc.node(*c)) {
|
||||
if !c.visible {
|
||||
continue;
|
||||
}
|
||||
if let Some((mut p, _)) = subtree_outline(doc, c, depth + 1) {
|
||||
if let Some((mut p, _)) = subtree_outline_at(doc, c, depth + 1, stroke_only) {
|
||||
p.apply_affine(c.transform);
|
||||
out.extend(p.elements().iter().copied());
|
||||
}
|
||||
}
|
||||
Some((out, FillRule::Winding))
|
||||
}
|
||||
_ if stroke_only && !n.fills.iter().any(|p| p.visible) && n.has_visible_stroke() => {
|
||||
let (path, _) = node_outline_at(doc, n, depth)?;
|
||||
let path = if matches!(n.kind, NodeKind::Text(_)) { path } else { figcraft_doc::geometry::stroke_geometry(n).unwrap_or(path) };
|
||||
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. });
|
||||
Some(((*operand_stroke(n, &path, closed)).clone(), FillRule::Winding))
|
||||
}
|
||||
_ => node_outline_at(doc, n, depth),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,9 +48,11 @@ fn an_image_within_the_limits_decodes() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_wider_or_taller_than_the_limit_is_refused() {
|
||||
assert!(images::decode_within(&png(30, 20), ColorProfile::Srgb, 29, u64::MAX).is_none());
|
||||
assert!(images::decode_within(&png(20, 30), ColorProfile::Srgb, 29, u64::MAX).is_none());
|
||||
fn an_image_wider_or_taller_than_the_limit_is_scaled_down_to_fit() {
|
||||
let (p, _) = images::decode_within(&png(30, 20), ColorProfile::Srgb, 15, u64::MAX).unwrap();
|
||||
assert_eq!((p.width(), p.height()), (15, 10));
|
||||
let (p, _) = images::decode_within(&png(20, 30), ColorProfile::Srgb, 15, u64::MAX).unwrap();
|
||||
assert_eq!((p.width(), p.height()), (10, 15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -63,10 +65,22 @@ fn a_huge_image_is_refused_from_its_header_alone() {
|
||||
// 16 000 × 16 000 is within the side limit; its pixels (1 GB) aren't within a 1 MB budget, and
|
||||
// the file has none to decode: only the header is read.
|
||||
assert!(images::decode_within(&png_header_only(16_000, 16_000), ColorProfile::Srgb, 16_384, 1 << 20).is_none());
|
||||
// Past the side limit, the shipped decode refuses it the same way.
|
||||
// Past the side limit, the shipped decode reads its header and finds no pixels to scale down.
|
||||
assert!(images::decode(&png_header_only(20_000, 10)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_past_the_side_limit_is_drawn_downscaled() {
|
||||
// #1336: one side over 16 384 px drew nothing.
|
||||
let p = images::decode(&png(16_385, 4)).unwrap();
|
||||
assert_eq!(p.width(), 16_384);
|
||||
assert!(p.height() >= 1 && p.height() <= 4);
|
||||
assert!(p.pixels().iter().all(|c| c.alpha() == 255));
|
||||
let p = images::decode(&png(4, 18_078)).unwrap();
|
||||
assert_eq!(p.height(), 16_384);
|
||||
assert!(p.width() >= 1 && p.width() <= 4);
|
||||
}
|
||||
|
||||
fn key(name: &str) -> images::Key {
|
||||
(name.to_string(), [0; 7], ColorProfile::Srgb)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use tiny_skia::Pixmap;
|
||||
|
||||
pub(crate) type Key = (String, [i32; 7], ColorProfile);
|
||||
|
||||
/// The largest image side decoded.
|
||||
/// The largest side of a decoded image: larger images are scaled down to fit.
|
||||
const MAX_SIDE: u32 = 16_384;
|
||||
/// The most bytes one image's decoding may allocate: the image crate's own default limit (a
|
||||
/// larger image is refused from its header).
|
||||
@@ -83,26 +83,25 @@ fn fkey(f: &ImageFilters) -> [i32; 7] {
|
||||
[q(f.exposure), q(f.contrast), q(f.saturation), q(f.temperature), q(f.tint), q(f.highlights), q(f.shadows)]
|
||||
}
|
||||
|
||||
/// Decode encoded image bytes into a premultiplied sRGB pixmap (max 16k per side).
|
||||
/// Decode encoded image bytes into a premultiplied sRGB pixmap (scaled down to 16k per side).
|
||||
pub fn decode(bytes: &[u8]) -> Option<Pixmap> {
|
||||
decode_in(bytes, ColorProfile::Srgb).map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// Decode encoded image bytes into a premultiplied pixmap (max 16k per side): converted from the
|
||||
/// Decode encoded image bytes into a premultiplied pixmap (scaled down to 16k per side): converted from the
|
||||
/// image's own profile into `target` (`false`), or as sRGB when it has none (`true`: the sRGB →
|
||||
/// `target` conversion is still to do).
|
||||
fn decode_in(bytes: &[u8], target: ColorProfile) -> Option<(Pixmap, bool)> {
|
||||
decode_within(bytes, target, MAX_SIDE, MAX_DECODED_BYTES)
|
||||
}
|
||||
|
||||
/// [`decode_in`] for images at most `max_side` wide and tall whose decoded pixels take at most
|
||||
/// `max_bytes`; larger ones are refused from their header, before any pixels are decoded.
|
||||
/// [`decode_in`] for images whose decoded pixels take at most `max_bytes` (larger ones are refused
|
||||
/// from their header, before any pixels are decoded), scaled down to at most `max_side` wide and
|
||||
/// tall.
|
||||
pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, max_bytes: u64) -> Option<(Pixmap, bool)> {
|
||||
use image::ImageDecoder;
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(max_side);
|
||||
limits.max_image_height = Some(max_side);
|
||||
limits.max_alloc = Some(max_bytes);
|
||||
reader.limits(limits);
|
||||
let mut dec = reader.into_decoder().ok()?;
|
||||
@@ -110,7 +109,11 @@ pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, m
|
||||
return None;
|
||||
}
|
||||
let source = crate::image_profile::Source::read(bytes, &mut dec);
|
||||
let img = image::DynamicImage::from_decoder(dec).ok()?;
|
||||
let mut img = image::DynamicImage::from_decoder(dec).ok()?;
|
||||
if img.width() > max_side || img.height() > max_side {
|
||||
// `thumbnail` keeps the aspect ratio and works in integers (no float copy of the pixels).
|
||||
img = img.thumbnail(max_side, max_side);
|
||||
}
|
||||
match source.convert(&img, target) {
|
||||
Some(rgba) => Some((premultiplied(&rgba)?, false)),
|
||||
None => Some((premultiplied(&img.to_rgba8())?, true)),
|
||||
|
||||
+164
-23
@@ -1,32 +1,26 @@
|
||||
//! Embeds the optional craft-fonts build input (`CRAFT_FONTS_DIR`): both `fonts/manifest.txt` and the
|
||||
//! opt-in Latin presentation fonts in `fonts/latin-manifest.txt` (Inter, Roboto, …). Unset or missing,
|
||||
//! `CRAFT_FONTS` is empty and the app uses system fonts. Recipe: craft-fonts `docs/integration.md`.
|
||||
//! `CRAFT_FONTS` is empty and native apps use system fonts. Required web releases enforce Inter
|
||||
//! Regular/SemiBold and Noto Sans Georgian Regular after filtering. Recipe: craft-fonts `docs/integration.md`.
|
||||
//! Noto Sans CJK only with `FIGCRAFT_EMBED_CJK` set (the web build's only way to show CJK UI text).
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Font build script: emit `craft_fonts.rs` from `input_fonts`, passing the
|
||||
/// `CRAFT_FONTS_DIR`, `CRAFT_FONTS_REQUIRED`, wasm target and `FIGCRAFT_EMBED_CJK` inputs;
|
||||
/// report generation and write failures with `cargo::error`.
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
|
||||
println!("cargo::rerun-if-env-changed=FIGCRAFT_EMBED_CJK");
|
||||
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
|
||||
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) {
|
||||
let mut ok = false;
|
||||
for manifest in ["fonts/latin-manifest.txt", "fonts/manifest.txt"] {
|
||||
match craft_fonts(&dir, manifest) {
|
||||
Ok(entries) => {
|
||||
ok = true;
|
||||
src.push_str(&entries);
|
||||
}
|
||||
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
|
||||
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
|
||||
}
|
||||
Err(e) => println!("cargo::warning=craft-fonts {manifest} skipped: CRAFT_FONTS_DIR={}: {e}", dir.display()),
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
println!("cargo::warning=building without craft-fonts (once it is there, `cargo clean -p figcraft-text` makes the next build use it)");
|
||||
}
|
||||
let dir = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from);
|
||||
let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some();
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let embed_cjk = std::env::var_os("FIGCRAFT_EMBED_CJK").is_some();
|
||||
match input_fonts(dir.as_deref(), wasm, required, embed_cjk) {
|
||||
Ok(entries) => src.push_str(&entries),
|
||||
Err(e) => println!("cargo::error={e}"),
|
||||
}
|
||||
src.push_str("];\n");
|
||||
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
|
||||
@@ -35,13 +29,45 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect the effective catalog and enforce the required web release faces after filtering.
|
||||
fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool, embed_cjk: bool) -> Result<String, String> {
|
||||
let Some(dir) = dir else {
|
||||
return if required { Err("CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing".into()) } else { Ok(String::new()) };
|
||||
};
|
||||
let mut out = String::new();
|
||||
let mut faces = Vec::new();
|
||||
for manifest in ["fonts/latin-manifest.txt", "fonts/manifest.txt"] {
|
||||
let path = dir.join(manifest);
|
||||
if manifest == "fonts/latin-manifest.txt" && !path.exists() {
|
||||
watch(&path);
|
||||
continue;
|
||||
}
|
||||
match craft_fonts(dir, manifest, wasm, embed_cjk) {
|
||||
Ok(catalog) => {
|
||||
out.push_str(&catalog.entries);
|
||||
faces.extend(catalog.faces);
|
||||
}
|
||||
Err(e) if required => return Err(format!("CRAFT_FONTS_DIR={}: {e}", dir.display())),
|
||||
Err(e) => println!("cargo::warning=craft-fonts {manifest} skipped: CRAFT_FONTS_DIR={}: {e}", dir.display()),
|
||||
}
|
||||
}
|
||||
if required && wasm {
|
||||
for (family, style) in [("Inter", "Regular"), ("Inter", "SemiBold"), ("Noto Sans Georgian", "Regular")] {
|
||||
if !faces.iter().any(|(f, s)| f == family && s == style) {
|
||||
return Err(format!("required web font missing: {family} {style}; CRAFT_FONTS_DIR must include both Latin and core release faces"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Rebuild when `manifest` changes or, while it is missing, when it is added to its folder.
|
||||
///
|
||||
/// Cargo takes a watched path that doesn't exist as changed on every build, and then rebuilds this
|
||||
/// crate and every crate that depends on it each time. So a missing manifest is never watched
|
||||
/// itself: its folder is, when there is one (a craft-fonts checkout without the Latin manifest).
|
||||
/// With no craft-fonts at all there is nothing to watch, so the build can't notice it arriving:
|
||||
/// the warning in `main` says how to pick it up.
|
||||
/// rebuild this crate after adding a previously absent checkout.
|
||||
fn watch(manifest: &std::path::Path) {
|
||||
let watched = if manifest.exists() { Some(manifest) } else { manifest.parent().filter(|folder| folder.is_dir()) };
|
||||
if let Some(path) = watched {
|
||||
@@ -49,20 +75,33 @@ fn watch(manifest: &std::path::Path) {
|
||||
}
|
||||
}
|
||||
|
||||
fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result<String, String> {
|
||||
struct Catalog {
|
||||
entries: String,
|
||||
faces: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Parse a font manifest of `family | style | file | scripts` rows into `CraftFont`
|
||||
/// entries: skip Adobe-authored families, skip CJK unless requested, keep the web catalog
|
||||
/// to its budgeted families, canonicalize and watch each face, and emit `include_bytes!`
|
||||
/// items.
|
||||
fn craft_fonts(dir: &std::path::Path, manifest_rel: &str, wasm: bool, embed_cjk: bool) -> Result<Catalog, String> {
|
||||
let manifest = dir.join(manifest_rel);
|
||||
watch(&manifest);
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let mut out = String::new();
|
||||
let mut faces = Vec::new();
|
||||
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
|
||||
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
|
||||
let [family, style, file, scripts, ..] = f.as_slice() else {
|
||||
return Err(format!("malformed manifest line: {line}"));
|
||||
};
|
||||
// FigCraft's asset policy excludes Adobe-authored visual assets on every target.
|
||||
if matches!(*family, "Source Sans 3" | "Source Serif 4") {
|
||||
continue;
|
||||
}
|
||||
// CJK fonts are large; the desktop app finds system CJK fonts instead unless asked.
|
||||
let cjk = scripts.contains("Hans");
|
||||
if cjk && std::env::var_os("FIGCRAFT_EMBED_CJK").is_none() {
|
||||
if cjk && !embed_cjk {
|
||||
continue;
|
||||
}
|
||||
// Web builds have a size budget: Inter, Noto Sans Georgian (small, for the Georgian UI), only the
|
||||
@@ -73,7 +112,109 @@ fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result<String, Stri
|
||||
}
|
||||
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
|
||||
println!("cargo::rerun-if-changed={}", path.display());
|
||||
faces.push(((*family).to_owned(), (*style).to_owned()));
|
||||
let _ = writeln!(out, " CraftFont {{ family: {family:?}, style: {style:?}, bytes: include_bytes!({:?}) }},", path.display().to_string());
|
||||
}
|
||||
Ok(out)
|
||||
Ok(Catalog { entries: out, faces })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
/// Test fixture: create a unique temporary directory, write a synthetic face file and
|
||||
/// install the default Georgian manifest.
|
||||
fn new() -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("fig-font-input-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(dir.join("fonts")).unwrap();
|
||||
// The build script validates manifest/file presence; font bytes are checked centrally.
|
||||
std::fs::write(dir.join("fonts/face.ttf"), b"synthetic build-input fixture").unwrap();
|
||||
let fixture = Self(dir);
|
||||
fixture.manifest("manifest.txt", &[("Noto Sans Georgian", "Regular")]);
|
||||
fixture
|
||||
}
|
||||
|
||||
/// Test fixture: write manifest rows that all point at the fixture's `fonts/face.ttf` face.
|
||||
fn manifest(&self, name: &str, faces: &[(&str, &str)]) {
|
||||
let text = faces.iter().map(|(family, style)| format!("{family} | {style} | fonts/face.ttf | Latn\n")).collect::<String>();
|
||||
std::fs::write(self.0.join("fonts").join(name), text).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
/// Remove the fixture's temporary directory, ignoring errors.
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asserts the native Latin catalog keeps its 43 permitted presentation faces, the web
|
||||
/// catalog keeps only the four Inter faces, Adobe-authored families are absent, and
|
||||
/// permitted native faces remain.
|
||||
#[test]
|
||||
fn restored_latin_catalog_preserves_permitted_native_faces_and_web_budget() {
|
||||
let f = Fixture::new();
|
||||
std::fs::write(f.0.join("fonts/latin-manifest.txt"), include_str!("tests/fixtures/latin-families.txt")).unwrap();
|
||||
let native = craft_fonts(&f.0, "fonts/latin-manifest.txt", false, false).unwrap();
|
||||
assert_eq!(native.faces.len(), 43, "all prior permitted presentation faces remain available");
|
||||
let web = craft_fonts(&f.0, "fonts/latin-manifest.txt", true, false).unwrap();
|
||||
assert_eq!(web.faces.len(), 4, "the web catalog keeps only the four Inter faces");
|
||||
for catalog in [&native, &web] {
|
||||
assert!(!catalog.entries.contains("Source Sans 3"));
|
||||
assert!(!catalog.entries.contains("Source Serif 4"));
|
||||
}
|
||||
assert!(native.faces.iter().any(|(family, _)| family == "Carlito"));
|
||||
assert!(native.faces.iter().any(|(family, _)| family == "Liberation Serif"));
|
||||
assert!(input_fonts(Some(&f.0), true, true, false).is_ok());
|
||||
}
|
||||
|
||||
/// Asserts required web input rejects a catalog missing Inter Regular or Inter SemiBold.
|
||||
#[test]
|
||||
fn required_web_rejects_core_only_and_partial_latin_catalogs() {
|
||||
let f = Fixture::new();
|
||||
assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter Regular"));
|
||||
f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Unrelated", "SemiBold")]);
|
||||
assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter SemiBold"));
|
||||
}
|
||||
|
||||
/// Asserts required web input embeds the Inter and Georgian catalogs (three entries) and
|
||||
/// rejects a manifest without Noto Sans Georgian Regular.
|
||||
#[test]
|
||||
fn required_web_embeds_both_catalogs_and_requires_georgian() {
|
||||
let f = Fixture::new();
|
||||
f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Inter", "SemiBold")]);
|
||||
let entries = input_fonts(Some(&f.0), true, true, false).unwrap();
|
||||
assert_eq!(entries.matches("CraftFont {").count(), 3);
|
||||
assert!(entries.contains("family: \"Inter\""));
|
||||
assert!(entries.contains("family: \"Noto Sans Georgian\""));
|
||||
f.manifest("manifest.txt", &[("Other", "Regular")]);
|
||||
assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Noto Sans Georgian Regular"));
|
||||
}
|
||||
|
||||
/// Asserts absent font input is empty when optional and an error when required, while present
|
||||
/// input includes Noto Sans Georgian.
|
||||
#[test]
|
||||
fn optional_and_native_inputs_keep_their_existing_policy() {
|
||||
let f = Fixture::new();
|
||||
assert!(input_fonts(None, true, false, false).unwrap().is_empty());
|
||||
assert!(input_fonts(None, true, true, false).unwrap_err().contains("CRAFT_FONTS_DIR is missing"));
|
||||
assert!(input_fonts(Some(&f.0), true, false, false).unwrap().contains("Noto Sans Georgian"));
|
||||
assert!(input_fonts(Some(&f.0), false, true, false).unwrap().contains("Noto Sans Georgian"));
|
||||
}
|
||||
|
||||
/// Asserts required web input rejects a manifest whose face file is missing, even when every
|
||||
/// face row is listed.
|
||||
#[test]
|
||||
fn required_web_rejects_missing_asset_even_with_all_face_rows() {
|
||||
let f = Fixture::new();
|
||||
f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Inter", "SemiBold")]);
|
||||
std::fs::remove_file(f.0.join("fonts/face.ttf")).unwrap();
|
||||
assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("fonts/face.ttf"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,6 +265,28 @@ fn hangul_comes_from_a_korean_font_when_one_is_installed() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_come_from_a_symbol_font_when_one_is_installed() {
|
||||
// ⌖ (U+2316) and ⤒ (U+2912) are in no Latin text face. With a symbol font that has them (Apple
|
||||
// Symbols on macOS, Segoe UI Symbol on Windows, Noto Sans Symbols 2 and Noto Sans Math on
|
||||
// Linux) the fallback draws them instead of the missing-glyph box (#1156). Each character
|
||||
// skips on a machine with no installed fallback family that has it.
|
||||
let Some(f) = face() else { return };
|
||||
let families = ["Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", "Noto Sans Symbols 2", "Noto Sans Symbols2", "Noto Sans Math"];
|
||||
for c in ['\u{2316}', '\u{2912}'] {
|
||||
if f.has_char(c) || !families.iter().any(|k| find_face(k, "Regular").is_some_and(|k| k.has_char(c))) {
|
||||
continue;
|
||||
}
|
||||
let t = text(&format!("A {c}"), TextAutoResize::WidthAndHeight);
|
||||
let l = layout(&t, None, None);
|
||||
let drawn: Vec<_> = l.glyphs.iter().filter(|g| g.char_start == 2).collect();
|
||||
assert!(!drawn.is_empty(), "U+{:04X} has a glyph", c as u32);
|
||||
for g in drawn {
|
||||
assert!(g.face.has_char(c), "U+{:04X} drawn by {} {}, which lacks it", c as u32, g.face.family, g.face.style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_opportunities() {
|
||||
// (before, after, may break between, derivation)
|
||||
|
||||
@@ -405,8 +405,10 @@ fn names(f: &skrifa::FontRef) -> (Option<String>, Option<String>) {
|
||||
(family, style)
|
||||
}
|
||||
|
||||
/// Families tried, in order, for characters a text's own font lacks.
|
||||
const FALLBACK_FAMILIES: [&str; 20] = [
|
||||
/// Families tried, in order, for characters a text's own font lacks. The symbol fonts come last:
|
||||
/// Apple Symbols (macOS), Segoe UI Symbol (Windows) and the Noto symbol and math fonts (Linux), for
|
||||
/// signs such as ⌖ and ⤒ that no text face has.
|
||||
const FALLBACK_FAMILIES: [&str; 24] = [
|
||||
"Inter",
|
||||
"Helvetica Neue",
|
||||
"Arial",
|
||||
@@ -427,6 +429,10 @@ const FALLBACK_FAMILIES: [&str; 20] = [
|
||||
"Noto Sans Arabic",
|
||||
"Apple Symbols",
|
||||
"Segoe UI Symbol",
|
||||
"Noto Sans Symbols",
|
||||
"Noto Sans Symbols 2",
|
||||
"Noto Sans Symbols2",
|
||||
"Noto Sans Math",
|
||||
];
|
||||
|
||||
/// A system font file entry not yet loaded.
|
||||
|
||||
@@ -192,6 +192,19 @@ fn fallback_faces_follow_the_runs_style() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_platform_has_a_symbol_font_in_the_fallbacks() {
|
||||
// Symbols such as ⌖ (U+2316) and ⤒ (U+2912) are in no text face (#1156): macOS has Apple
|
||||
// Symbols, Windows Segoe UI Symbol, and Linux the Noto symbol and math fonts (fontconfig names
|
||||
// "Noto Sans Symbols 2"; some packages name it "Noto Sans Symbols2").
|
||||
for family in ["Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", "Noto Sans Symbols 2", "Noto Sans Symbols2", "Noto Sans Math"] {
|
||||
assert!(FALLBACK_FAMILIES.contains(&family), "{family} is a fallback");
|
||||
}
|
||||
// Text faces come first: a symbol font only draws what they lack.
|
||||
let at = |f: &str| FALLBACK_FAMILIES.iter().position(|x| *x == f).unwrap();
|
||||
assert!(at("Noto Sans") < at("Noto Sans Symbols") && at("DejaVu Sans") < at("Noto Sans Math"));
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn user_font_dirs_follow_xdg_data_home() {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Exercise the actual build-script catalog policy without changing process environment.
|
||||
#[allow(dead_code)]
|
||||
#[path = "../build.rs"]
|
||||
mod build_script;
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
# Synthetic paths; family/style inventory from craft-fonts 76ba5a0496503c0e2841abd626295038cbe07cde.
|
||||
Inter | Regular | fonts/face.ttf | Latn
|
||||
Inter | Italic | fonts/face.ttf | Latn
|
||||
Inter | SemiBold | fonts/face.ttf | Latn
|
||||
Inter | Bold | fonts/face.ttf | Latn
|
||||
Carlito | Regular | fonts/face.ttf | Latn
|
||||
Carlito | Bold | fonts/face.ttf | Latn
|
||||
Carlito | Italic | fonts/face.ttf | Latn
|
||||
Carlito | Bold Italic | fonts/face.ttf | Latn
|
||||
Caladea | Regular | fonts/face.ttf | Latn
|
||||
Caladea | Bold | fonts/face.ttf | Latn
|
||||
Caladea | Italic | fonts/face.ttf | Latn
|
||||
Caladea | Bold Italic | fonts/face.ttf | Latn
|
||||
Liberation Sans | Regular | fonts/face.ttf | Latn
|
||||
Liberation Sans | Bold | fonts/face.ttf | Latn
|
||||
Liberation Sans | Italic | fonts/face.ttf | Latn
|
||||
Liberation Sans | Bold Italic | fonts/face.ttf | Latn
|
||||
Liberation Serif | Regular | fonts/face.ttf | Latn
|
||||
Liberation Serif | Bold | fonts/face.ttf | Latn
|
||||
Liberation Serif | Italic | fonts/face.ttf | Latn
|
||||
Liberation Serif | Bold Italic | fonts/face.ttf | Latn
|
||||
Liberation Mono | Regular | fonts/face.ttf | Latn
|
||||
Liberation Mono | Bold | fonts/face.ttf | Latn
|
||||
Liberation Mono | Italic | fonts/face.ttf | Latn
|
||||
Liberation Mono | Bold Italic | fonts/face.ttf | Latn
|
||||
Source Sans 3 | Regular | fonts/face.ttf | Latn
|
||||
Source Sans 3 | Italic | fonts/face.ttf | Latn
|
||||
Source Sans 3 | Semibold | fonts/face.ttf | Latn
|
||||
Source Sans 3 | Bold | fonts/face.ttf | Latn
|
||||
Source Serif 4 | Regular | fonts/face.ttf | Latn
|
||||
Source Serif 4 | Italic | fonts/face.ttf | Latn
|
||||
Source Serif 4 | Semibold | fonts/face.ttf | Latn
|
||||
Source Serif 4 | Bold | fonts/face.ttf | Latn
|
||||
Montserrat | Variable | fonts/face.ttf | Latn
|
||||
Montserrat | Italic Variable | fonts/face.ttf | Latn
|
||||
Lato | Regular | fonts/face.ttf | Latn
|
||||
Lato | Bold | fonts/face.ttf | Latn
|
||||
Lato | Italic | fonts/face.ttf | Latn
|
||||
Open Sans | Variable | fonts/face.ttf | Latn
|
||||
Open Sans | Italic Variable | fonts/face.ttf | Latn
|
||||
Roboto | Variable | fonts/face.ttf | Latn
|
||||
Roboto | Italic Variable | fonts/face.ttf | Latn
|
||||
Merriweather | Regular | fonts/face.ttf | Latn
|
||||
Merriweather | Bold | fonts/face.ttf | Latn
|
||||
Merriweather | Italic | fonts/face.ttf | Latn
|
||||
Playfair Display | Variable | fonts/face.ttf | Latn
|
||||
Playfair Display | Italic Variable | fonts/face.ttf | Latn
|
||||
Poppins | Regular | fonts/face.ttf | Latn
|
||||
Poppins | Bold | fonts/face.ttf | Latn
|
||||
Poppins | Italic | fonts/face.ttf | Latn
|
||||
Nunito Sans | Variable | fonts/face.ttf | Latn
|
||||
Nunito Sans | Italic Variable | fonts/face.ttf | Latn
|
||||
@@ -18,7 +18,7 @@ figcraft-render = { workspace = true }
|
||||
figcraft-pathops = { workspace = true }
|
||||
figcraft-engine = { workspace = true }
|
||||
figcraft-format = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -34,3 +34,9 @@ toml = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
wat = { workspace = true }
|
||||
# Keep the test font features explicit; production uses the same bundled fallback faces.
|
||||
# Dependency dev-features do not propagate into app release builds.
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
|
||||
@@ -207,6 +207,9 @@ name = "English"
|
||||
"CMYK colour" = "CMYK colour"
|
||||
"Can view" = "Can view"
|
||||
"Check" = "Check"
|
||||
"Check for Updates" = "Check for Updates"
|
||||
"Check for updates…" = "Check for updates…"
|
||||
"Checking for updates…" = "Checking for updates…"
|
||||
"Checking the fonts installed on this computer…" = "Checking the fonts installed on this computer…"
|
||||
"Checking the server…" = "Checking the server…"
|
||||
"Chevron" = "Chevron"
|
||||
@@ -327,7 +330,9 @@ name = "English"
|
||||
"Copy {value}" = "Copy {value}"
|
||||
"Corner radius" = "Corner radius"
|
||||
"Corner smoothing" = "Corner smoothing"
|
||||
"Could not check for updates:\n{error}" = "Could not check for updates:\n{error}"
|
||||
"Couldn't download the variables" = "Couldn't download the variables"
|
||||
"Couldn't download {name}" = "Couldn't download {name}"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers."
|
||||
"Couldn't read {name}." = "Couldn't read {name}."
|
||||
"Couldn't save automatically: {error}" = "Couldn't save automatically: {error}"
|
||||
@@ -506,6 +511,7 @@ name = "English"
|
||||
"Fetching from the server…" = "Fetching from the server…"
|
||||
"FigCraft couldn't open its window: {error}" = "FigCraft couldn't open its window: {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 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 {version} is currently the newest version available." = "FigCraft {version} is currently the newest version available."
|
||||
"File" = "File"
|
||||
"File name" = "File name"
|
||||
"File not found" = "File not found"
|
||||
@@ -1494,6 +1500,7 @@ name = "English"
|
||||
"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard"
|
||||
"Unsaved changes: save first, or close again with {key} to discard" = "Unsaved changes: save first, or close again with {key} to discard"
|
||||
"Up" = "Up"
|
||||
"Update check is not supported in the web browser." = "Update check is not supported in the web browser."
|
||||
"Update plugin" = "Update plugin"
|
||||
"Update" = "Update"
|
||||
"Update from main" = "Update from main"
|
||||
@@ -1644,10 +1651,12 @@ name = "English"
|
||||
"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "image filters are ambiguous because both filter containers are present; filter conversion was skipped"
|
||||
"image filters use FigCraft's approximate renderer" = "image filters use FigCraft's approximate renderer"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "instance swap properties whose default component isn't in the file were skipped"
|
||||
"instance swap properties whose default component was past the layer limit were skipped" = "instance swap properties whose default component was past the layer limit were skipped"
|
||||
"instances inside their own main component were imported as frames" = "instances inside their own main component were imported as frames"
|
||||
"instances of components that aren't in the file show no layers" = "instances of components that aren't in the file show no layers"
|
||||
"instances of components that aren't in the file were imported as frames" = "instances of components that aren't in the file were imported as frames"
|
||||
"instances that would nest a component inside itself were imported as frames" = "instances that would nest a component inside itself were imported as frames"
|
||||
"instances whose component was past the layer limit were imported as frames" = "instances whose component was past the layer limit were imported as frames"
|
||||
"instances without a main component were imported as empty frames" = "instances without a main component were imported as empty frames"
|
||||
"invalid internal .fig text path data was ignored" = "invalid internal .fig text path data was ignored"
|
||||
"invalid or oversized vector networks import as outlines" = "invalid or oversized vector networks import as outlines"
|
||||
|
||||
@@ -20,6 +20,7 @@ status = "draft — needs native review"
|
||||
"Compatibility notes" = "Notas de compatibilidad"
|
||||
"Connector" = "Conector"
|
||||
"Couldn't download the variables" = "No se pudieron descargar las variables"
|
||||
"Couldn't download {name}" = "No se pudo descargar {name}"
|
||||
|
||||
"% of all commits" = "% del total de commits"
|
||||
"(no longer published)" = "(ya no publicado)"
|
||||
@@ -1640,10 +1641,12 @@ status = "draft — needs native review"
|
||||
"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "los filtros de imagen son ambiguos porque están los dos contenedores de filtros; se omitió la conversión de filtros"
|
||||
"image filters use FigCraft's approximate renderer" = "los filtros de imagen usan el renderizado aproximado de FigCraft"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "se omitieron las propiedades de intercambio de instancia cuyo componente predeterminado no está en el archivo"
|
||||
"instance swap properties whose default component was past the layer limit were skipped" = "se omitieron las propiedades de intercambio de instancia cuyo componente predeterminado quedó fuera del límite de capas"
|
||||
"instances inside their own main component were imported as frames" = "las instancias dentro de su propio componente principal se importaron como marcos"
|
||||
"instances of components that aren't in the file show no layers" = "las instancias de componentes que no están en el archivo no muestran capas"
|
||||
"instances of components that aren't in the file were imported as frames" = "las instancias de componentes que no están en el archivo se importaron como marcos"
|
||||
"instances that would nest a component inside itself were imported as frames" = "las instancias que anidarían un componente dentro de sí mismo se importaron como marcos"
|
||||
"instances whose component was past the layer limit were imported as frames" = "las instancias cuyo componente quedó fuera del límite de capas se importaron como marcos"
|
||||
"instances without a main component were imported as empty frames" = "las instancias sin componente principal se importaron como marcos vacíos"
|
||||
"invalid internal .fig text path data was ignored" = "se ignoraron datos internos no válidos de texto en trazado del .fig"
|
||||
"invalid or oversized vector networks import as outlines" = "las redes vectoriales no válidas o demasiado grandes se importan como contornos"
|
||||
@@ -1976,6 +1979,7 @@ status = "draft — needs native review"
|
||||
"file.revert" = "Volver a lo guardado"
|
||||
"file.save" = "Guardar"
|
||||
"file.saveAs" = "Guardar como"
|
||||
"file.markSaved" = "Marcar como guardado"
|
||||
"file.saveVersion" = "Guardar en el historial de versiones"
|
||||
"file.versionHistory" = "Mostrar historial de versiones"
|
||||
"fill.add" = "Añadir relleno"
|
||||
|
||||
@@ -21,7 +21,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"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 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" = "სათაური, დღის წესრიგი, დიდი ციფრი, დიაგრამა და დასკვნითი სლაიდი"
|
||||
"A to Z" = "ა-დან ჰ-მდე"
|
||||
@@ -104,7 +104,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Alpha" = "ალფა"
|
||||
"Alphabetical" = "ანბანის მიხედვით"
|
||||
"Alt+drag" = "Alt+გადათრევა"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "ანალიტიკის ეკრანი ბადეზე: ციფრები, ვექტორებით დახატული დიაგრამები და ცხრილი"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "ანალიტიკა ბადეზე: ციფრები, დიაგრამები და ცხრილი"
|
||||
"An invite link that opens FigCraft and joins" = "მოსაწვევი ბმული, რომელიც ხსნის FigCraft-ს და უერთდება სესიას"
|
||||
"Anchor" = "საყრდენი"
|
||||
"Android" = "Android"
|
||||
@@ -215,7 +215,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Clear from this list" = "ამ სიიდან ამოღება"
|
||||
"Code or invite link" = "კოდი ან მოსაწვევი ბმული"
|
||||
"Color profile: {name}" = "ფერის პროფილი: {name}"
|
||||
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "ფერისა და ტექსტის სტილები, ღია და მუქი ცვლადები, ღილაკები, შეყვანის ველები და ბარათები"
|
||||
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "სტილები, ღია და მუქი ცვლადები, ღილაკები და ბარათები"
|
||||
"Connector" = "შემაერთებელი"
|
||||
"Convert to frame" = "ჩარჩოდ გარდაქმნა"
|
||||
"Convert to section" = "სექციად გარდაქმნა"
|
||||
@@ -480,7 +480,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Expand" = "გაშლა"
|
||||
"Export" = "ექსპორტირება"
|
||||
"Export all" = "ყველაფრის ექსპორტირება"
|
||||
"Export frames and layers as PNG, JPG, SVG or PDF" = "ჩარჩოებისა და შრეების ექსპორტირება PNG, JPG, SVG ან PDF ფორმატში"
|
||||
"Export frames and layers as PNG, JPG, SVG or PDF" = "ჩარჩოები და შრეები PNG, JPG, SVG ან PDF ფორმატში"
|
||||
"Export page as PNG" = "გვერდის PNG ფაილად ექსპორტირება"
|
||||
"Export settings" = "ექსპორტის პარამეტრები"
|
||||
"Export variables" = "ცვლადების ექსპორტირება"
|
||||
@@ -553,7 +553,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Font size" = "შრიფტის ზომა"
|
||||
"Font style" = "შრიფტის სტილი"
|
||||
"Forget on this computer" = "ამ კომპიუტერზე დავიწყება"
|
||||
"Four screens of a trail app with components and a prototype flow" = "ლაშქრობის აპის ოთხი ეკრანი კომპონენტებითა და პროტოტიპის ნაკადით"
|
||||
"Four screens of a trail app with components and a prototype flow" = "ლაშქრობის აპის ოთხი ეკრანი და პროტოტიპის ნაკადი"
|
||||
"Fractions" = "წილადები"
|
||||
"Frame" = "ჩარჩო"
|
||||
"Frame selection" = "მონიშნულის ჩარჩოში მოქცევა"
|
||||
@@ -576,7 +576,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Gradient" = "გრადიენტი"
|
||||
"Gray" = "ნაცრისფერი"
|
||||
"Green" = "მწვანე"
|
||||
"Greyscale components and two example screens to sketch a flow with" = "ნაცრისფერი კომპონენტები და ორი სანიმუშო ეკრანი ნაკადის ჩანახატისთვის"
|
||||
"Greyscale components and two example screens to sketch a flow with" = "ნაცრისფერი კომპონენტები ნაკადის ჩანახატისთვის"
|
||||
"Grid" = "ბადე"
|
||||
"Grid ({size})" = "ბადე ({size})"
|
||||
"Grid styles" = "ბადის სტილები"
|
||||
@@ -623,7 +623,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Hue" = "ფერის ტონი"
|
||||
"Hug" = "შიგთავსზე მორგება"
|
||||
"Hug contents" = "შიგთავსზე მორგება"
|
||||
"Icon components on a 24 px grid with keylines and a size preview" = "ხატულების კომპონენტები 24 px ბადეზე საორიენტაციო ხაზებითა და ზომების გადახედვით"
|
||||
"Icon components on a 24 px grid with keylines and a size preview" = "ხატულები 24 px ბადეზე, საორიენტაციო ხაზებით"
|
||||
"Icon set starter" = "ხატულების ნაკრების საწყისი"
|
||||
"If it keeps happening, please report it at {link} with this message." = "თუ ეს განმეორდება, გთხოვთ, ეს შეტყობინება გამოგვიგზავნეთ აქ: {link}"
|
||||
"If" = "თუ"
|
||||
@@ -745,7 +745,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Link" = "ბმული"
|
||||
"Link copied" = "ბმული დაკოპირდა"
|
||||
"Link docs, code or tickets for developers." = "მიაბით დოკუმენტაცია, კოდი ან ტიკეტები დეველოპერებისთვის."
|
||||
"Link frames on the Prototype tab, then present the flow" = "დააკავშირეთ ჩარჩოები „პროტოტიპის“ ჩანართზე, შემდეგ წარადგინეთ ნაკადი"
|
||||
"Link frames on the Prototype tab, then present the flow" = "დააკავშირეთ ჩარჩოები და წარადგინეთ ნაკადი"
|
||||
"Link this component to its code component: instances then show your real snippet with their values." = "დააკავშირეთ ეს კომპონენტი მის კოდის კომპონენტთან: ინსტანსები მაშინ თქვენს რეალურ ფრაგმენტს აჩვენებს მათი მნიშვნელობებით."
|
||||
"List spacing" = "სიის დაშორება"
|
||||
"List style" = "სიის სტილი"
|
||||
@@ -921,7 +921,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"On click" = "დაწკაპუნებით"
|
||||
"On drag" = "გადათრევისას"
|
||||
"On this page" = "ამ გვერდზე"
|
||||
"One announcement in five common sizes: square, portrait, story, link and banner" = "ერთი განცხადება ხუთ ხშირად გამოყენებულ ზომაში: კვადრატი, ვერტიკალური, სთორი, ბმული და ბანერი"
|
||||
"One announcement in five common sizes: square, portrait, story, link and banner" = "ერთი განცხადება ხუთ პოპულარულ ზომაში"
|
||||
"Online" = "ონლაინ"
|
||||
"Online files" = "ონლაინ ფაილები"
|
||||
"Online sharing is off: files are shared on the local network only." = "ონლაინ გაზიარება გამორთულია: ფაილები მხოლოდ ლოკალურ ქსელში ზიარდება."
|
||||
@@ -1003,7 +1003,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Pick color" = "ფერის არჩევა"
|
||||
"Pick color (I)" = "ფერის აღება (I)"
|
||||
"Pick one font family first" = "ჯერ აირჩიეთ ერთი შრიფტის ოჯახი"
|
||||
"Pick the Frame tool, then drag on the canvas or choose a device size" = "აირჩიეთ ჩარჩოს ხელსაწყო, შემდეგ გადაათრიეთ ტილოზე ან აირჩიეთ მოწყობილობის ზომა"
|
||||
"Pick the Frame tool, then drag on the canvas or choose a device size" = "ჩარჩოს ხელსაწყოთი გადაათრიეთ ან აირჩიეთ ზომა"
|
||||
"Pin a property" = "თვისების მიმაგრება"
|
||||
"Pinch" = "მოჭყლეტა"
|
||||
"Pixel grid" = "პიქსელური ბადე"
|
||||
@@ -1182,7 +1182,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Save files automatically" = "ფაილების ავტომატურად შენახვა"
|
||||
"Search templates" = "შაბლონების ძიება"
|
||||
"See all templates" = "ყველა შაბლონის ნახვა"
|
||||
"Select layers and they space themselves as their content changes" = "მონიშნეთ შრეები და შიგთავსის ცვლილებისას ისინი ავტომატურად დაშორდება ერთმანეთს"
|
||||
"Select layers and they space themselves as their content changes" = "შრეები შიგთავსის ცვლილებისას თავად დაშორდება"
|
||||
"SHA-256: {sha}…" = "SHA-256: {sha}…"
|
||||
"Same as layer" = "შრის მსგავსად"
|
||||
"Saturation" = "გაჯერება"
|
||||
@@ -1330,7 +1330,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Start on path" = "დასაწყისი ტრაექტორიაზე"
|
||||
"Start point" = "საწყისი წერტილი"
|
||||
"Start sharing" = "გაზიარების დაწყება"
|
||||
"Start sharing with the Share button, then pass on the code it gives" = "გაზიარება დაიწყეთ ღილაკით „გაზიარება“, შემდეგ გადაეცით მის მიერ მოცემული კოდი"
|
||||
"Start sharing with the Share button, then pass on the code it gives" = "დააჭირეთ „გაზიარებას“ და გადაეცით მიღებული კოდი"
|
||||
"Starting to share…" = "გაზიარების დაწყება…"
|
||||
"Status" = "სტატუსი"
|
||||
"Sticky" = "მიმაგრებული"
|
||||
@@ -1451,7 +1451,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Try another search or filter." = "სცადეთ სხვა ძიება ან ფილტრი."
|
||||
"Try another search, or browse templates." = "სცადეთ სხვა ძიება ან დაათვალიერეთ შაბლონები."
|
||||
"Try another word, or look at every kind." = "სცადეთ სხვა სიტყვა ან ნახეთ ყველა სახეობა."
|
||||
"Turn a layer into a component, reuse it, and change every copy at once" = "შრე აქციეთ კომპონენტად, გამოიყენეთ ხელახლა და შეცვალეთ ყველა ასლი ერთდროულად"
|
||||
"Turn a layer into a component, reuse it, and change every copy at once" = "შრე აქციეთ კომპონენტად და შეცვალეთ ყველა ასლი"
|
||||
"Two digits" = "ორი ციფრი"
|
||||
"Type" = "ტიპი"
|
||||
"Type settings" = "ტექსტის პარამეტრები"
|
||||
|
||||
@@ -239,6 +239,10 @@ pub fn scroll_focus(nav: &mut Nav, ui: &Ui) {
|
||||
#[derive(Clone, Default)]
|
||||
struct Shared {
|
||||
canvas: Option<Id>,
|
||||
/// The root `Ui::scope_id` of the frame. A resizable panel's real `__resize` widget is
|
||||
/// `root_scope.with(<panel salt>).with("__resize")`; FigCraft's panels are fixed, so its own
|
||||
/// `panel_edge` and `pages_edge` IDs are the ones to exclude today.
|
||||
root_scope: Option<Id>,
|
||||
keyboard: bool,
|
||||
/// The focused widget showed its focus itself this frame (no ring needed).
|
||||
ring: Option<Id>,
|
||||
@@ -249,8 +253,10 @@ struct Shared {
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// Run `f` on the per-context temporary `Shared` accessibility state keyed by
|
||||
/// `figcraft_a11y`.
|
||||
fn shared<R>(ctx: &Context, f: impl FnOnce(&mut Shared) -> R) -> R {
|
||||
ctx.data_mut(|d| f(d.get_temp_mut_or_default::<Shared>(Id::new("figcraft_a11y"))))
|
||||
ctx.data_mut(|d| f(d.get_temp_mut_or_default::<Shared>(Id::unique("figcraft_a11y"))))
|
||||
}
|
||||
|
||||
/// Whether a widget other than the canvas has the keyboard (a field, a focused button): the
|
||||
@@ -284,8 +290,10 @@ pub fn keyboard(ctx: &Context) -> bool {
|
||||
|
||||
/// Before the panels draw: pointer presses, the focused control's region, and the navigation
|
||||
/// keys (F6, Tab, arrows, Escape), which are taken out of the input when they move the keyboard.
|
||||
pub fn begin(app: &mut FigApp, ctx: &Context) {
|
||||
pub fn begin(app: &mut FigApp, ctx: &Context, root_scope: Id) {
|
||||
app.ui.nav.last = std::mem::take(&mut app.ui.nav.next);
|
||||
// Before the keys: a panel edge derived from the root scope must already be excluded.
|
||||
shared(ctx, |s| s.root_scope = Some(root_scope));
|
||||
keys(app, ctx);
|
||||
let keyboard = app.ui.nav.keyboard;
|
||||
shared(ctx, |s| {
|
||||
@@ -646,7 +654,13 @@ fn step_within(app: &mut FigApp, ctx: &Context, region: Region, step: i32) {
|
||||
/// them, are left out).
|
||||
fn focusables(ctx: &Context, nav: &Nav, region: Region) -> Vec<(Id, Rect)> {
|
||||
let canvas = nav.last.canvas;
|
||||
let edges = [Id::new("left_panel").with("__resize"), Id::new("right_panel").with("__resize")];
|
||||
// Both the real resize widgets (should a panel ever become resizable) and FigCraft's own
|
||||
// custom panel and pages-list edges, which must never enter the F6/Tab order.
|
||||
let mut edges = vec![Id::unique(("panel_edge", "left")), Id::unique(("panel_edge", "right")), Id::unique("pages_edge")];
|
||||
if let Some(root) = shared(ctx, |s| s.root_scope) {
|
||||
edges.push(root.with("left_panel").with("__resize"));
|
||||
edges.push(root.with("right_panel").with("__resize"));
|
||||
}
|
||||
ctx.viewport(|v| {
|
||||
v.prev_pass
|
||||
.widgets
|
||||
|
||||
@@ -403,3 +403,31 @@ fn the_align_buttons_are_off_for_a_layer_alone_on_the_page() {
|
||||
u.app.run("select.set", json!({"ids": [inner]}));
|
||||
assert!(!align_left(&tree(&mut u)), "one in a frame aligns to it");
|
||||
}
|
||||
|
||||
/// Asserts side-panel and Pages edges are not focusable, and Tab never stops on the
|
||||
/// side-panel edges.
|
||||
#[test]
|
||||
fn the_custom_panel_edges_never_enter_the_keyboard_or_focus_order() {
|
||||
// FigCraft's panels are fixed, so egui makes no `panel.__resize` widgets; the draggable edges
|
||||
// are `Id::unique(("panel_edge", ...))`. They sense the pointer but must not be focusable, or
|
||||
// Tab/F6 would stop on an unnamed control.
|
||||
let (mut u, _, _) = setup();
|
||||
let (left, right) = (egui::Id::unique(("panel_edge", "left")), egui::Id::unique(("panel_edge", "right")));
|
||||
// The Pages list's edge has the same contract as the side panels' edges.
|
||||
let pages = egui::Id::unique("pages_edge");
|
||||
for f in [left, right, pages] {
|
||||
let focusable = u.ctx.viewport(|v| v.prev_pass.widgets.get(f).map(|w| w.sense.is_focusable()));
|
||||
assert_eq!(focusable, Some(false), "the panel/pages edge still senses clicks/drags but is not focusable");
|
||||
}
|
||||
let mut seen = Vec::new();
|
||||
for back in [false, true] {
|
||||
f6(&mut u, back);
|
||||
for _ in 0..30 {
|
||||
press(&mut u, Key::Tab, Modifiers::NONE);
|
||||
if let Some(id) = u.ctx.memory(|m| m.focused()) {
|
||||
seen.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(!seen.contains(&left) && !seen.contains(&right), "Tab never stops on a panel edge: {seen:?}");
|
||||
}
|
||||
|
||||
@@ -164,8 +164,9 @@ enum Act {
|
||||
Hide(NodeId, bool),
|
||||
}
|
||||
|
||||
/// Stable egui id for the assets drag payload.
|
||||
fn drag_id() -> Id {
|
||||
Id::new("asset_drag")
|
||||
Id::unique("asset_drag")
|
||||
}
|
||||
|
||||
/// Whether component `id` has the same layers in two versions of a document (layers an edit
|
||||
@@ -276,7 +277,7 @@ fn refresh_libraries(app: &mut FigApp, ui: &Ui) {
|
||||
return;
|
||||
}
|
||||
let paths: Vec<String> = doc.libraries.enabled.iter().map(|l| l.path.clone()).collect();
|
||||
let id = Id::new("asset_libraries_checked");
|
||||
let id = Id::unique("asset_libraries_checked");
|
||||
let now = ui.input(|i| i.time);
|
||||
let last: Option<f64> = ui.data(|d| d.get_temp(id));
|
||||
if last.is_none_or(|l| now - l >= LIBRARY_REFRESH) {
|
||||
@@ -285,7 +286,7 @@ fn refresh_libraries(app: &mut FigApp, ui: &Ui) {
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(id, now));
|
||||
// Team libraries: in the background, at once for one not fetched yet.
|
||||
let sid = Id::new("asset_server_libraries_checked");
|
||||
let sid = Id::unique("asset_server_libraries_checked");
|
||||
let fetched: Option<f64> = ui.data(|d| d.get_temp(sid));
|
||||
let unread = paths.iter().any(|p| is_server_link(p) && app.session.libraries.cached(p).is_none());
|
||||
if paths.iter().any(|p| is_server_link(p)) && (unread || fetched.is_none_or(|l| now - l >= SERVER_REFRESH)) {
|
||||
@@ -299,7 +300,7 @@ fn refresh_libraries(app: &mut FigApp, ui: &Ui) {
|
||||
/// The search field with the libraries button; returns the lowercase query.
|
||||
fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let filter_id = Id::new("asset_filter");
|
||||
let filter_id = Id::unique("asset_filter");
|
||||
let mut filter: String = ui.data(|d| d.get_temp(filter_id)).unwrap_or_default();
|
||||
let (fr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover());
|
||||
let field = Rect::from_min_max(pos2(fr.min.x + 12.0, fr.min.y + 8.0), pos2(fr.max.x - 44.0, fr.max.y - 4.0));
|
||||
@@ -309,7 +310,7 @@ fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String {
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(te_rect));
|
||||
let r = child.add(
|
||||
egui::TextEdit::singleline(&mut filter)
|
||||
.id(Id::new("asset_filter_field"))
|
||||
.id(Id::unique("asset_filter_field"))
|
||||
.frame(egui::Frame::NONE)
|
||||
.font(regular(11.0))
|
||||
.hint_text(tr("Search assets"))
|
||||
@@ -324,7 +325,7 @@ fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String {
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(filter_id, filter.clone()));
|
||||
let br = Rect::from_center_size(pos2(fr.max.x - 26.0, field.center().y), vec2(24.0, 24.0));
|
||||
let bresp = ui.interact(br, Id::new("asset_libraries_btn"), Sense::click()).tip(tr("Libraries"));
|
||||
let bresp = ui.interact(br, Id::unique("asset_libraries_btn"), Sense::click()).tip(tr("Libraries"));
|
||||
if bresp.hovered() {
|
||||
ui.painter().rect_filled(br, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
@@ -342,7 +343,7 @@ fn updates_banner(app: &mut FigApp, ui: &mut Ui, w: f32, count: usize) {
|
||||
let r = Rect::from_min_max(pos2(row.min.x + 12.0, row.min.y + 4.0), pos2(row.max.x - 12.0, row.max.y - 4.0));
|
||||
ui.painter().rect_filled(r, CornerRadius::same(6), t.selected_row);
|
||||
let what = trn("{n} components and styles have newer versions", count, &[]);
|
||||
ui.interact(r, Id::new("asset_updates"), Sense::hover()).on_hover_text(what);
|
||||
ui.interact(r, Id::unique("asset_updates"), Sense::hover()).on_hover_text(what);
|
||||
ui.painter().text(pos2(r.min.x + 10.0, r.center().y), Align2::LEFT_CENTER, tr("Library updates available"), semibold(11.0), t.text);
|
||||
let mut child =
|
||||
ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_max(pos2(r.max.x - 70.0, r.min.y + 6.0), pos2(r.max.x - 6.0, r.max.y))));
|
||||
@@ -438,7 +439,7 @@ fn tiles(ui: &mut Ui, w: f32, src: &Source, list: &[Tile], thumbs: &Thumbs, want
|
||||
let r = Rect::from_min_size(pos2(x, y), vec2(tile, tile));
|
||||
let resp = ui.interact(
|
||||
r.union(Rect::from_min_size(r.left_bottom(), vec2(tile, TILE_LABEL))),
|
||||
Id::new(("asset", src, item.id.0)),
|
||||
Id::unique(("asset", src, item.id.0)),
|
||||
Sense::click_and_drag(),
|
||||
);
|
||||
ui.painter().rect_filled(r, CornerRadius::same(6), t.field);
|
||||
@@ -581,6 +582,10 @@ fn render_thumbs(ui: &Ui, w: f32, thumbs: &mut Thumbs, wanted: &[(Source, NodeId
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the Assets panel: refresh libraries, switch to the libraries view when asked,
|
||||
/// otherwise cache a listing keyed by revision, query and library state, render local and
|
||||
/// enabled-library assets with reused thumbnails, and dispatch place, style, hide, toggle
|
||||
/// and drag actions.
|
||||
pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
||||
refresh_libraries(app, ui);
|
||||
if app.ui.libraries {
|
||||
@@ -593,7 +598,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
||||
let revision = ds.revision;
|
||||
let q = search_row(app, ui, w);
|
||||
let key = (revision, q.clone(), libraries_key(app, &doc));
|
||||
let listing_id = Id::new("asset_listing");
|
||||
let listing_id = Id::unique("asset_listing");
|
||||
let listing = match ui.data(|d| d.get_temp::<Arc<Listing>>(listing_id)) {
|
||||
Some(l) if l.key == key => l,
|
||||
_ => {
|
||||
@@ -605,12 +610,12 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
||||
if listing.updates > 0 {
|
||||
updates_banner(app, ui, w, listing.updates);
|
||||
}
|
||||
let mut thumbs: Thumbs = ui.data(|d| d.get_temp(Id::new("asset_thumbs"))).unwrap_or_default();
|
||||
let mut thumbs: Thumbs = ui.data(|d| d.get_temp(Id::unique("asset_thumbs"))).unwrap_or_default();
|
||||
let mut sources: Vec<(Source, Arc<Document>)> = vec![(Source::Local, doc.clone())];
|
||||
sources.extend(listing.libraries.iter().filter_map(|l| l.doc.clone().map(|d| (Source::Library(l.id.clone()), d))));
|
||||
invalidate_thumbs(ui, &mut thumbs, &sources);
|
||||
let mut wanted = Vec::new();
|
||||
let collapsed_id = Id::new("asset_collapsed_libraries");
|
||||
let collapsed_id = Id::unique("asset_collapsed_libraries");
|
||||
let collapsed: HashSet<String> = ui.data(|d| d.get_temp(collapsed_id)).unwrap_or_default();
|
||||
let mut act = None;
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
@@ -657,7 +662,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
||||
render_thumbs(ui, w, &mut thumbs, &wanted);
|
||||
let drag = ui.data(|d| d.get_temp::<AssetDrag>(drag_id()));
|
||||
let tex = drag.as_ref().and_then(|d| thumbs.map.get(&(d.src.clone(), d.component)).cloned().flatten());
|
||||
ui.data_mut(|d| d.insert_temp(Id::new("asset_thumbs"), thumbs));
|
||||
ui.data_mut(|d| d.insert_temp(Id::unique("asset_thumbs"), thumbs));
|
||||
match act {
|
||||
Some(Act::Drag(d)) => ui.data_mut(|m| {
|
||||
m.insert_temp(drag_id(), d);
|
||||
@@ -773,9 +778,11 @@ fn drag_feedback(app: &mut FigApp, ui: &mut Ui, doc: &Document, libs: &HashMap<S
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
|
||||
/// Draw the 64×64 component ghost on the tooltip layer, using the thumbnail when available
|
||||
/// and the component or component-set icon otherwise.
|
||||
fn ghost(ui: &Ui, p: Pos2, set: bool, tex: Option<egui::TextureHandle>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::new("asset_ghost")));
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::unique("asset_ghost")));
|
||||
let r = Rect::from_center_size(p, vec2(64.0, 64.0));
|
||||
painter.rect_filled(r, CornerRadius::same(6), t.panel.gamma_multiply(0.9));
|
||||
painter.rect_stroke(r, CornerRadius::same(6), Stroke::new(1.0, COMPONENT), egui::StrokeKind::Inside);
|
||||
@@ -812,7 +819,7 @@ fn libraries_view(app: &mut FigApp, ui: &mut Ui) {
|
||||
let w = ui.available_width();
|
||||
let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover());
|
||||
let back = Rect::from_center_size(pos2(hr.min.x + 24.0, hr.center().y), vec2(24.0, 24.0));
|
||||
let bresp = ui.interact(back, Id::new("asset_libraries_back"), Sense::click()).tip(tr("Back to assets"));
|
||||
let bresp = ui.interact(back, Id::unique("asset_libraries_back"), Sense::click()).tip(tr("Back to assets"));
|
||||
if bresp.hovered() {
|
||||
ui.painter().rect_filled(back, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
@@ -831,8 +838,12 @@ fn libraries_view(app: &mut FigApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw the publish form: status, publishable components/styles/collections, description and
|
||||
/// publish buttons. Run `library.publish` locally or publish to the server in the
|
||||
/// background, reset the form on success, and report when the file is unsaved so other files
|
||||
/// cannot yet use it.
|
||||
fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
|
||||
let form_id = Id::new("asset_publish_form");
|
||||
let form_id = Id::unique("asset_publish_form");
|
||||
let mut form: PublishForm = ui.data(|d| d.get_temp(form_id)).unwrap_or_default();
|
||||
title(ui, w, tr("This file"));
|
||||
let m = doc.published.as_ref().map(|p| &p.manifest);
|
||||
@@ -901,7 +912,7 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(PAD);
|
||||
if let Some(d) =
|
||||
crate::widgets::text_field(ui, Id::new("asset_publish_description"), &description, w - PAD * 2.0, tr("Description (optional)"))
|
||||
crate::widgets::text_field(ui, Id::unique("asset_publish_description"), &description, w - PAD * 2.0, tr("Description (optional)"))
|
||||
{
|
||||
form.description = Some(d);
|
||||
}
|
||||
@@ -965,7 +976,7 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
|
||||
/// How other files use this file's library on its server (asked for with a button: it is a
|
||||
/// request to the server).
|
||||
fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) {
|
||||
let usage_id = Id::new(("asset_library_usage", link));
|
||||
let usage_id = Id::unique(("asset_library_usage", link));
|
||||
let loading = app.session.waiting_for(USAGE);
|
||||
let (mut ask, mut stop) = (false, false);
|
||||
ui.horizontal(|ui| {
|
||||
@@ -1010,6 +1021,8 @@ fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) {
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
|
||||
/// Draw the libraries enabled in this file with version/count/status, remove and swap
|
||||
/// actions, add-file and pasted-link controls, and background fetch/cancel handling.
|
||||
fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
title(ui, w, tr("Libraries in this file"));
|
||||
@@ -1050,9 +1063,9 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
|
||||
let clip = Rect::from_min_size(pos2(r.min.x + PAD, r.min.y), vec2(text_w, r.height()));
|
||||
ui.painter().with_clip_rect(clip).galley(pos2(r.min.x + PAD, r.min.y + 6.0), name, t.text);
|
||||
ui.painter().with_clip_rect(clip).galley(pos2(r.min.x + PAD, r.min.y + 23.0), line, t.text_secondary);
|
||||
ui.interact(clip, Id::new(("asset_lib_row", &l.id)), Sense::hover()).on_hover_text(shown_path(&l.path));
|
||||
ui.interact(clip, Id::unique(("asset_lib_row", &l.id)), Sense::hover()).on_hover_text(shown_path(&l.path));
|
||||
let x = Rect::from_center_size(pos2(r.max.x - 24.0, r.center().y), vec2(24.0, 24.0));
|
||||
let xr = ui.interact(x, Id::new(("asset_lib_remove", &l.id)), Sense::click()).tip(tr("Remove library from this file"));
|
||||
let xr = ui.interact(x, Id::unique(("asset_lib_remove", &l.id)), Sense::click()).tip(tr("Remove library from this file"));
|
||||
if xr.hovered() {
|
||||
ui.painter().rect_filled(x, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
@@ -1063,7 +1076,7 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
|
||||
if swappable {
|
||||
let sw = Rect::from_center_size(pos2(r.max.x - 52.0, r.center().y), vec2(24.0, 24.0));
|
||||
let sr = ui
|
||||
.interact(sw, Id::new(("asset_lib_swap", &l.id)), Sense::click())
|
||||
.interact(sw, Id::unique(("asset_lib_swap", &l.id)), Sense::click())
|
||||
.tip(tr("Swap library: use another library's assets of the same names"));
|
||||
if sr.hovered() {
|
||||
ui.painter().rect_filled(sw, CornerRadius::same(5), t.hover);
|
||||
@@ -1089,7 +1102,7 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
|
||||
let pasted = ui
|
||||
.horizontal(|ui| {
|
||||
ui.add_space(PAD);
|
||||
crate::widgets::text_field(ui, Id::new("asset_add_link"), "", w - PAD * 2.0, tr("Paste a team library link (a view link)"))
|
||||
crate::widgets::text_field(ui, Id::unique("asset_add_link"), "", w - PAD * 2.0, tr("Paste a team library link (a view link)"))
|
||||
})
|
||||
.inner;
|
||||
if let Some(link) = pasted.map(|l| l.trim().to_string()).filter(|l| !l.is_empty())
|
||||
@@ -1140,7 +1153,7 @@ fn added(app: &mut FigApp, r: &Value) {
|
||||
/// Tests: the components whose thumbnails are cached (for this file's assets).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn cached_thumbnails(ctx: &egui::Context) -> Vec<NodeId> {
|
||||
let thumbs: Thumbs = ctx.data(|d| d.get_temp(Id::new("asset_thumbs"))).unwrap_or_default();
|
||||
let thumbs: Thumbs = ctx.data(|d| d.get_temp(Id::unique("asset_thumbs"))).unwrap_or_default();
|
||||
let mut out: Vec<NodeId> = thumbs.map.keys().filter(|(s, _)| *s == Source::Local).map(|(_, id)| *id).collect();
|
||||
out.sort();
|
||||
out
|
||||
|
||||
+24
-250
@@ -1,69 +1,23 @@
|
||||
//! Names in right-to-left and mixed-direction scripts (Hebrew, Arabic…) in the panels: layer, page,
|
||||
//! style, component and file names. egui has no bidi: it shapes each run of one font on its own,
|
||||
//! reversing right-to-left runs but leaving the words and runs of a name in logical order. So
|
||||
//! names go through [`figcraft_text::display`] (visual order, Arabic letters joined, mirrored
|
||||
//! brackets) and every shown cluster is laid out as its own section, which egui's shaper leaves as
|
||||
//! it is. Names are elided at their end (the left end of a right-to-left name) and edited by
|
||||
//! [`single_line`], whose caret, clicks and selection follow the shown text (the arrow keys move
|
||||
//! visually). Left-to-right names take egui's own path unchanged.
|
||||
//! style, component and file names. egui 0.37's epaint shapes the logical (typing-order) text with
|
||||
//! the Unicode bidirectional algorithm (UAX #9), lays every run out in visual order, joins Arabic
|
||||
//! letters through the font's OpenType features, mirrors brackets, and keeps one glyph per char so
|
||||
//! that caret, selection and click offsets stay logical. Names are therefore passed to egui in
|
||||
//! logical order and elided at their visual end. The rename editor is egui's own single-line
|
||||
//! `TextEdit`, whose arrow keys move the caret on screen while its state remains a logical range.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::text::{ByteIndex, CCursor, CCursorRange, LayoutJob, LayoutSection, TextFormat, TextWrapping};
|
||||
use egui::{Color32, FontId, Galley, Id, Painter, Pos2, Rect, Response, Stroke, Ui, pos2, vec2};
|
||||
use figcraft_text::display::{self, VisualLine};
|
||||
|
||||
/// Shown (visual, elided) lines by name, font and width: elision measures many candidates, so the
|
||||
/// result is kept between frames (cleared when it grows large).
|
||||
#[derive(Clone, Default)]
|
||||
struct Elided(Arc<std::sync::Mutex<HashMap<ElidedKey, VisualLine>>>);
|
||||
|
||||
/// A name, its font's size and family (proportional or not) and the width it was cut to (bits).
|
||||
type ElidedKey = (String, u32, bool, u32);
|
||||
|
||||
const ELIDED_MAX: usize = 4096;
|
||||
use egui::text::{LayoutJob, TextWrapping};
|
||||
use egui::{Color32, FontId, Galley, Id, Painter, Pos2, Rect, Response, Ui, pos2, vec2};
|
||||
|
||||
/// `name` on one line in `font`, at most `max_w` wide (ending in an ellipsis when cut), ready to
|
||||
/// paint: in visual order with Arabic letters joined when it has right-to-left text.
|
||||
/// paint. The text stays in logical order; egui shapes, joins and reorders it, and puts the
|
||||
/// ellipsis at the row's visual end (the left end of a right-to-left name).
|
||||
pub fn name_galley(painter: &Painter, name: &str, font: FontId, color: Color32, max_w: f32) -> Arc<Galley> {
|
||||
let max_w = max_w.max(1.0);
|
||||
if !display::has_rtl(name) {
|
||||
let mut job = LayoutJob::simple_singleline(name.to_string(), font, color);
|
||||
job.wrap = TextWrapping::truncate_at_width(max_w);
|
||||
return painter.layout_job(job);
|
||||
}
|
||||
let key = (name.to_string(), font.size.to_bits(), matches!(font.family, egui::FontFamily::Proportional), max_w.to_bits());
|
||||
let cache = painter.ctx().data_mut(|d| d.get_temp_mut_or_default::<Elided>(Id::new("bidi_elided")).clone());
|
||||
let hit = cache.0.lock().ok().and_then(|m| m.get(&key).cloned());
|
||||
let shown = hit.unwrap_or_else(|| {
|
||||
let fits = |v: &VisualLine| visual_galley(painter, v, &font, color).size().x <= max_w;
|
||||
let v = display::elide(name, fits);
|
||||
if let Ok(mut m) = cache.0.lock() {
|
||||
if m.len() >= ELIDED_MAX {
|
||||
m.clear();
|
||||
}
|
||||
m.insert(key, v.clone());
|
||||
}
|
||||
v
|
||||
});
|
||||
visual_galley(painter, &shown, &font, color)
|
||||
}
|
||||
|
||||
/// A line in display order laid out with each cluster as its own section, so egui's shaper (which
|
||||
/// would reverse a right-to-left run once more) gets one ordered, joined cluster at a time.
|
||||
fn visual_galley(painter: &Painter, line: &VisualLine, font: &FontId, color: Color32) -> Arc<Galley> {
|
||||
let chars: Vec<char> = line.text.chars().collect();
|
||||
let format = TextFormat::simple(font.clone(), color);
|
||||
let mut job = LayoutJob::default();
|
||||
for c in &line.clusters {
|
||||
let start = job.text.len();
|
||||
job.text.extend(chars.get(c.shown.clone()).unwrap_or_default());
|
||||
// Pushed as is: `LayoutJob::append` would merge it with the section before.
|
||||
if job.text.len() > start {
|
||||
job.sections.push(LayoutSection { leading_space: 0.0, byte_range: ByteIndex(start)..ByteIndex(job.text.len()), format: format.clone() });
|
||||
}
|
||||
}
|
||||
let mut job = LayoutJob::simple_singleline(name.to_string(), font, color);
|
||||
job.wrap = TextWrapping::truncate_at_width(max_w);
|
||||
painter.layout_job(job)
|
||||
}
|
||||
|
||||
@@ -76,209 +30,29 @@ pub fn paint_name(painter: &Painter, left_center: Pos2, name: &str, font: FontId
|
||||
rect
|
||||
}
|
||||
|
||||
/// Each shown cluster's left and right x in a galley of `v.text` laid out from x = 0.
|
||||
fn edges(v: &VisualLine, galley: &Galley) -> Vec<(f64, f64)> {
|
||||
let chars: Vec<(f64, f64)> = galley
|
||||
.rows
|
||||
.first()
|
||||
.map(|r| r.row.glyphs.iter().map(|g| (f64::from(g.pos.x), f64::from(g.pos.x + g.advance_width))).collect())
|
||||
.unwrap_or_default();
|
||||
v.cluster_edges(&chars)
|
||||
}
|
||||
|
||||
/// The shown text of a field being edited, laid out once per frame.
|
||||
struct Shown {
|
||||
line: VisualLine,
|
||||
galley: Arc<Galley>,
|
||||
edges: Vec<(f64, f64)>,
|
||||
}
|
||||
|
||||
impl Shown {
|
||||
fn new(ui: &Ui, text: &str, font: &FontId, color: Color32) -> Shown {
|
||||
let line = display::visual_line(text);
|
||||
let galley = visual_galley(ui.painter(), &line, font, color);
|
||||
let edges = edges(&line, &galley);
|
||||
Shown { line, galley, edges }
|
||||
}
|
||||
|
||||
fn caret_x(&self, i: usize) -> f32 {
|
||||
self.line.caret_x(i, &self.edges) as f32
|
||||
}
|
||||
|
||||
fn hit(&self, x: f32) -> usize {
|
||||
self.line.hit(f64::from(x), &self.edges)
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_id(id: Id) -> Id {
|
||||
id.with("bidi_scroll")
|
||||
}
|
||||
|
||||
fn anchor_id(id: Id) -> Id {
|
||||
id.with("bidi_anchor")
|
||||
}
|
||||
|
||||
/// A frameless one-line text editor for a name (a [`egui::TextEdit`] with id `id`). Text with
|
||||
/// right-to-left characters shows in visual order, and the caret, the selection, clicks, drags and
|
||||
/// the left and right arrow keys follow what's on screen.
|
||||
/// A frameless one-line text editor for a name (a [`egui::TextEdit`] with id `id`). The editor's
|
||||
/// text is logical; egui's galley paints it in visual order, and its caret, selection, clicks,
|
||||
/// drags and left and right arrows follow what is on screen while reporting logical char offsets.
|
||||
pub fn single_line(ui: &mut Ui, id: Id, text: &mut String, font: FontId, width: f32) -> Response {
|
||||
let rtl = display::has_rtl(text);
|
||||
let focused = ui.memory(|m| m.has_focus(id));
|
||||
let color = ui.visuals().text_color();
|
||||
if rtl && focused {
|
||||
visual_arrows(ui, id, text, &font, color);
|
||||
}
|
||||
// Centred in the height it's given (a rename row), like the name it replaces.
|
||||
let height = ui.available_height().max(0.0);
|
||||
let edit = egui::TextEdit::singleline(text)
|
||||
egui::TextEdit::singleline(text)
|
||||
.id(id)
|
||||
.frame(egui::Frame::NONE)
|
||||
.font(font.clone())
|
||||
.font(font)
|
||||
.desired_width(width)
|
||||
.vertical_align(egui::Align::Center)
|
||||
.min_size(vec2(0.0, height));
|
||||
if !rtl {
|
||||
ui.data_mut(|d| d.remove::<f32>(scroll_id(id)));
|
||||
return edit.show(ui).response.response;
|
||||
}
|
||||
// egui would draw the chars in logical order: hide its text, caret and selection and draw the
|
||||
// shown text over it.
|
||||
let out = ui
|
||||
.scope(|ui| {
|
||||
let v = ui.visuals_mut();
|
||||
v.selection.bg_fill = Color32::TRANSPARENT;
|
||||
v.selection.stroke = Stroke::new(0.0, Color32::TRANSPARENT);
|
||||
v.text_cursor.stroke = Stroke::NONE;
|
||||
edit.text_color(Color32::TRANSPARENT).show(ui)
|
||||
})
|
||||
.inner;
|
||||
let response = out.response.response;
|
||||
let shown = Shown::new(ui, text, &font, color);
|
||||
let clip = out.text_clip_rect;
|
||||
let mut scroll: f32 = ui.data(|d| d.get_temp(scroll_id(id))).unwrap_or(0.0);
|
||||
let origin = |scroll: f32| pos2(clip.min.x - scroll, out.galley_pos.y);
|
||||
// Clicks and drags place the caret by what's on screen.
|
||||
let mut range = out.state.cursor.char_range();
|
||||
if let Some(p) = response.interact_pointer_pos() {
|
||||
let at = shown.hit(p.x - origin(scroll).x);
|
||||
if response.double_clicked() {
|
||||
let (a, b) = word_at(text, at);
|
||||
range = Some(CCursorRange::two(CCursor::new(a), CCursor::new(b)));
|
||||
} else if response.clicked() || (response.is_pointer_button_down_on() && !response.dragged()) {
|
||||
// Pressed (not yet dragging): the caret and the drag's anchor go here.
|
||||
ui.data_mut(|d| d.insert_temp(anchor_id(id), at));
|
||||
range = Some(CCursorRange::one(CCursor::new(at)));
|
||||
} else if response.dragged() {
|
||||
let anchor = ui.data(|d| d.get_temp::<usize>(anchor_id(id))).unwrap_or(at);
|
||||
range = Some(CCursorRange { primary: CCursor::new(at), secondary: CCursor::new(anchor), h_pos: None });
|
||||
}
|
||||
if range != out.state.cursor.char_range() {
|
||||
let mut state = out.state.clone();
|
||||
state.cursor.set_char_range(range);
|
||||
state.store(ui.ctx(), id);
|
||||
}
|
||||
}
|
||||
// Keep the caret in view.
|
||||
let caret = range.map_or(0, |r| r.primary.index.0);
|
||||
let cx = shown.caret_x(caret);
|
||||
let w = clip.width().max(1.0);
|
||||
if cx - scroll > w - 1.0 {
|
||||
scroll = cx - w + 1.0;
|
||||
}
|
||||
if cx - scroll < 0.0 {
|
||||
scroll = cx;
|
||||
}
|
||||
scroll = scroll.clamp(0.0, (shown.galley.size().x - w + 1.0).max(0.0));
|
||||
ui.data_mut(|d| d.insert_temp(scroll_id(id), scroll));
|
||||
let o = origin(scroll);
|
||||
let painter = ui.painter().with_clip_rect(clip.expand2(vec2(1.0, 0.0)));
|
||||
let h = shown.galley.size().y;
|
||||
if focused && let Some(r) = range {
|
||||
let (a, b) = (r.primary.index.0, r.secondary.index.0);
|
||||
for (x0, x1) in shown.line.selection(a, b, &shown.edges) {
|
||||
let sel = Rect::from_min_max(pos2(o.x + x0 as f32, o.y), pos2(o.x + x1 as f32, o.y + h));
|
||||
painter.rect_filled(sel, 0.0, ui.visuals().selection.bg_fill);
|
||||
}
|
||||
}
|
||||
painter.galley(o, shown.galley.clone(), color);
|
||||
if focused {
|
||||
let x = (o.x + cx).round() + 0.5;
|
||||
painter.line_segment([pos2(x, o.y), pos2(x, o.y + h)], ui.visuals().text_cursor.stroke);
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
/// Plain and Shift-held left and right arrows move the caret across the shown text (consumed so
|
||||
/// the editor doesn't move it logically). Word and line jumps stay logical.
|
||||
fn visual_arrows(ui: &mut Ui, id: Id, text: &str, font: &FontId, color: Color32) {
|
||||
let moves: Vec<(bool, bool)> = ui.input_mut(|i| {
|
||||
let mut out = Vec::new();
|
||||
i.events.retain(|e| match e {
|
||||
egui::Event::Key { key: key @ (egui::Key::ArrowLeft | egui::Key::ArrowRight), pressed: true, modifiers, .. }
|
||||
if !modifiers.alt && !modifiers.command && !modifiers.ctrl =>
|
||||
{
|
||||
out.push((*key == egui::Key::ArrowRight, modifiers.shift));
|
||||
false
|
||||
}
|
||||
_ => true,
|
||||
});
|
||||
out
|
||||
});
|
||||
if moves.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) else { return };
|
||||
let shown = Shown::new(ui, text, font, color);
|
||||
let mut r = state.cursor.char_range().unwrap_or_default();
|
||||
for (right, shift) in moves {
|
||||
let (lo, hi) = (r.primary.index.0.min(r.secondary.index.0), r.primary.index.0.max(r.secondary.index.0));
|
||||
let next = if !shift && lo != hi {
|
||||
// A selection collapses to its end on the side of the arrow.
|
||||
let (xa, xb) = (shown.caret_x(lo), shown.caret_x(hi));
|
||||
if (xa < xb) == right { hi } else { lo }
|
||||
} else {
|
||||
shown.line.step(r.primary.index.0, right, &shown.edges)
|
||||
};
|
||||
r = if shift {
|
||||
CCursorRange { primary: CCursor::new(next), secondary: r.secondary, h_pos: None }
|
||||
} else {
|
||||
CCursorRange::one(CCursor::new(next))
|
||||
};
|
||||
}
|
||||
state.cursor.set_char_range(Some(r));
|
||||
state.store(ui.ctx(), id);
|
||||
}
|
||||
|
||||
/// The word around char `i` (letters, digits and marks; else the single char).
|
||||
fn word_at(text: &str, i: usize) -> (usize, usize) {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let is_word =
|
||||
|k: usize| chars.get(k).is_some_and(|c| c.is_alphanumeric() || matches!(*c as u32, 0x0300..=0x036F | 0x0591..=0x05C7 | 0x064B..=0x065F));
|
||||
let i = i.min(chars.len());
|
||||
let at = if is_word(i) {
|
||||
i
|
||||
} else if i > 0 && is_word(i - 1) {
|
||||
i - 1
|
||||
} else {
|
||||
return (i, (i + 1).min(chars.len()));
|
||||
};
|
||||
let mut a = at;
|
||||
while a > 0 && is_word(a - 1) {
|
||||
a -= 1;
|
||||
}
|
||||
let mut b = at;
|
||||
while is_word(b) {
|
||||
b += 1;
|
||||
}
|
||||
(a, b)
|
||||
.align(egui::Align2::LEFT_CENTER)
|
||||
.min_size(vec2(0.0, height))
|
||||
.show(ui)
|
||||
.response
|
||||
.response
|
||||
}
|
||||
|
||||
/// Faces the UI adds for scripts its own fonts lack (desktop: from the system), as egui font data;
|
||||
/// `taken` as for [`crate::theme::system_fallbacks`].
|
||||
pub fn script_fallbacks(have: &dyn Fn(char) -> bool, taken: &mut Vec<Arc<figcraft_text::Face>>) -> Vec<(String, egui::FontData)> {
|
||||
// Hebrew, Arabic letters, Arabic presentation forms (what joined names are drawn with).
|
||||
const SAMPLES: [char; 3] = ['\u{05D0}', '\u{0628}', '\u{FE91}'];
|
||||
// Hebrew and Arabic letters; egui's shaper selects the joined contextual forms itself.
|
||||
const SAMPLES: [char; 2] = ['\u{05D0}', '\u{0628}'];
|
||||
const FAMILIES: [&str; 11] = [
|
||||
"Segoe UI",
|
||||
"Arial",
|
||||
|
||||
@@ -170,7 +170,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let size = vec2(h.width(), 40.0);
|
||||
let at = place(sel, size, app.canvas.rect);
|
||||
let t = Tokens::get(ctx);
|
||||
egui::Area::new(egui::Id::new("board_bar")).fixed_pos(at).order(egui::Order::Middle).show(ctx, |ui| {
|
||||
egui::Area::new(egui::Id::unique("board_bar")).fixed_pos(at).order(egui::Order::Middle).show(ctx, |ui| {
|
||||
let (rect, _) = ui.allocate_exact_size(size, Sense::click());
|
||||
a11y::container_bounds(ui, rect);
|
||||
let p = ui.painter();
|
||||
@@ -198,7 +198,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
fn color_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 {
|
||||
for (i, (name, c)) in BOARD_COLORS.iter().enumerate() {
|
||||
let r = Rect::from_center_size(pos2(x + SWATCH / 2.0, cy), vec2(SWATCH, SWATCH));
|
||||
let resp = ui.interact(r, ui.id().with(("bb_color", i)), Sense::click()).on_hover_text(tr(color_label(name)));
|
||||
let resp = ui.interact(r, ui.scope_id().with(("bb_color", i)), Sense::click()).on_hover_text(tr(color_label(name)));
|
||||
a11y::button(&resp, tr(color_label(name)));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r.expand(2.0), CornerRadius::same(6), t.hover);
|
||||
@@ -215,7 +215,7 @@ fn color_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) ->
|
||||
/// The shape menu at `at` (its left edge and middle), showing `shape`; where the next part starts.
|
||||
fn shape_part(app: &mut FigApp, ui: &mut Ui, shape: BoardShape, at: Pos2, t: &Tokens) -> f32 {
|
||||
let r = Rect::from_min_size(pos2(at.x, at.y - CELL / 2.0), vec2(CELL + 12.0, CELL));
|
||||
let resp = ui.interact(r, ui.id().with("bb_shape"), Sense::click()).on_hover_text(tr("Shape"));
|
||||
let resp = ui.interact(r, ui.scope_id().with("bb_shape"), Sense::click()).on_hover_text(tr("Shape"));
|
||||
a11y::menu_button(&resp, tr("Shape"), a11y::menu_open(&resp));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||||
@@ -240,7 +240,7 @@ fn shape_part(app: &mut FigApp, ui: &mut Ui, shape: BoardShape, at: Pos2, t: &To
|
||||
fn size_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 {
|
||||
for (label, size) in TEXT_SIZES {
|
||||
let r = Rect::from_center_size(pos2(x + CELL / 2.0, cy), vec2(CELL, CELL));
|
||||
let resp = ui.interact(r, ui.id().with(("bb_size", label)), Sense::click()).on_hover_text(tr("Text size"));
|
||||
let resp = ui.interact(r, ui.scope_id().with(("bb_size", label)), Sense::click()).on_hover_text(tr("Text size"));
|
||||
a11y::button(&resp, &format!("{} {label}", tr("Text size")));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||||
|
||||
@@ -56,12 +56,12 @@ fn pick_option(u: &mut Ui, i: usize, n: usize) {
|
||||
|
||||
/// Whether the bar over the selection showed in the last frame.
|
||||
fn bar_shown(ctx: &egui::Context) -> bool {
|
||||
ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Middle, egui::Id::new("board_bar"))))
|
||||
ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Middle, egui::Id::unique("board_bar"))))
|
||||
}
|
||||
|
||||
/// The bar over the selection, when it shows.
|
||||
fn bar(u: &Ui) -> Option<Rect> {
|
||||
bar_shown(&u.ctx).then(|| u.ctx.memory(|m| m.area_rect(egui::Id::new("board_bar")))).flatten()
|
||||
bar_shown(&u.ctx).then(|| u.ctx.memory(|m| m.area_rect(egui::Id::unique("board_bar")))).flatten()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -75,7 +75,7 @@ pub fn banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) {
|
||||
let Some(b) = m.branches.iter().find(|b| b.id == id) else { return };
|
||||
let (file, name, active) = (m.doc.name.clone(), b.name.clone(), b.state == BranchState::Active);
|
||||
let t = Tokens::get(ctx);
|
||||
let review = widgets::canvas_banner(ctx, Id::new("branch_banner"), canvas, |ui| {
|
||||
let review = widgets::canvas_banner(ctx, Id::unique("branch_banner"), canvas, |ui| {
|
||||
ui.label(egui::RichText::new(format!("“{name}”")).font(semibold(11.0)).color(t.text));
|
||||
let state =
|
||||
if active { trf("Branch of {file}", &[("file", &file)]) } else { trf("Branch of {file} · merged or archived", &[("file", &file)]) };
|
||||
@@ -146,7 +146,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let mut open = true;
|
||||
let mut action = None;
|
||||
egui::Window::new("Review and merge")
|
||||
.id(Id::new("branch_review_window"))
|
||||
.id(Id::unique("branch_review_window"))
|
||||
.title_bar(false)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
|
||||
@@ -163,12 +163,16 @@ fn pointer_event(app: &FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one canvas pass: update the viewport size and first-fit zoom; handle pointer/key input
|
||||
/// and accessibility; draw the drop overlay, guides, live pointer and preedit; request/poll
|
||||
/// renders and upload the texture; draw the page background and last texture; then draw overlays,
|
||||
/// labels, handles, comments, versions/branch/dev/live UI and the cursor.
|
||||
pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
||||
let rect = ui.max_rect();
|
||||
app.canvas.rect = rect;
|
||||
let ctx = ui.ctx().clone();
|
||||
let ppp = ctx.pixels_per_point();
|
||||
let resp = ui.interact(rect, ui.id().with("canvas"), Sense::click_and_drag());
|
||||
let resp = ui.interact(rect, ui.scope_id().with("canvas"), Sense::click_and_drag());
|
||||
let key_box = app.ui.key_box;
|
||||
crate::a11y::canvas(&mut app.ui.nav, &resp, || key_box.map_or_else(|| describe(&app.session), describe_box));
|
||||
if let Ok(ds) = app.session.ds_mut()
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Programmatic gestures never replace the user's system clipboard: keys and clicks injected
|
||||
//! through the control channel (`ui.input`, `ui.menu.invoke`, …) may copy inside FigCraft but the
|
||||
//! OS clipboard is the user's. Every copy command goes through `FigApp::clipboard_set_text` /
|
||||
//! `FigApp::clipboard_set_png`, which drop the write while the frame's input is synthetic.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use egui::{Event, Pos2, RawInput, Rect, vec2};
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::control::ControlRequest;
|
||||
use crate::{FigApp, Services};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Recorded {
|
||||
text: Vec<String>,
|
||||
pngs: usize,
|
||||
/// What the system clipboard holds now (the last text written).
|
||||
last_text: Option<String>,
|
||||
}
|
||||
|
||||
/// The system clipboard of the test machine.
|
||||
struct Clipboard(Arc<Mutex<Recorded>>);
|
||||
|
||||
impl Services for Clipboard {
|
||||
/// Record a system-clipboard text write in the test clipboard service.
|
||||
fn clipboard_set_text(&mut self, s: &str) {
|
||||
let mut r = self.0.lock().unwrap();
|
||||
r.text.push(s.to_string());
|
||||
r.last_text = Some(s.to_string());
|
||||
}
|
||||
/// Return the test system clipboard's last text.
|
||||
fn clipboard_get_text(&mut self) -> Option<String> {
|
||||
self.0.lock().unwrap().last_text.clone()
|
||||
}
|
||||
/// Count a PNG clipboard write in the test clipboard service.
|
||||
fn clipboard_set_png(&mut self, _w: u32, _h: u32, _rgba: &[u8]) {
|
||||
self.0.lock().unwrap().pngs += 1;
|
||||
}
|
||||
}
|
||||
|
||||
struct Win {
|
||||
ctx: egui::Context,
|
||||
app: FigApp,
|
||||
time: f64,
|
||||
recorded: Arc<Mutex<Recorded>>,
|
||||
}
|
||||
|
||||
impl Win {
|
||||
/// Create a test window over a session containing one selected rectangle.
|
||||
fn new() -> Self {
|
||||
let mut s = Session::new();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
let mut win = Self::with_session(s);
|
||||
let _ = win.app.run("select.all", json!({}));
|
||||
win
|
||||
}
|
||||
|
||||
/// Build a test window around `s` with a recording clipboard service and run three frames.
|
||||
fn with_session(s: Session) -> Self {
|
||||
let recorded = Arc::new(Mutex::new(Recorded::default()));
|
||||
let app = FigApp::new(s, Box::new(Clipboard(recorded.clone())));
|
||||
let mut win = Self { ctx: egui::Context::default(), app, time: 0.0, recorded };
|
||||
for _ in 0..3 {
|
||||
win.frame(vec![]);
|
||||
}
|
||||
win
|
||||
}
|
||||
|
||||
/// Run one production UI frame with `events` at the next 60 Hz test time.
|
||||
fn frame(&mut self, events: Vec<Event>) {
|
||||
self.time += 1.0 / 60.0;
|
||||
self.app.clock.set_test_now(Some(self.time));
|
||||
let input =
|
||||
RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() };
|
||||
let app = &mut self.app;
|
||||
let mut out = self.ctx.run_ui(input, |ui| app.ui(ui));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// Return the recorded `(text writes, PNG writes)` count.
|
||||
fn writes(&self) -> (usize, usize) {
|
||||
let r = self.recorded.lock().unwrap();
|
||||
(r.text.len(), r.pngs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Asserts that a control-channel copy shortcut writes no system clipboard, still runs the
|
||||
/// command and is acknowledged.
|
||||
#[test]
|
||||
fn a_synthetic_copy_shortcut_leaves_the_system_clipboard_alone() {
|
||||
let mut w = Win::new();
|
||||
assert!(!w.app.session.selection().is_empty(), "something to copy");
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
w.app.control_queue.push(ControlRequest {
|
||||
id: json!(1),
|
||||
method: "ui.input".into(),
|
||||
// Shift+Cmd+C: `edit.copyAsPng`, which wrote the OS clipboard directly before the fence.
|
||||
params: json!({"events": [{"key": "C", "shift": true, "cmd": true}]}),
|
||||
reply: tx,
|
||||
state: Default::default(),
|
||||
});
|
||||
w.frame(vec![]);
|
||||
let events = crate::windows::take_input(&mut w.app, egui::ViewportId::ROOT);
|
||||
assert!(!events.is_empty(), "the injected shortcut");
|
||||
w.frame(events);
|
||||
assert_eq!(w.writes(), (0, 0), "a control-channel gesture never replaces the user's clipboard");
|
||||
assert!(
|
||||
w.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Copied")),
|
||||
"the command itself ran (only its OS clipboard write is fenced): {:?}",
|
||||
w.app.ui.toast
|
||||
);
|
||||
assert!(rx.try_recv().is_ok(), "the batch is acknowledged");
|
||||
}
|
||||
|
||||
/// Asserts copy commands run under the synthetic-control fence write nothing, while the same
|
||||
/// command afterwards writes once.
|
||||
#[test]
|
||||
fn a_copy_command_invoked_for_a_synthetic_click_still_keeps_the_clipboard() {
|
||||
let mut w = Win::new();
|
||||
// What a synthetic pointer click on Copy runs, fenced as `control::drain`/`background` fence it.
|
||||
w.app.control_synthetic = true;
|
||||
crate::menus::invoke(&mut w.app, "edit.copyAsCss");
|
||||
crate::menus::invoke(&mut w.app, "edit.copyAsText");
|
||||
assert_eq!(w.writes(), (0, 0));
|
||||
w.app.control_synthetic = false;
|
||||
// A user's own copy still writes.
|
||||
crate::menus::invoke(&mut w.app, "edit.copyAsCss");
|
||||
assert_eq!(w.writes(), (1, 0));
|
||||
}
|
||||
|
||||
/// Asserts a real `ui.menu.invoke` of Copy link is fenced from the system clipboard, while the
|
||||
/// same menu item invoked by the user writes the selected layer's link.
|
||||
#[test]
|
||||
fn a_synthetic_ui_menu_invoke_copy_link_keeps_the_clipboard_then_a_user_copy_writes_the_link() {
|
||||
let mut w = Win::new();
|
||||
let selected = *w.app.session.selection().first().expect("a selected layer");
|
||||
let link = format!("figcraft://node/{}", selected.0);
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
w.app.control_queue.push(ControlRequest {
|
||||
id: json!(1),
|
||||
method: "ui.menu.invoke".into(),
|
||||
params: json!({"id": "edit.copyLink"}),
|
||||
reply: tx,
|
||||
state: Default::default(),
|
||||
});
|
||||
w.frame(vec![]);
|
||||
let reply = rx.try_recv().expect("the menu invocation is acknowledged");
|
||||
assert_eq!(reply.id, json!(1));
|
||||
assert!(reply.result.is_ok(), "the menu item ran: {:?}", reply.result);
|
||||
assert_eq!(w.writes(), (0, 0), "a synthetic menu invocation never replaces the user's clipboard");
|
||||
assert_eq!(w.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Link copied"));
|
||||
assert!(!w.app.control_synthetic, "the fence ends with the frame");
|
||||
|
||||
crate::menus::invoke(&mut w.app, "edit.copyLink");
|
||||
assert_eq!(w.writes(), (1, 0), "the user's invocation writes exactly one text clipboard entry");
|
||||
let recorded = w.recorded.lock().unwrap();
|
||||
assert_eq!(recorded.text, vec![link.clone()]);
|
||||
assert_eq!(recorded.last_text.as_deref(), Some(link.as_str()));
|
||||
}
|
||||
|
||||
/// Return the characters of a text node in the test window.
|
||||
fn edit_text(w: &Win, id: NodeId) -> String {
|
||||
match &w.app.session.doc().unwrap().node(id).unwrap().kind {
|
||||
NodeKind::Text(t) => t.characters.clone(),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Asserts a synthetic Cut deletes the selected text, keeps the edit session active, writes
|
||||
/// no system clipboard, and ends its fence with the frame.
|
||||
#[test]
|
||||
fn a_synthetic_cut_deletes_the_selection_but_leaves_the_users_clipboard_alone() {
|
||||
let mut s = Session::new();
|
||||
let id = NodeId(s.execute("text.create", &json!({"text": "abc", "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap());
|
||||
let mut w = Win::with_session(s);
|
||||
figcraft_engine::textedit::begin(&mut w.app.session, id, Some(3), false).unwrap();
|
||||
figcraft_engine::textedit::set_caret(&mut w.app.session, 0, true).unwrap();
|
||||
|
||||
// A control-channel gesture is fenced from the OS clipboard, but its Cut still edits the
|
||||
// document: the selection is deleted exactly as a user's Cut would delete it.
|
||||
w.app.push_control_input(vec![Event::Cut]);
|
||||
let events = crate::windows::take_input(&mut w.app, egui::ViewportId::ROOT);
|
||||
assert_eq!(events, [Event::Cut], "the production input path owns the synthetic frame");
|
||||
w.frame(events);
|
||||
assert_eq!(w.writes(), (0, 0), "a synthetic cut never writes the user's clipboard");
|
||||
assert_eq!(edit_text(&w, id), "", "the selected text is deleted");
|
||||
assert!(w.app.session.ds().unwrap().text_edit.is_some(), "the edit session stays active");
|
||||
assert!(!w.app.control_synthetic, "the fence ends with the frame");
|
||||
}
|
||||
|
||||
/// Asserts a programmatic copy leaves the system clipboard alone and invalidates the stale
|
||||
/// mirror, so a later user paste uses the system text rather than the programmatic layers.
|
||||
#[test]
|
||||
fn a_programmatic_copy_does_not_let_a_stale_mirror_hide_the_users_paste() {
|
||||
let mut s = Session::new();
|
||||
let hello = figcraft_doc::NodeId(s.execute("text.create", &json!({"text": "hello", "x": 10, "y": 10})).unwrap()["id"].as_u64().unwrap());
|
||||
let other = figcraft_doc::NodeId(s.execute("text.create", &json!({"text": "other", "x": 10, "y": 80})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [hello]})).unwrap();
|
||||
let mut w = Win::with_session(s);
|
||||
|
||||
// The user copies the "hello" text layer: the OS clipboard and `last_copy` become "hello".
|
||||
crate::menus::invoke(&mut w.app, "edit.copy");
|
||||
assert_eq!(w.app.ui.last_copy.as_deref(), Some("hello"));
|
||||
assert_eq!(w.writes().0, 1, "the user's copy reaches the system clipboard");
|
||||
|
||||
// A control-channel copy changes the engine clipboard to "other" but never writes the system
|
||||
// clipboard. The stale "hello" mirror must not claim the programmatic selection is ours.
|
||||
let _ = w.app.run("select.set", json!({"ids": [other]}));
|
||||
w.app.control_synthetic = true;
|
||||
crate::menus::invoke(&mut w.app, "edit.copy");
|
||||
w.app.control_synthetic = false;
|
||||
assert_eq!(w.writes().0, 1, "the programmatic copy leaves the system clipboard alone");
|
||||
assert_eq!(w.app.ui.last_copy, None, "the old system-clipboard mirror is invalidated");
|
||||
|
||||
// The user pastes: the system clipboard's own text wins over the programmatic layers.
|
||||
crate::menus::paste(&mut w.app, Some("hello"), None);
|
||||
let doc = w.app.session.doc().unwrap();
|
||||
let texts: Vec<String> = doc
|
||||
.pages
|
||||
.iter()
|
||||
.filter_map(|p| doc.node(*p))
|
||||
.flat_map(|p| p.children.iter().copied())
|
||||
.filter_map(|id| doc.node(id).and_then(|n| n.kind.text().map(|t| t.characters.clone())))
|
||||
.collect();
|
||||
assert_eq!(texts.iter().filter(|t| t.as_str() == "hello").count(), 2, "the user's text was pasted");
|
||||
assert_eq!(texts.iter().filter(|t| t.as_str() == "other").count(), 1, "the programmatic selection was not pasted");
|
||||
}
|
||||
@@ -199,7 +199,7 @@ fn finish(app: &mut FigApp) {
|
||||
|
||||
/// The selected gradient stop in the picker (shared with the on-canvas stops).
|
||||
pub fn stop_id() -> Id {
|
||||
Id::new("cp_stop")
|
||||
Id::unique("cp_stop")
|
||||
}
|
||||
|
||||
/// Apply a swatch colour: a solid paint's colour, or the selected gradient stop's.
|
||||
@@ -224,7 +224,7 @@ fn set_color(app: &mut FigApp, target: &PickerTarget, paint: &mut Paint, c: Colo
|
||||
fn page_colors(app: &FigApp, ui: &Ui) -> Vec<Color> {
|
||||
let Ok(ds) = app.session.ds() else { return vec![] };
|
||||
let key = (std::sync::Arc::as_ptr(&ds.doc) as usize, ds.page);
|
||||
let cache_id = Id::new("cp_doc_colors");
|
||||
let cache_id = Id::unique("cp_doc_colors");
|
||||
if let Some((k, colors)) = ui.data(|d| d.get_temp::<((usize, figcraft_doc::NodeId), Vec<Color>)>(cache_id))
|
||||
&& k == key
|
||||
{
|
||||
@@ -241,7 +241,7 @@ const MODELS: [&str; 5] = ["Hex", "RGB", "HSL", "HSB", "CSS"];
|
||||
/// The model dropdown with its fields and the alpha field. Returns a new colour and whether the
|
||||
/// edit is done (false while a field is being scrubbed).
|
||||
fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> {
|
||||
let model_id = Id::new("cp_model");
|
||||
let model_id = Id::unique("cp_model");
|
||||
let model: usize = ui.data(|d| d.get_temp(model_id)).unwrap_or(0).min(MODELS.len() - 1);
|
||||
let mut out: Option<Color> = None;
|
||||
let mut done = true;
|
||||
@@ -259,7 +259,7 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> {
|
||||
});
|
||||
let num = |ui: &mut Ui, id: &str, v: f64, w: f32| match widgets::number_field(
|
||||
ui,
|
||||
Id::new(("cp_f", id)),
|
||||
Id::unique(("cp_f", id)),
|
||||
widgets::Lead::None,
|
||||
Some(v.round()),
|
||||
"",
|
||||
@@ -309,19 +309,19 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> {
|
||||
}
|
||||
}
|
||||
"CSS" => {
|
||||
if let Some(text) = widgets::text_field(ui, Id::new("cp_css"), &c.to_css(), 152.0, "") {
|
||||
if let Some(text) = widgets::text_field(ui, Id::unique("cp_css"), &c.to_css(), 152.0, "") {
|
||||
out = Color::parse_css(&text);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(h) = widgets::text_field(ui, Id::new("cp_hex"), &c.to_hex(), 101.0, "") {
|
||||
if let Some(h) = widgets::text_field(ui, Id::unique("cp_hex"), &c.to_hex(), 101.0, "") {
|
||||
out = Color::from_hex_or_name(&h).map(|h| h.with_alpha(if h.a < 1.0 { h.a } else { c.a }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if MODELS.get(model) != Some(&"CSS") {
|
||||
let alpha = Some((f64::from(out.unwrap_or(c).a) * 100.0).round());
|
||||
let edit = match widgets::number_field(ui, Id::new("cp_a"), widgets::Lead::None, alpha, "%", 44.0, 1.0) {
|
||||
let edit = match widgets::number_field(ui, Id::unique("cp_a"), widgets::Lead::None, alpha, "%", 44.0, 1.0) {
|
||||
Some(widgets::FieldEdit::Set(v)) => Some((v, true)),
|
||||
Some(widgets::FieldEdit::Scrub(v, end)) => Some((v, end)),
|
||||
None => None,
|
||||
@@ -339,6 +339,8 @@ fn hue_color(ctx: &egui::Context, h: f32) -> Color32 {
|
||||
doc_c32(ctx, Hsb { h, s: 1.0, b: 1.0 }.to_color())
|
||||
}
|
||||
|
||||
/// Draw the saturation/value square and its knob, updating `hsb.s` and `hsb.b` on click or
|
||||
/// drag; returns `(changed, done)`.
|
||||
fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) {
|
||||
// White → hue horizontally, then transparent → black vertically.
|
||||
let mut m = Mesh::default();
|
||||
@@ -362,7 +364,7 @@ fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) {
|
||||
m2.add_triangle(0, 1, 2);
|
||||
m2.add_triangle(0, 2, 3);
|
||||
ui.painter().add(m2);
|
||||
let resp = ui.interact(rect, Id::new("cp_sv"), Sense::click_and_drag());
|
||||
let resp = ui.interact(rect, Id::unique("cp_sv"), Sense::click_and_drag());
|
||||
let mut changed = false;
|
||||
if (resp.dragged() || resp.clicked() || resp.drag_started())
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
@@ -377,6 +379,8 @@ fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) {
|
||||
(changed, resp.drag_stopped() || resp.clicked())
|
||||
}
|
||||
|
||||
/// Draw the hue gradient and knob, updating `hsb.h` clamped below 360; returns
|
||||
/// `(changed, done)`.
|
||||
fn hue_slider(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) {
|
||||
let n = 12;
|
||||
let mut m = Mesh::default();
|
||||
@@ -393,7 +397,7 @@ fn hue_slider(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) {
|
||||
}
|
||||
}
|
||||
ui.painter().add(m);
|
||||
slider_knob(ui, rect, hsb.h / 360.0, Id::new("cp_hue"))
|
||||
slider_knob(ui, rect, hsb.h / 360.0, Id::unique("cp_hue"))
|
||||
.map(|(t, done)| {
|
||||
hsb.h = (t * 360.0).clamp(0.0, 359.9);
|
||||
(true, done)
|
||||
@@ -417,6 +421,8 @@ fn slider_knob(ui: &mut Ui, rect: Rect, t: f32, id: Id) -> Option<(f32, bool)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Draw the checkerboard and transparent-to-opaque alpha gradient with a knob; returns the
|
||||
/// new alpha and done state, or `None` when the slider is not interacting.
|
||||
fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> {
|
||||
widgets::checker(&ui.painter().with_clip_rect(rect), rect, rect.height() / 2.0);
|
||||
let mut m = Mesh::default();
|
||||
@@ -432,15 +438,18 @@ fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> {
|
||||
m.add_triangle(0, 1, 2);
|
||||
m.add_triangle(0, 2, 3);
|
||||
ui.painter().add(m);
|
||||
slider_knob(ui, rect, color.a, Id::new("cp_alpha"))
|
||||
slider_knob(ui, rect, color.a, Id::unique("cp_alpha"))
|
||||
}
|
||||
|
||||
/// The colour picker window's area id. A fixed id (not `Window::new`'s, which egui keys by
|
||||
/// `Id::new(Some(title))`) so the style picker can tell a click inside the picker from one outside.
|
||||
/// `Id::unique(Some(title))`) so the style picker can tell a click inside the picker from one outside.
|
||||
pub fn window_id() -> Id {
|
||||
Id::new("color_picker")
|
||||
Id::unique("color_picker")
|
||||
}
|
||||
|
||||
/// Draw the colour picker for the current target: close it when there is no target or the
|
||||
/// target's selection no longer matches, otherwise apply eyedropper, paint-kind, colour and
|
||||
/// gradient-stop edits live, positioned beside a plugin field when applicable.
|
||||
pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let Some(target) = app.ui.picker.clone() else {
|
||||
// A colour sampled for a picker that has closed has no paint to go to.
|
||||
@@ -477,7 +486,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let mut close_now = false;
|
||||
egui::Window::new("color_picker")
|
||||
// A fixed area id so the style picker can tell a click in here from a click outside
|
||||
// (`Window::new` alone would key the area by `Id::new(Some(title))`).
|
||||
// (`Window::new` alone would key the area by `Id::unique(Some(title))`).
|
||||
.id(window_id())
|
||||
.title_bar(false)
|
||||
.fixed_pos(pos)
|
||||
@@ -494,7 +503,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let (hr, _) = ui.allocate_exact_size(vec2(240.0, 40.0), Sense::hover());
|
||||
ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Custom"), semibold(11.0), t.text);
|
||||
let cr = Rect::from_center_size(pos2(hr.max.x - 20.0, hr.center().y), vec2(24.0, 24.0));
|
||||
let cresp = ui.interact(cr, Id::new("cp_close"), Sense::click());
|
||||
let cresp = ui.interact(cr, Id::unique("cp_close"), Sense::click());
|
||||
icons::paint(ui.painter(), cr.shrink(6.0), Icon::Close, t.icon);
|
||||
if cresp.clicked() {
|
||||
close_now = true;
|
||||
@@ -605,11 +614,11 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let mut changed = false;
|
||||
let mut done = false;
|
||||
// The bar first, so the stops on it take their own clicks.
|
||||
let bresp = ui.interact(bar, Id::new("cp_bar"), Sense::click());
|
||||
let bresp = ui.interact(bar, Id::unique("cp_bar"), Sense::click());
|
||||
for (i, s) in g.stops.clone().iter().enumerate() {
|
||||
let x = bar.min.x + s.position * bar.width();
|
||||
let r = Rect::from_center_size(pos2(x, bar.center().y), vec2(12.0, 18.0));
|
||||
let resp = ui.interact(r, Id::new(("cp_stopk", i)), Sense::click_and_drag());
|
||||
let resp = ui.interact(r, Id::unique(("cp_stopk", i)), Sense::click_and_drag());
|
||||
ui.painter().rect_filled(r, CornerRadius::same(3), doc_c32(ui.ctx(), s.color.with_alpha(1.0)));
|
||||
ui.painter().rect_stroke(
|
||||
r,
|
||||
@@ -773,7 +782,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
}
|
||||
if let Some(color) = edit_color {
|
||||
let mut hsb = ui
|
||||
.data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::new("cp_hsb")))
|
||||
.data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::unique("cp_hsb")))
|
||||
.filter(|(_, _, _, rgba)| *rgba == color.to_rgba8())
|
||||
.map(|(h, s, b, _)| Hsb { h, s, b })
|
||||
.unwrap_or_else(|| Hsb::from_color(color));
|
||||
@@ -817,7 +826,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
changed = true;
|
||||
done |= d;
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(Id::new("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8())));
|
||||
ui.data_mut(|d| d.insert_temp(Id::unique("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8())));
|
||||
// A Display P3 colour sRGB can't show: the canvas (sRGB) shows the nearest one.
|
||||
if app.session.doc().is_ok_and(|d| d.color_profile.conversion_to(figcraft_color::ColorProfile::Srgb).clips(nc)) {
|
||||
gamut_warning(ui, &t);
|
||||
@@ -983,7 +992,7 @@ fn pattern_controls(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint:
|
||||
ui.add_space(16.0);
|
||||
let field = |ui: &mut Ui, key: &str, lead: Lead, value: f64, suffix: &str| match widgets::number_field(
|
||||
ui,
|
||||
Id::new(key),
|
||||
Id::unique(key),
|
||||
lead,
|
||||
Some(value),
|
||||
suffix,
|
||||
@@ -1029,7 +1038,7 @@ fn saved_preset_row(ui: &mut Ui, name: &str) -> Option<PresetAction> {
|
||||
let w = ui.available_width().max(180.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click());
|
||||
let del = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(20.0, 20.0));
|
||||
let del_resp = ui.interact(del, ui.id().with(("cp_preset_del", name)), Sense::click()).tip(tr("Delete preset"));
|
||||
let del_resp = ui.interact(del, ui.scope_id().with(("cp_preset_del", name)), Sense::click()).tip(tr("Delete preset"));
|
||||
let hot = resp.hovered() || del_resp.hovered();
|
||||
if hot {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);
|
||||
@@ -1061,7 +1070,7 @@ fn image_presets(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: &m
|
||||
.find(|(_, f)| *f == filters)
|
||||
.map(|(name, _)| name)
|
||||
.unwrap_or_else(|| if filters.is_identity() { tr("No preset").into() } else { tr("Custom").into() });
|
||||
let name_id = Id::new("cp_preset_name");
|
||||
let name_id = Id::unique("cp_preset_name");
|
||||
let mut naming: Option<String> = ui.data(|d| d.get_temp(name_id));
|
||||
let mut action = None;
|
||||
ui.horizontal(|ui| {
|
||||
|
||||
@@ -476,7 +476,7 @@ pub fn start_draft(app: &mut FigApp, x: f64, y: f64) {
|
||||
|
||||
/// The reply field's id for a thread.
|
||||
fn reply_id(thread: u64) -> Id {
|
||||
Id::new(("comment_reply", thread))
|
||||
Id::unique(("comment_reply", thread))
|
||||
}
|
||||
|
||||
fn set_cursor(ctx: &egui::Context, id: Id, at: usize) {
|
||||
@@ -650,7 +650,7 @@ fn message(
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let ctx = ui.ctx().clone();
|
||||
let id = cx.c.id;
|
||||
let hover_key = Id::new(("comment_msg_hover", id, i));
|
||||
let hover_key = Id::unique(("comment_msg_hover", id, i));
|
||||
let hovered = ctx.data(|d| d.get_temp::<bool>(hover_key)).unwrap_or(false);
|
||||
let indent = 36.0;
|
||||
let block = ui.scope(|ui| {
|
||||
@@ -663,10 +663,10 @@ fn message(
|
||||
ui.label(egui::RichText::new(when).font(regular(11.0)).color(t.text_tertiary));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if m.author == cx.me {
|
||||
let menu = Id::new(("comment_msg_more_menu", id, i));
|
||||
let menu = Id::unique(("comment_msg_more_menu", id, i));
|
||||
let more = hover_button(
|
||||
ui,
|
||||
Id::new(("comment_msg_more", id, i)),
|
||||
Id::unique(("comment_msg_more", id, i)),
|
||||
Icon::Dots,
|
||||
hovered || egui::Popup::is_id_open(&ctx, menu),
|
||||
tr("More"),
|
||||
@@ -682,10 +682,10 @@ fn message(
|
||||
}
|
||||
});
|
||||
}
|
||||
let menu = Id::new(("comment_msg_react_menu", id, i));
|
||||
let menu = Id::unique(("comment_msg_react_menu", id, i));
|
||||
let react = hover_button(
|
||||
ui,
|
||||
Id::new(("comment_msg_react", id, i)),
|
||||
Id::unique(("comment_msg_react", id, i)),
|
||||
Icon::Reaction,
|
||||
hovered || egui::Popup::is_id_open(&ctx, menu),
|
||||
tr("Add reaction"),
|
||||
@@ -715,7 +715,7 @@ fn message(
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(indent);
|
||||
ui.vertical(|ui| {
|
||||
if composer(ui, Id::new(("comment_edit", id, i)), text, tr("Edit comment"), cx.people, cx.me, pick) {
|
||||
if composer(ui, Id::unique(("comment_edit", id, i)), text, tr("Edit comment"), cx.people, cx.me, pick) {
|
||||
let mut p = message_params(id, i);
|
||||
p["text"] = json!(text.trim());
|
||||
*act = Some(Act::Run("comment.edit", p));
|
||||
@@ -828,12 +828,12 @@ pub fn popovers(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let me = me(app);
|
||||
let mut pick = std::mem::take(&mut app.ui.comments.mention);
|
||||
let mut post = false;
|
||||
egui::Area::new(Id::new("comment_draft")).order(egui::Order::Foreground).fixed_pos(popover_pos(app.to_screen(x, y), canvas)).show(
|
||||
egui::Area::new(Id::unique("comment_draft")).order(egui::Order::Foreground).fixed_pos(popover_pos(app.to_screen(x, y), canvas)).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
popover_frame(&t).show(ui, |ui| {
|
||||
ui.set_width(POPOVER_W - 24.0);
|
||||
post = composer(ui, Id::new("comment_draft_text"), &mut text, tr("Add a comment"), &people, &me, &mut pick);
|
||||
post = composer(ui, Id::unique("comment_draft_text"), &mut text, tr("Add a comment"), &people, &me, &mut pick);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -877,7 +877,7 @@ pub fn thread(app: &mut FigApp, ctx: &egui::Context, id: u64, anchor: Pos2, boun
|
||||
let mut pick = std::mem::take(&mut app.ui.comments.mention);
|
||||
let mut act: Option<Act> = None;
|
||||
let cx = ThreadCtx { c: &c, me: &me, people: &people };
|
||||
egui::Area::new(Id::new("comment_thread")).order(egui::Order::Foreground).fixed_pos(popover_pos(anchor, bounds)).show(ctx, |ui| {
|
||||
egui::Area::new(Id::unique("comment_thread")).order(egui::Order::Foreground).fixed_pos(popover_pos(anchor, bounds)).show(ctx, |ui| {
|
||||
popover_frame(&t).show(ui, |ui| {
|
||||
ui.set_width(POPOVER_W - 24.0);
|
||||
// Header: resolve, more, close.
|
||||
@@ -934,7 +934,7 @@ pub fn thread(app: &mut FigApp, ctx: &egui::Context, id: u64, anchor: Pos2, boun
|
||||
}
|
||||
Some(Act::CopyLink) => {
|
||||
if let Some(link) = app.run("comment.link", json!({"id": id})).and_then(|v| v.get("link").and_then(Value::as_str).map(str::to_string)) {
|
||||
app.services.clipboard_set_text(&link);
|
||||
app.clipboard_set_text(&link);
|
||||
app.toast(tr("Link copied"));
|
||||
}
|
||||
}
|
||||
@@ -1051,7 +1051,7 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(16.0);
|
||||
let w = ui.available_width() - 16.0;
|
||||
if let Some(s) = crate::widgets::text_field(ui, Id::new("comment_search"), &app.ui.comments.search, w, tr("Search comments")) {
|
||||
if let Some(s) = crate::widgets::text_field(ui, Id::unique("comment_search"), &app.ui.comments.search, w, tr("Search comments")) {
|
||||
app.ui.comments.search = s;
|
||||
}
|
||||
});
|
||||
@@ -1178,7 +1178,7 @@ pub fn presenter(app: &mut FigApp, ctx: &egui::Context, frame: NodeId, place: cr
|
||||
let mut clicked = None;
|
||||
for p in &pins {
|
||||
let r = pin_rect(p.at);
|
||||
egui::Area::new(Id::new(("present_comment_pin", p.id))).order(egui::Order::Foreground).fixed_pos(r.min).show(ctx, |ui| {
|
||||
egui::Area::new(Id::unique(("present_comment_pin", p.id))).order(egui::Order::Foreground).fixed_pos(r.min).show(ctx, |ui| {
|
||||
let (rr, resp) = ui.allocate_exact_size(r.size(), Sense::click());
|
||||
paint_pin(ui.painter(), rr, p, open == Some(p.id) || resp.hovered());
|
||||
if resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
|
||||
|
||||
@@ -79,7 +79,8 @@ fn combine_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str) {
|
||||
widgets::separator(ui);
|
||||
widgets::sublabels(ui, tr("Description"), None);
|
||||
if let Some(new) = widgets::multiline_field(ui, Id::new(("comp_desc", id)), description, tr("Describe how and when to use this component"), 3) {
|
||||
if let Some(new) = widgets::multiline_field(ui, Id::unique(("comp_desc", id)), description, tr("Describe how and when to use this component"), 3)
|
||||
{
|
||||
app.run("node.set", json!({"id": id, "props": {"description": new.chars().take(10_000).collect::<String>()}}));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
@@ -91,7 +92,7 @@ fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str)
|
||||
pub(crate) fn local_components(app: &FigApp, ui: &Ui, doc: &Document) -> std::sync::Arc<Vec<(NodeId, String)>> {
|
||||
type Cached = ((u64, u64), std::sync::Arc<Vec<(NodeId, String)>>);
|
||||
let key = app.session.ds().map(|d| (d.uid, d.revision)).unwrap_or_default();
|
||||
let id = Id::new("component_panel_local_components");
|
||||
let id = Id::unique("component_panel_local_components");
|
||||
if let Some((k, list)) = ui.data(|d| d.get_temp::<Cached>(id))
|
||||
&& k == key
|
||||
{
|
||||
@@ -267,7 +268,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
|
||||
let header = row_rect(ui, 32.0, |ui| {
|
||||
let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
|
||||
icons::paint(ui.painter(), ir, Icon::ComponentSet, COMPONENT);
|
||||
if let Some(nn) = widgets::text_field(ui, Id::new(("vprop_name", owner, k.as_str())), k, 110.0, tr("Name")) {
|
||||
if let Some(nn) = widgets::text_field(ui, Id::unique(("vprop_name", owner, k.as_str())), k, 110.0, tr("Name")) {
|
||||
app.run("component.editProperty", json!({"id": owner, "name": k, "newName": nn}));
|
||||
}
|
||||
let def = defaults.get(k).or(vals.first()).cloned().unwrap_or_default();
|
||||
@@ -292,7 +293,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
|
||||
row(ui, 30.0, |ui| {
|
||||
ui.add_space(22.0);
|
||||
for v in &chunk {
|
||||
if let Some(nv) = widgets::text_field(ui, Id::new(("vprop_val", owner, k.as_str(), v.as_str())), v, 62.0, "")
|
||||
if let Some(nv) = widgets::text_field(ui, Id::unique(("vprop_val", owner, k.as_str(), v.as_str())), v, 62.0, "")
|
||||
&& nv != *v
|
||||
{
|
||||
app.run("component.editProperty", json!({"id": owner, "name": k, "value": v, "newValue": nv}));
|
||||
@@ -320,7 +321,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
|
||||
row(ui, 32.0, |ui| {
|
||||
let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
|
||||
icons::paint(ui.painter(), ir, type_icon(d), COMPONENT);
|
||||
if let Some(nn) = widgets::text_field(ui, Id::new(("prop_name", owner, k.as_str())), k, 96.0, tr("Name")) {
|
||||
if let Some(nn) = widgets::text_field(ui, Id::unique(("prop_name", owner, k.as_str())), k, 96.0, tr("Name")) {
|
||||
app.run("component.editProperty", json!({"id": owner, "name": k, "newName": nn}));
|
||||
}
|
||||
match d {
|
||||
@@ -330,7 +331,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
|
||||
}
|
||||
}
|
||||
PropDef::Text { default } => {
|
||||
if let Some(v) = widgets::text_field(ui, Id::new(("prop_def", owner, k.as_str())), default, 84.0, tr("Default")) {
|
||||
if let Some(v) = widgets::text_field(ui, Id::unique(("prop_def", owner, k.as_str())), default, 84.0, tr("Default")) {
|
||||
app.run("component.editProperty", json!({"id": owner, "name": k, "default": v}));
|
||||
}
|
||||
}
|
||||
@@ -701,7 +702,7 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps
|
||||
PropDef::Text { .. } => {
|
||||
crate::variable_ui::bindable(app, ui, &[id], &bind_field, VariableType::String, w, |app, ui| {
|
||||
let cur = v.as_str().unwrap_or_default().to_string();
|
||||
if let Some(ns) = widgets::text_field(ui, Id::new(("iprop", id, k.as_str())), &cur, w, "")
|
||||
if let Some(ns) = widgets::text_field(ui, Id::unique(("iprop", id, k.as_str())), &cur, w, "")
|
||||
&& ns != cur
|
||||
{
|
||||
app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): ns}}));
|
||||
|
||||
+811
-108
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,657 @@
|
||||
//! Screenshot capture (the local completion-slot adapter): per-request identity and reverse
|
||||
//! completion, cancellation/late delivery, budget admission, straight-alpha round-trips and zero
|
||||
//! retained bytes after draining. Driven headless through the real UI pass, so geometry comes from
|
||||
//! the frame that issues the capture, not from a stale logic tick.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::control::{CaptureSpec, ControlReply, ControlRequest};
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
/// Queue a `ui.screenshot` request in the test app and return its reply receiver.
|
||||
fn push(u: &mut Ui, id: u64, params: serde_json::Value) -> std::sync::mpsc::Receiver<ControlReply> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
u.app.control_queue.push(ControlRequest { id: json!(id), method: "ui.screenshot".into(), params, reply: tx, state: Default::default() });
|
||||
rx
|
||||
}
|
||||
|
||||
/// Run UI passes until every admitted capture has been issued (settled on real UI passes).
|
||||
fn settle_to_issue(u: &mut Ui, n: usize) {
|
||||
for _ in 0..12 {
|
||||
u.frame(vec![]);
|
||||
if u.app.capture.shots.len() == n && u.app.capture.shots.iter().all(|r| r.issued) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("{n} captures were not issued: {} admitted", u.app.capture.shots.len());
|
||||
}
|
||||
|
||||
/// A real changed viewport size, without allocating a readback image of that size.
|
||||
fn resized_frame(u: &mut Ui, size: egui::Vec2) -> egui::FullOutput {
|
||||
let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() };
|
||||
let app = &mut u.app;
|
||||
let mut out = u.ctx.run_ui(input, |ui| app.ui(ui));
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether any viewport in the frame output issues a `Screenshot` command.
|
||||
fn has_screenshot(out: &egui::FullOutput) -> bool {
|
||||
out.viewport_output.values().any(|v| v.commands.iter().any(|c| matches!(c, egui::ViewportCommand::Screenshot(_))))
|
||||
}
|
||||
|
||||
/// Asserts reverse capture completion replies only to the matching request, then drains both
|
||||
/// requests.
|
||||
#[test]
|
||||
fn two_captures_correlate_by_request_across_reverse_completion() {
|
||||
let mut u = Ui::new(Session::empty());
|
||||
let first = push(&mut u, 1, json!({}));
|
||||
let second = push(&mut u, 2, json!({}));
|
||||
settle_to_issue(&mut u, 2);
|
||||
let [a, b] = [0, 1].map(|i| u.app.capture.shots[i].spec.expect("geometry"));
|
||||
assert_eq!(a.viewport, b.viewport);
|
||||
assert!(a.size_px[0] > 0 && a.size_px[1] > 0);
|
||||
// The second capture arrives first; only its own request is completed.
|
||||
let slot_b = u.app.capture.shots[1].slot.clone().expect("issued");
|
||||
slot_b.complete(Arc::new(egui::ColorImage::filled(b.size_px, egui::Color32::GREEN)));
|
||||
u.frame(vec![]);
|
||||
let got = second.try_recv().expect("the second capture's reply");
|
||||
assert_eq!(got.id, json!(2));
|
||||
assert!(first.try_recv().is_err(), "the first is still in flight");
|
||||
assert_eq!(u.app.capture.shots.len(), 1);
|
||||
let slot_a = u.app.capture.shots[0].slot.clone().expect("issued");
|
||||
slot_a.complete(Arc::new(egui::ColorImage::filled(a.size_px, egui::Color32::RED)));
|
||||
u.frame(vec![]);
|
||||
let got = first.try_recv().expect("the first capture's reply");
|
||||
assert_eq!(got.id, json!(1));
|
||||
assert!(u.app.capture.shots.is_empty(), "no retained requests");
|
||||
}
|
||||
|
||||
/// Asserts a cancelled capture gets no reply and its slot is released.
|
||||
#[test]
|
||||
fn a_cancelled_capture_never_replies_or_writes() {
|
||||
let mut u = Ui::new(Session::empty());
|
||||
let rx = push(&mut u, 3, json!({}));
|
||||
settle_to_issue(&mut u, 1);
|
||||
let spec = u.app.capture.shots[0].spec.expect("geometry");
|
||||
let slot = u.app.capture.shots[0].slot.clone().expect("issued");
|
||||
u.app.capture.shots[0].state.cancel_if_open();
|
||||
slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::BLUE)));
|
||||
u.frame(vec![]);
|
||||
assert!(rx.try_recv().is_err(), "a cancelled transport gets no reply");
|
||||
assert!(u.app.capture.shots.is_empty(), "the slot is released");
|
||||
}
|
||||
|
||||
/// A unique directory for a commit test; the caller removes it.
|
||||
fn commit_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-capture-commit-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
dir
|
||||
}
|
||||
|
||||
/// Asserts cancellation after encoding still prevents the final file write and reply.
|
||||
#[test]
|
||||
fn a_cancellation_after_encoding_prevents_the_final_write() {
|
||||
let dir = commit_dir("cancelled");
|
||||
let path = dir.join("shot.png");
|
||||
let gate = crate::control::RequestGate::new();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let clock = crate::FrameClock::default();
|
||||
let img = egui::ColorImage::filled([2, 2], egui::Color32::RED);
|
||||
// Encoding is complete before the transport cancels: the final gate must still refuse the file.
|
||||
let encoded = super::encode_shot(&img, Some(path.to_str().expect("utf-8 path"))).expect("encoded");
|
||||
assert!(!path.exists(), "encoding alone does not write");
|
||||
assert_eq!(gate.cancel_if_open(), crate::control::RequestState::Cancelled);
|
||||
assert!(!super::commit_shot(&gate, &json!(40), &tx, 100.0, &clock, Ok(encoded)), "a cancelled request does not commit");
|
||||
assert!(!path.exists(), "the cancellation landed before the final write");
|
||||
assert!(rx.try_recv().is_err(), "a cancelled transport gets no reply");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Asserts a capture that arrives past its deadline reports a timeout and writes no PNG.
|
||||
#[test]
|
||||
fn a_real_arrival_that_crosses_the_deadline_does_not_write_its_png() {
|
||||
let mut u = Ui::new(Session::empty());
|
||||
let dir = commit_dir("settle-expired");
|
||||
let path = dir.join("shot.png");
|
||||
let rx = push(&mut u, 60, json!({"path": path.to_string_lossy()}));
|
||||
settle_to_issue(&mut u, 1);
|
||||
let spec = u.app.capture.shots[0].spec.expect("geometry");
|
||||
let slot = u.app.capture.shots[0].slot.clone().expect("issued");
|
||||
let deadline = u.app.capture.shots[0].deadline;
|
||||
let epoch = u.app.capture.shots[0].epoch;
|
||||
slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED)));
|
||||
// A frame's saved time may precede encoding; the final write must use a fresh clock.
|
||||
let stale_now = u.app.clock.now();
|
||||
u.app.clock.set_test_now(Some(deadline + 1.0));
|
||||
super::drain_completed(&mut u.app, &u.ctx, egui::ViewportId::ROOT, stale_now, epoch);
|
||||
let reply = rx.try_recv().expect("the late commit reports a timeout");
|
||||
assert!(reply.result.unwrap_err().contains("didn't deliver"));
|
||||
assert!(!path.exists(), "the final deadline check must precede the PNG write");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Asserts an expiry during encoding reports the timeout under the gate, marks the request
|
||||
/// replied and writes no file.
|
||||
#[test]
|
||||
fn an_expiry_during_encoding_prevents_the_final_write_and_reports_timeout() {
|
||||
let dir = commit_dir("expired");
|
||||
let path = dir.join("shot.png");
|
||||
let gate = crate::control::RequestGate::new();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut clock = crate::FrameClock::default();
|
||||
clock.set_test_now(Some(5.0));
|
||||
let img = egui::ColorImage::filled([2, 2], egui::Color32::RED);
|
||||
let encoded = super::encode_shot(&img, Some(path.to_str().expect("utf-8 path"))).expect("encoded");
|
||||
// The gate is open, but the fresh clock read under it is already at the deadline.
|
||||
assert!(super::commit_shot(&gate, &json!(41), &tx, 5.0, &clock, Ok(encoded)), "the open request is settled once");
|
||||
let reply = rx.try_recv().expect("a timeout reply");
|
||||
assert_eq!(reply.id, json!(41));
|
||||
assert!(reply.result.unwrap_err().contains("didn't deliver"), "the late commit reports the same timeout");
|
||||
assert!(!path.exists(), "an expired request never writes its file");
|
||||
assert!(gate.is_replied(), "the timeout reply was committed under the gate");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Asserts a commit before the deadline writes the file and queues one reply, and a second
|
||||
/// commit is refused.
|
||||
#[test]
|
||||
fn a_commit_before_the_deadline_writes_and_queues_its_reply_once() {
|
||||
let dir = commit_dir("committed");
|
||||
let path = dir.join("shot.png");
|
||||
let gate = crate::control::RequestGate::new();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut clock = crate::FrameClock::default();
|
||||
clock.set_test_now(Some(1.0));
|
||||
let img = egui::ColorImage::filled([2, 2], egui::Color32::GREEN);
|
||||
let encoded = super::encode_shot(&img, Some(path.to_str().expect("utf-8 path"))).expect("encoded");
|
||||
assert!(super::commit_shot(&gate, &json!(42), &tx, 10.0, &clock, Ok(encoded)), "an open request commits");
|
||||
let reply = rx.try_recv().expect("the committed reply");
|
||||
assert_eq!(reply.id, json!(42));
|
||||
assert_eq!(reply.result.expect("written")["path"], json!(path.to_string_lossy()));
|
||||
assert!(path.is_file(), "the file is written under the gate");
|
||||
assert!(gate.is_replied(), "a timeout now consumes the committed reply");
|
||||
// A second commit is refused: the gate is no longer open.
|
||||
let again = super::encode_shot(&img, Some(path.to_str().expect("utf-8 path"))).expect("encoded");
|
||||
assert!(!super::commit_shot(&gate, &json!(43), &tx, 10.0, &clock, Ok(again)));
|
||||
assert!(rx.try_recv().is_err(), "no duplicate reply");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Asserts a capture past its deadline sends one timeout reply, releases its slot, and late
|
||||
/// pixels are ignored.
|
||||
#[test]
|
||||
fn a_capture_after_its_deadline_replies_once_and_late_pixels_are_inert() {
|
||||
let mut u = Ui::new(Session::empty());
|
||||
let rx = push(&mut u, 4, json!({}));
|
||||
settle_to_issue(&mut u, 1);
|
||||
let spec = u.app.capture.shots[0].spec.expect("geometry");
|
||||
let slot = u.app.capture.shots[0].slot.clone().expect("issued");
|
||||
u.advance_clock(crate::control::SHOT_TIMEOUT + 1.0);
|
||||
u.frame(vec![]);
|
||||
let reply = rx.try_recv().expect("timed out");
|
||||
assert_eq!(reply.id, json!(4));
|
||||
assert!(reply.result.unwrap_err().contains("didn't deliver"), "one timeout reply");
|
||||
assert!(u.app.capture.shots.is_empty(), "released");
|
||||
// The renderer finishing afterwards finds a closed slot: no reply, no write, no panic.
|
||||
slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED)));
|
||||
u.frame(vec![]);
|
||||
assert!(rx.try_recv().is_err(), "a late capture is inert");
|
||||
}
|
||||
|
||||
/// Asserts the snapshot count budget rejects an over-budget request before issuing, and every
|
||||
/// admitted capture still completes under its own id.
|
||||
#[test]
|
||||
fn a_snapshot_batch_is_capped_before_issuing_over_the_count_budget() {
|
||||
let mut u = Ui::new(Session::empty());
|
||||
let mut rxs: Vec<_> = (0..crate::control::MAX_SHOTS as u64).map(|i| push(&mut u, 10 + i, json!({}))).collect();
|
||||
settle_to_issue(&mut u, crate::control::MAX_SHOTS);
|
||||
let over = push(&mut u, 99, json!({}));
|
||||
u.frame(vec![]);
|
||||
let reply = over.try_recv().expect("rejected at admission");
|
||||
assert!(reply.result.unwrap_err().contains("too many screenshots"), "budget error");
|
||||
assert_eq!(u.app.capture.shots.len(), crate::control::MAX_SHOTS);
|
||||
// Every admitted capture still completes with its own identity. Each drain removes its
|
||||
// request, so always complete the first remaining one: the vector shifts under the loop.
|
||||
for (i, rx) in rxs.iter_mut().enumerate() {
|
||||
let spec = u.app.capture.shots.first().expect("one left").spec.expect("geometry");
|
||||
let slot = u.app.capture.shots.first().and_then(|r| r.slot.clone()).expect("issued");
|
||||
slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED)));
|
||||
u.frame(vec![]);
|
||||
assert_eq!(rx.try_recv().expect("answered").id, json!(10 + i as u64), "request {i} keeps its identity");
|
||||
}
|
||||
rxs.clear();
|
||||
}
|
||||
|
||||
/// Asserts an undersized readback is not accepted but reissued with one retry and no reply.
|
||||
#[test]
|
||||
fn an_undersized_readback_is_reissued_instead_of_accepted() {
|
||||
let mut u = Ui::new(Session::empty());
|
||||
let rx = push(&mut u, 30, json!({}));
|
||||
settle_to_issue(&mut u, 1);
|
||||
let spec = u.app.capture.shots[0].spec.expect("geometry");
|
||||
let slot = u.app.capture.shots[0].slot.clone().expect("issued");
|
||||
assert!(spec.size_px[0] > 2, "room for a two-pixel undersized readback");
|
||||
// Smaller by two pixels on one axis: a scale mismatch, not the admitted surface.
|
||||
slot.complete(Arc::new(egui::ColorImage::filled([spec.size_px[0] - 2, spec.size_px[1]], egui::Color32::RED)));
|
||||
u.frame(vec![]);
|
||||
assert!(rx.try_recv().is_err(), "the undersized pixels are not accepted");
|
||||
let r = u.app.capture.shots.first().expect("reissued");
|
||||
assert_eq!(r.retries, 1, "one retry, not an error");
|
||||
assert!(!r.issued, "waits for the settled geometry again");
|
||||
}
|
||||
|
||||
/// Asserts a one-pixel rounding difference is accepted as the same surface.
|
||||
#[test]
|
||||
fn a_one_pixel_rounding_difference_is_still_the_same_surface() {
|
||||
let mut u = Ui::new(Session::empty());
|
||||
let rx = push(&mut u, 31, json!({}));
|
||||
settle_to_issue(&mut u, 1);
|
||||
let spec = u.app.capture.shots[0].spec.expect("geometry");
|
||||
let slot = u.app.capture.shots[0].slot.clone().expect("issued");
|
||||
slot.complete(Arc::new(egui::ColorImage::filled([spec.size_px[0] - 1, spec.size_px[1] + 1], egui::Color32::RED)));
|
||||
u.frame(vec![]);
|
||||
let reply = rx.try_recv().expect("a pixel of rounding is accepted");
|
||||
assert_eq!(reply.id, json!(31));
|
||||
assert!(reply.result.is_ok());
|
||||
assert!(u.app.capture.shots.is_empty(), "released");
|
||||
}
|
||||
|
||||
/// Asserts an A→B→A tab switch invalidates the admitted capture with "document changed".
|
||||
#[test]
|
||||
fn a_document_a_to_b_to_a_switch_is_not_the_admitted_capture() {
|
||||
let mut s = Session::empty();
|
||||
s.execute("file.new", &json!({"name": "A"})).unwrap();
|
||||
s.execute("file.new", &json!({"name": "B"})).unwrap();
|
||||
let (a, b) = (s.docs.iter().position(|d| d.doc.name == "A").expect("file A"), s.docs.iter().position(|d| d.doc.name == "B").expect("file B"));
|
||||
let mut u = Ui::new(s);
|
||||
u.app.session.active = a;
|
||||
let rx = push(&mut u, 32, json!({}));
|
||||
settle_to_issue(&mut u, 1);
|
||||
let spec = u.app.capture.shots[0].spec.expect("geometry");
|
||||
let slot = u.app.capture.shots[0].slot.clone().expect("issued");
|
||||
// A→B→A before the callback is drained: the final UID is A again, but its pixels were never
|
||||
// what this capture asked for.
|
||||
assert!(u.app.run("doc.switch", json!({"index": b})).is_some());
|
||||
assert!(u.app.run("doc.switch", json!({"index": a})).is_some());
|
||||
slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED)));
|
||||
u.frame(vec![]);
|
||||
let reply = rx.try_recv().expect("answered");
|
||||
assert!(reply.result.unwrap_err().contains("document changed"), "the A→B→A switch invalidates the capture");
|
||||
assert!(u.app.capture.shots.is_empty(), "released");
|
||||
}
|
||||
|
||||
/// Asserts a geometry change bumps the capture epoch and reissues instead of accepting old
|
||||
/// pixels.
|
||||
#[test]
|
||||
fn a_geometry_change_reissues_instead_of_accepting_old_pixels() {
|
||||
let mut u = Ui::new(Session::empty());
|
||||
let rx = push(&mut u, 5, json!({}));
|
||||
settle_to_issue(&mut u, 1);
|
||||
let old = u.app.capture.shots[0].spec.expect("geometry");
|
||||
let slot = u.app.capture.shots[0].slot.clone().expect("issued");
|
||||
// A resize between issuance and arrival bumps the epoch.
|
||||
u.app
|
||||
.capture
|
||||
.geometry
|
||||
.insert(egui::ViewportId::ROOT, (CaptureSpec { size_px: [old.size_px[0] + 1, old.size_px[1]], ..old }, u.app.capture.epoch.wrapping_add(1)));
|
||||
slot.complete(Arc::new(egui::ColorImage::filled(old.size_px, egui::Color32::RED)));
|
||||
u.frame(vec![]);
|
||||
assert!(rx.try_recv().is_err(), "the old pixels are not accepted");
|
||||
let r = u.app.capture.shots.first().expect("reissued");
|
||||
assert_eq!(r.retries, 1, "one retry, not an error");
|
||||
assert!(!r.issued, "waits for the settled geometry again");
|
||||
}
|
||||
|
||||
/// Asserts a translucent pixel round-trips through the PNG as straight RGBA without
|
||||
/// darkening.
|
||||
#[test]
|
||||
fn a_translucent_pixel_round_trips_straight_rgba() {
|
||||
let img = egui::ColorImage::from_rgba_unmultiplied([1, 1], &[128, 64, 32, 128]);
|
||||
let reply = super::shot_reply(&img, None).expect("encoded");
|
||||
use base64::Engine as _;
|
||||
let png = base64::engine::general_purpose::STANDARD.decode(reply["data"].as_str().expect("data")).expect("base64");
|
||||
let decoded = image::load_from_memory(&png).expect("png").to_rgba8();
|
||||
assert_eq!(decoded.get_pixel(0, 0).0, [128, 64, 32, 128], "straight alpha is not darkened");
|
||||
}
|
||||
|
||||
/// Asserts a capture is not issued before three completed real UI frames; the following
|
||||
/// completed frame issues it.
|
||||
#[test]
|
||||
fn a_capture_is_not_issued_before_three_completed_real_ui_frames() {
|
||||
let mut u = Ui::new(Session::empty());
|
||||
let _rx = push(&mut u, 6, json!({}));
|
||||
u.frame(vec![]);
|
||||
assert_eq!(u.app.capture.shots.len(), 1, "admitted");
|
||||
assert!(!u.app.capture.shots[0].issued, "not on the frame that anchors the count");
|
||||
u.frame(vec![]);
|
||||
assert!(!u.app.capture.shots[0].issued, "not on the second frame");
|
||||
u.frame(vec![]);
|
||||
assert!(!u.app.capture.shots[0].issued, "the third frame has not completed when its pass 0 runs");
|
||||
u.frame(vec![]);
|
||||
assert!(u.app.capture.shots[0].issued, "issued once three frames have presented their layout");
|
||||
}
|
||||
|
||||
/// Asserts a discarded sizing pass does not count toward capture settling, and the next
|
||||
/// completed frame issues it.
|
||||
#[test]
|
||||
fn a_discarded_sizing_pass_does_not_count_as_a_settled_capture_frame() {
|
||||
let mut u = Ui::new(Session::empty());
|
||||
let _rx = push(&mut u, 33, json!({}));
|
||||
// Each `frame_raw_with_discard` presents a layout after rerunning a discarded first pass.
|
||||
// Only that completed frame counts; the discarded pass must not make the capture look settled.
|
||||
u.frame_raw_with_discard(egui::RawInput::default());
|
||||
u.frame_raw_with_discard(egui::RawInput::default());
|
||||
u.frame_raw_with_discard(egui::RawInput::default());
|
||||
assert!(!u.app.capture.shots[0].issued, "three multipass frames present only two prior layouts here; the third pass 0 is not settled");
|
||||
u.frame(vec![]);
|
||||
assert!(u.app.capture.shots[0].issued, "the next completed frame issues the capture");
|
||||
}
|
||||
|
||||
/// Asserts pixels delivered while hidden force a draining paint and are answered rather than
|
||||
/// timed out.
|
||||
#[test]
|
||||
fn a_hidden_window_that_delivers_pixels_is_asked_to_paint_the_draining_pass() {
|
||||
let mut u = Ui::new(Session::empty());
|
||||
let rx = push(&mut u, 7, json!({}));
|
||||
settle_to_issue(&mut u, 1);
|
||||
let spec = u.app.capture.shots[0].spec.expect("geometry");
|
||||
let slot = u.app.capture.shots[0].slot.clone().expect("issued");
|
||||
// The renderer completes the readback while the window is hidden; the screenshot command that
|
||||
// produced it is drained, so without a new forced paint the request would only time out.
|
||||
slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED)));
|
||||
let mut input = egui::RawInput::default();
|
||||
input.viewports.insert(egui::ViewportId::ROOT, egui::ViewportInfo { minimized: Some(true), ..Default::default() });
|
||||
let ctx = u.ctx.clone();
|
||||
let out = ctx.run_logic(&input, |ctx| crate::control::background(&mut u.app, ctx));
|
||||
let asked = out
|
||||
.viewport_commands
|
||||
.get(&egui::ViewportId::ROOT)
|
||||
.is_some_and(|cmds| cmds.iter().any(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden)));
|
||||
assert!(asked, "the completed pixels keep the hidden window painting");
|
||||
// The forced paint drains the slot; the deadline is untouched.
|
||||
u.frame(vec![]);
|
||||
let reply = rx.try_recv().expect("the completed capture is answered, not timed out");
|
||||
assert_eq!(reply.id, json!(7));
|
||||
assert!(reply.result.unwrap()["width"].as_u64().is_some(), "written or returned");
|
||||
assert!(u.app.capture.shots.is_empty(), "released");
|
||||
}
|
||||
|
||||
/// Asserts a capture for a window with unknown visibility is parked with its deadline and a
|
||||
/// paint request, then times out once.
|
||||
#[test]
|
||||
fn a_capture_for_a_window_with_unknown_visibility_is_admitted_with_a_deadline() {
|
||||
let mut u = Ui::new(Session::empty());
|
||||
let rx = push(&mut u, 40, json!({}));
|
||||
// A minimised window may report no `visible` flag at all. The request must still be parked
|
||||
// with its deadline rather than left in `control_queue` waiting for a frame that never draws.
|
||||
let mut input = egui::RawInput::default();
|
||||
// Neither `visible` nor `minimized` is reported: the platform has not said whether the
|
||||
// window draws, so waiting for its frame would leave the request queued indefinitely.
|
||||
input.viewports.insert(egui::ViewportId::ROOT, egui::ViewportInfo::default());
|
||||
let ctx = u.ctx.clone();
|
||||
let out = ctx.run_logic(&input, |ctx| crate::control::background(&mut u.app, ctx));
|
||||
assert_eq!(u.app.capture.shots.len(), 1, "admitted, not left queued");
|
||||
assert!(u.app.control_queue.is_empty(), "the parked request no longer waits in the input queue");
|
||||
let r = &u.app.capture.shots[0];
|
||||
assert!(!r.issued);
|
||||
let now = u.app.clock.now();
|
||||
assert!((r.deadline - (now + crate::control::SHOT_TIMEOUT)).abs() < 1e-6, "the hidden request has its deadline");
|
||||
let asked = out
|
||||
.viewport_commands
|
||||
.get(&egui::ViewportId::ROOT)
|
||||
.is_some_and(|cmds| cmds.iter().any(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden)));
|
||||
assert!(asked, "the parked capture asks the window to paint");
|
||||
// The deadline still answers the transport when no frame draws.
|
||||
u.advance_clock(crate::control::SHOT_TIMEOUT + 2.0);
|
||||
u.frame(vec![]);
|
||||
let reply = rx.try_recv().expect("the parked capture times out");
|
||||
assert_eq!(reply.id, json!(40));
|
||||
assert!(reply.result.unwrap_err().contains("didn't deliver"));
|
||||
assert!(u.app.capture.shots.is_empty(), "released");
|
||||
}
|
||||
|
||||
/// Asserts a completed capture is rejected when current geometry is unavailable, failing
|
||||
/// closed.
|
||||
#[test]
|
||||
fn a_completed_capture_is_rejected_when_current_geometry_is_unavailable() {
|
||||
let mut u = Ui::new(Session::empty());
|
||||
let rx = push(&mut u, 41, json!({}));
|
||||
settle_to_issue(&mut u, 1);
|
||||
let spec = u.app.capture.shots[0].spec.expect("geometry");
|
||||
let slot = u.app.capture.shots[0].slot.clone().expect("issued");
|
||||
slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED)));
|
||||
// A surface that cannot represent geometry at all (smaller than one point here). The pixels
|
||||
// may still match the old admitted spec, but they cannot be proven current: fail closed.
|
||||
u.advance_clock(1.0);
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(0.0, 0.0))),
|
||||
time: Some(1.0),
|
||||
..Default::default()
|
||||
};
|
||||
let app = &mut u.app;
|
||||
let ctx = u.ctx.clone();
|
||||
let mut out = ctx.run_ui(input, |ui| crate::control::drain(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
let reply = rx.try_recv().expect("answered instead of accepted");
|
||||
assert_eq!(reply.id, json!(41));
|
||||
assert!(reply.result.unwrap_err().contains("geometry is unavailable"), "fail closed");
|
||||
assert!(u.app.capture.shots.is_empty(), "released");
|
||||
}
|
||||
|
||||
/// Asserts a tab switch while a capture settles invalidates it with "document changed".
|
||||
#[test]
|
||||
fn a_capture_belongs_to_the_document_admitted_not_the_one_showing_when_it_arrives() {
|
||||
let mut s = Session::empty();
|
||||
s.execute("file.new", &json!({"name": "A"})).unwrap();
|
||||
s.execute("file.new", &json!({"name": "B"})).unwrap();
|
||||
let (a, b) = (s.docs.iter().position(|d| d.doc.name == "A").expect("file A"), s.docs.iter().position(|d| d.doc.name == "B").expect("file B"));
|
||||
let mut u = Ui::new(s);
|
||||
u.app.session.active = a;
|
||||
let rx = push(&mut u, 8, json!({}));
|
||||
u.frame(vec![]);
|
||||
assert_eq!(u.app.capture.shots.len(), 1, "admitted against A");
|
||||
// The tab switches while the capture settles: the pixels that arrive are B's, not A's.
|
||||
u.app.session.active = b;
|
||||
settle_to_issue(&mut u, 1);
|
||||
let spec = u.app.capture.shots[0].spec.expect("geometry");
|
||||
let slot = u.app.capture.shots[0].slot.clone().expect("issued");
|
||||
slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED)));
|
||||
u.frame(vec![]);
|
||||
let reply = rx.try_recv().expect("answered");
|
||||
assert!(reply.result.unwrap_err().contains("document changed"), "a tab switch invalidates the capture");
|
||||
assert!(u.app.capture.shots.is_empty(), "released");
|
||||
}
|
||||
|
||||
/// Asserts the capture budget covers the encoding and reply peaks, not just pixels, and an
|
||||
/// over-budget resize is rejected before readback.
|
||||
#[test]
|
||||
fn the_capture_budget_counts_the_encoding_peak_not_only_the_pixels() {
|
||||
let fits = CaptureSpec { viewport: egui::ViewportId::ROOT, size_px: [4096, 4096], pixels_per_point: 1.0, zoom: 1.0 };
|
||||
let over = CaptureSpec { viewport: egui::ViewportId::ROOT, size_px: [5000, 5000], pixels_per_point: 1.0, zoom: 1.0 };
|
||||
// The encode phase holds the readback and the straight-RGBA copy while the PNG is built; the
|
||||
// reply phase holds the readback and PNG plus the base64 string after that copy is dropped.
|
||||
let raw = fits.bytes().expect("pixels");
|
||||
let png = raw + super::SHOT_PNG_OVERHEAD;
|
||||
let encode_phase = raw * 2 + png;
|
||||
let reply_phase = raw + png + (png / 3 + 1) * 4;
|
||||
assert_eq!(fits.cost(), Some(encode_phase.max(reply_phase)), "cost covers both live encoding phases");
|
||||
// 5000² × 4 is under the per-image pixel budget but its PNG copy and base64 reply are not.
|
||||
assert!(over.bytes().expect("pixels") <= super::MAX_SHOT_IMAGE_BYTES);
|
||||
assert!(over.cost().expect("cost") > super::MAX_SHOT_TOTAL_BYTES, "the reply is part of the bound");
|
||||
assert!(fits.cost().expect("cost") <= super::MAX_SHOT_TOTAL_BYTES);
|
||||
// The real resize path enforces that bound, even after admission at a smaller size.
|
||||
let mut u = Ui::new(Session::empty());
|
||||
let rx = push(&mut u, 9, json!({}));
|
||||
u.frame(vec![]);
|
||||
assert_eq!(u.app.capture.shots.len(), 1, "admitted");
|
||||
let out = resized_frame(&mut u, egui::vec2(5000.0, 5000.0));
|
||||
let reply = rx.try_recv().expect("rejected");
|
||||
assert!(reply.result.unwrap_err().contains("capture budget"), "the encoding peak is refused");
|
||||
assert!(u.app.capture.shots.is_empty(), "nothing retained");
|
||||
assert!(!has_screenshot(&out), "over-budget growth is rejected before any readback command");
|
||||
}
|
||||
|
||||
/// Asserts resizing settling captures rechecks aggregate reserved bytes, rejects the request
|
||||
/// that no longer fits, and makes the survivor reserve its real cost.
|
||||
#[test]
|
||||
fn resizing_settling_captures_rechecks_the_aggregate_reserved_bytes() {
|
||||
let mut u = Ui::new(Session::empty());
|
||||
let a = push(&mut u, 81, json!({}));
|
||||
let b = push(&mut u, 82, json!({}));
|
||||
u.frame(vec![]);
|
||||
assert_eq!(u.app.capture.shots.len(), 2, "both fit at the original size");
|
||||
let out = resized_frame(&mut u, egui::vec2(3200.0, 3200.0));
|
||||
let replies: Vec<_> = [a.try_recv(), b.try_recv()].into_iter().filter_map(Result::ok).collect();
|
||||
assert_eq!(replies.len(), 1, "each resized request fits alone, but not both together");
|
||||
assert!(replies[0].result.as_ref().unwrap_err().contains("capture budget"));
|
||||
assert_eq!(u.app.capture.shots.len(), 1);
|
||||
let remaining = &u.app.capture.shots[0];
|
||||
assert_eq!(remaining.spec.unwrap().size_px, [3200, 3200]);
|
||||
assert_eq!(remaining.reserved, remaining.spec.unwrap().cost().unwrap(), "the survivor reserves its real current cost");
|
||||
assert!(!remaining.issued);
|
||||
assert!(!has_screenshot(&out), "neither request was read back using the old small reservation");
|
||||
}
|
||||
|
||||
/// Asserts an allowed resize restarts settling, issues only after three new-size frames, and
|
||||
/// reserves the current cost.
|
||||
#[test]
|
||||
fn an_allowed_resize_restarts_settling_and_issues_with_the_current_reservation() {
|
||||
let mut u = Ui::new(Session::empty());
|
||||
let _rx = push(&mut u, 83, json!({}));
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(u.app.capture.shots[0].settle, 2);
|
||||
let size = egui::vec2(2000.0, 1200.0);
|
||||
let out = resized_frame(&mut u, size);
|
||||
assert!(!has_screenshot(&out), "the third old-size frame cannot issue a newly resized surface");
|
||||
assert_eq!(u.app.capture.shots[0].settle, 0);
|
||||
for _ in 0..2 {
|
||||
assert!(!has_screenshot(&resized_frame(&mut u, size)), "wait for three completed new-size frames");
|
||||
}
|
||||
let out = resized_frame(&mut u, size);
|
||||
assert!(has_screenshot(&out), "a valid settled capture still issues");
|
||||
let request = &u.app.capture.shots[0];
|
||||
assert!(request.issued);
|
||||
assert_eq!(request.spec.unwrap().size_px, [2000, 1200]);
|
||||
assert_eq!(request.reserved, request.spec.unwrap().cost().unwrap());
|
||||
}
|
||||
|
||||
/// Queue an injected-input request (the desktop app's `raw_input_hook` is simulated by
|
||||
/// `windows::take_input` in these tests).
|
||||
fn push_input(u: &mut Ui, id: u64) -> std::sync::mpsc::Receiver<ControlReply> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
u.app.control_queue.push(ControlRequest {
|
||||
id: json!(id),
|
||||
method: "ui.input".into(),
|
||||
params: json!({"events": [{"move": [10.0, 10.0]}]}),
|
||||
reply: tx,
|
||||
state: Default::default(),
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
/// Asserts injected input is queued, applied by the presenting pass, and acknowledged once
|
||||
/// after that pass.
|
||||
#[test]
|
||||
fn injected_input_is_acknowledged_only_after_the_pass_that_applies_it() {
|
||||
let mut u = Ui::new(Session::empty());
|
||||
let rx = push_input(&mut u, 20);
|
||||
u.frame(vec![]);
|
||||
assert!(rx.try_recv().is_err(), "queued, not applied yet");
|
||||
let events = crate::windows::take_input(&mut u.app, egui::ViewportId::ROOT);
|
||||
assert!(!events.is_empty(), "the batch the window draws with");
|
||||
assert!(u.app.control_synthetic, "the applying frame is fenced from the system clipboard");
|
||||
u.frame(vec![]);
|
||||
let reply = rx.try_recv().expect("acknowledged by the presented pass");
|
||||
assert_eq!(reply.id, json!(20));
|
||||
assert!(reply.result.is_ok());
|
||||
assert!(!u.app.control_synthetic, "the fence ends with the frame");
|
||||
}
|
||||
|
||||
/// Asserts a discarded pass neither acknowledges nor replays an injected batch; the presented
|
||||
/// pass acknowledges once.
|
||||
#[test]
|
||||
fn a_discarded_pass_does_not_acknowledge_or_lose_an_injected_batch() {
|
||||
let mut u = Ui::new(Session::empty());
|
||||
let rx = push_input(&mut u, 21);
|
||||
u.frame(vec![]);
|
||||
let events = crate::windows::take_input(&mut u.app, egui::ViewportId::ROOT);
|
||||
assert!(!events.is_empty());
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))),
|
||||
time: Some(1.0),
|
||||
..Default::default()
|
||||
};
|
||||
let app = &mut u.app;
|
||||
let ctx = u.ctx.clone();
|
||||
let mut passes = 0;
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
passes += 1;
|
||||
app.ui(ui);
|
||||
if passes == 1 {
|
||||
ui.ctx().request_discard("test sizing pass");
|
||||
}
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
assert_eq!(passes, 2);
|
||||
// The batch's side effects already happened; it is never replayed. Its acknowledgement waits
|
||||
// for the pass whose output is actually presented.
|
||||
assert_eq!(rx.try_recv().expect("acknowledged by the presented pass").id, json!(21));
|
||||
assert!(app.control_input.is_empty(), "the batch is not replayed");
|
||||
assert!(app.input_acks.is_empty() && app.applied_acks.is_empty());
|
||||
}
|
||||
|
||||
/// Asserts injected text is applied once across a discarded sizing pass and acknowledged
|
||||
/// once.
|
||||
#[test]
|
||||
fn injected_text_lands_once_across_a_discarded_sizing_pass() {
|
||||
// egui reruns a discarded pass with the same `RawInput`. The custom editor applied the text in
|
||||
// the first pass; the rerun must not insert it a second time.
|
||||
let mut s = Session::new();
|
||||
let id = NodeId(s.execute("text.create", &json!({"text": "X", "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap());
|
||||
let mut u = Ui::new(s);
|
||||
figcraft_engine::textedit::begin(&mut u.app.session, id, Some(1), false).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
u.app.control_queue.push(ControlRequest {
|
||||
id: json!(22),
|
||||
method: "ui.input".into(),
|
||||
params: json!({"events": [{"text": "abc"}]}),
|
||||
reply: tx,
|
||||
state: Default::default(),
|
||||
});
|
||||
u.frame(vec![]);
|
||||
let events = crate::windows::take_input(&mut u.app, egui::ViewportId::ROOT);
|
||||
assert!(!events.is_empty(), "the applying frame's batch");
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))),
|
||||
time: Some(1.0),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let app = &mut u.app;
|
||||
let ctx = u.ctx.clone();
|
||||
let mut passes = 0;
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
passes += 1;
|
||||
app.ui(ui);
|
||||
if passes == 1 {
|
||||
ui.ctx().request_discard("a grid-like sizing pass");
|
||||
}
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
assert_eq!(passes, 2, "egui reran the frame");
|
||||
let typed = match &app.session.doc().expect("a file").node(id).expect("the text layer").kind {
|
||||
figcraft_doc::NodeKind::Text(t) => t.characters.clone(),
|
||||
_ => panic!("not text"),
|
||||
};
|
||||
assert_eq!(typed, "Xabc", "the discarded pass's text was not applied twice");
|
||||
assert_eq!(rx.try_recv().expect("acknowledged once").id, json!(22));
|
||||
assert!(app.input_acks.is_empty() && app.applied_acks.is_empty());
|
||||
}
|
||||
@@ -52,7 +52,7 @@ fn named_button(u: &mut Ui, label: &str) -> Pos2 {
|
||||
|
||||
/// Corner field `k` (an index into the radii).
|
||||
fn field(u: &Ui, k: usize) -> Rect {
|
||||
u.ctx.read_response(Id::new(("corner", k)).with("field")).expect("corner field shows").rect
|
||||
u.ctx.read_response(Id::unique(("corner", k)).with("field")).expect("corner field shows").rect
|
||||
}
|
||||
|
||||
/// The iOS button next to Corner smoothing sets 60% in one undo step, as the help page's "click
|
||||
@@ -103,8 +103,8 @@ fn polygons_and_stars_take_corner_smoothing() {
|
||||
// With no corners of their own, the button is named for what it opens.
|
||||
let t = named_button(&mut u, "Corner smoothing");
|
||||
click(&mut u, t);
|
||||
assert!((0..4).all(|k| u.ctx.read_response(Id::new(("corner", k)).with("field")).is_none()), "{cmd}: one radius, no corner fields");
|
||||
let smoothing = u.ctx.read_response(Id::new("smoothing").with("field")).expect("the smoothing field shows").rect.center();
|
||||
assert!((0..4).all(|k| u.ctx.read_response(Id::unique(("corner", k)).with("field")).is_none()), "{cmd}: one radius, no corner fields");
|
||||
let smoothing = u.ctx.read_response(Id::unique("smoothing").with("field")).expect("the smoothing field shows").rect.center();
|
||||
let history = u.app.session.undo_depth().0;
|
||||
click(&mut u, smoothing);
|
||||
u.frame(vec![Event::Text("60".into())]);
|
||||
|
||||
@@ -256,7 +256,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
/// [`contributors_ui`] over a given list (tests pass their own).
|
||||
fn contributors_in(ui: &mut egui::Ui, contributors: &[Contributor], total_commits: u64) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let id = egui::Id::new("credits_view");
|
||||
let id = egui::Id::unique("credits_view");
|
||||
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(RichText::new(tr("Show")).color(t.text_secondary));
|
||||
@@ -446,7 +446,7 @@ fn cell(ui: &mut egui::Ui, numeric: bool, text: &str) {
|
||||
fn header_cell(ui: &mut egui::Ui, t: &Tokens, label: &str, numeric: bool, sort: Option<Option<bool>>) -> egui::Response {
|
||||
let rect = ui.max_rect();
|
||||
let resp = ui.allocate_rect(rect, if sort.is_some() { egui::Sense::click() } else { egui::Sense::hover() });
|
||||
let kind = if sort.is_some() { egui::WidgetType::Button } else { egui::WidgetType::Label };
|
||||
let kind = if sort.is_some() { egui::Role::Button } else { egui::Role::Label };
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(kind, true, label));
|
||||
let resp = if sort.is_some() { resp.on_hover_cursor(egui::CursorIcon::PointingHand) } else { resp };
|
||||
let current = sort.flatten();
|
||||
|
||||
@@ -51,12 +51,14 @@ impl Ui {
|
||||
u
|
||||
}
|
||||
|
||||
/// Store the About table's current view in test context data.
|
||||
fn set_view(&self, v: View) {
|
||||
self.ctx.data_mut(|d| d.insert_temp(egui::Id::new("credits_view"), v));
|
||||
self.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("credits_view"), v));
|
||||
}
|
||||
|
||||
/// Read the stored About table view, defaulting when unset.
|
||||
fn view(&self) -> View {
|
||||
self.ctx.data(|d| d.get_temp::<View>(egui::Id::new("credits_view"))).unwrap_or_default()
|
||||
self.ctx.data(|d| d.get_temp::<View>(egui::Id::unique("credits_view"))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// One frame; returns the rect `draw` filled.
|
||||
|
||||
@@ -121,7 +121,7 @@ impl Shown {
|
||||
pub fn show(self, ctx: &egui::Context, at: Pos2) {
|
||||
ctx.set_cursor_icon(self.icon());
|
||||
if let Shown::Drawn(g) = self {
|
||||
paint(&ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("canvas_cursor"))), at, g);
|
||||
paint(&ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("canvas_cursor"))), at, g);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let mut open = true;
|
||||
let mut action = None;
|
||||
egui::Window::new("Compare changes")
|
||||
.id(Id::new("dev_compare_window"))
|
||||
.id(Id::unique("dev_compare_window"))
|
||||
.title_bar(false)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
@@ -277,7 +277,7 @@ pub(crate) fn header(ui: &mut Ui, t: &Tokens, title: &str, name: &str, mode: &mu
|
||||
for (m, label) in modes.iter().rev() {
|
||||
let g = ui.painter().layout_no_wrap((*label).into(), regular(11.0), t.text);
|
||||
let cell = Rect::from_min_max(pos2(x - g.size().x - 20.0, r.center().y - 12.0), pos2(x, r.center().y + 12.0));
|
||||
let resp = ui.interact(cell, Id::new(("compare_mode", label)), Sense::click());
|
||||
let resp = ui.interact(cell, Id::unique(("compare_mode", label)), Sense::click());
|
||||
let on = *mode == *m;
|
||||
crate::a11y::tab(&resp, label, on);
|
||||
if on {
|
||||
@@ -294,7 +294,7 @@ pub(crate) fn header(ui: &mut Ui, t: &Tokens, title: &str, name: &str, mode: &mu
|
||||
}
|
||||
if widgets::icon_button_at(
|
||||
ui,
|
||||
Id::new("compare_close"),
|
||||
Id::unique("compare_close"),
|
||||
Rect::from_center_size(pos2(r.max.x - 24.0, r.center().y), vec2(24.0, 24.0)),
|
||||
Icon::Close,
|
||||
tr("Close"),
|
||||
@@ -344,7 +344,7 @@ pub(crate) fn images(ui: &mut Ui, t: &Tokens, r: Rect, shots: [(&Shot, &str); 2]
|
||||
p.text(pos2(img.min.x, label_y), Align2::LEFT_CENTER, before_label, semibold(11.0), t.text_secondary);
|
||||
p.text(pos2(img.max.x, label_y), Align2::RIGHT_CENTER, after_label, semibold(11.0), t.text_secondary);
|
||||
// The slider: a line with a knob, dragged (or clicked) anywhere over the images.
|
||||
let resp = ui.interact(img, Id::new("compare_split"), Sense::click_and_drag());
|
||||
let resp = ui.interact(img, Id::unique("compare_split"), Sense::click_and_drag());
|
||||
if let Some(pos) = resp.interact_pointer_pos().filter(|_| resp.dragged() || resp.clicked()) {
|
||||
*split = ((pos.x - img.min.x) / img.width().max(1.0)).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
@@ -291,7 +291,7 @@ impl Callout {
|
||||
|
||||
/// Where the template being edited in Connect to code keeps its draft.
|
||||
fn template_id(owner: NodeId, lang: CodeLang) -> Id {
|
||||
Id::new(("code_connect_template", owner, lang.id()))
|
||||
Id::unique(("code_connect_template", owner, lang.id()))
|
||||
}
|
||||
|
||||
/// Layer `id`'s code in `lang`, generated again only when the document or the choice change (a
|
||||
@@ -390,7 +390,7 @@ pub fn playground_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: Nod
|
||||
"text" => row(ui, 32.0, |ui| {
|
||||
widgets::label(ui, name, t.text_secondary);
|
||||
let cur = value.as_str().unwrap_or("");
|
||||
let field = Id::new(("playground_text", id, name));
|
||||
let field = Id::unique(("playground_text", id, name));
|
||||
if let Some(new) = widgets::text_field_named(ui, field, cur, ui.available_width().max(60.0), name)
|
||||
&& new != cur
|
||||
{
|
||||
@@ -471,10 +471,10 @@ pub fn connect_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId
|
||||
let col = widgets::col_width(ui.available_width());
|
||||
row(ui, 32.0, |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
if let Some(v) = widgets::text_field(ui, Id::new(("code_connect_import", owner, lang.id())), &link.import_path, col, tr("Import path")) {
|
||||
if let Some(v) = widgets::text_field(ui, Id::unique(("code_connect_import", owner, lang.id())), &link.import_path, col, tr("Import path")) {
|
||||
edit = Some(("importPath", v));
|
||||
}
|
||||
if let Some(v) = widgets::text_field(ui, Id::new(("code_connect_name", owner, lang.id())), &link.name, col, tr("Component name")) {
|
||||
if let Some(v) = widgets::text_field(ui, Id::unique(("code_connect_name", owner, lang.id())), &link.name, col, tr("Component name")) {
|
||||
edit = Some(("name", v));
|
||||
}
|
||||
});
|
||||
@@ -585,13 +585,13 @@ pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: No
|
||||
t.text_secondary,
|
||||
);
|
||||
let del = Rect::from_center_size(pos2(r.max.x - PAD - 4.0, r.center().y), vec2(22.0, 22.0));
|
||||
if widgets::icon_button_at(ui, Id::new(("annot_del", id, k)), del, Icon::Minus, tr("Remove annotation")) {
|
||||
if widgets::icon_button_at(ui, Id::unique(("annot_del", id, k)), del, Icon::Minus, tr("Remove annotation")) {
|
||||
app.run("annotation.remove", json!({"id": id, "index": k}));
|
||||
}
|
||||
if let Some(cat) = category_menu(ui, doc, Id::new(("annot_cat", id, k)), a) {
|
||||
if let Some(cat) = category_menu(ui, doc, Id::unique(("annot_cat", id, k)), a) {
|
||||
app.run("annotation.edit", json!({"id": id, "index": k, "category": cat}));
|
||||
}
|
||||
if let Some(label) = widgets::multiline_field(ui, Id::new(("annot_note", id, k)), &a.label, tr("Add a note for developers"), 2) {
|
||||
if let Some(label) = widgets::multiline_field(ui, Id::unique(("annot_note", id, k)), &a.label, tr("Add a note for developers"), 2) {
|
||||
if label.trim().is_empty() && a.properties.is_empty() {
|
||||
app.run("annotation.remove", json!({"id": id, "index": k}));
|
||||
} else {
|
||||
@@ -602,7 +602,7 @@ pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: No
|
||||
for (p, v) in annotations::pinned(doc, id, a) {
|
||||
prop_row(app, ui, &v.unwrap_or_else(|| Prop { label: p.label.to_string(), value: "—".into(), color: None, token: None }));
|
||||
}
|
||||
if let Some(p) = pin_menu(ui, Id::new(("annot_pin", id, k)), §ions, &a.properties) {
|
||||
if let Some(p) = pin_menu(ui, Id::unique(("annot_pin", id, k)), §ions, &a.properties) {
|
||||
let mut props = a.properties.clone();
|
||||
match props.iter().position(|q| q == p.id) {
|
||||
Some(i) => {
|
||||
@@ -620,7 +620,7 @@ pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: No
|
||||
}
|
||||
// A new annotation starts as a note; properties are pinned to it afterwards.
|
||||
if n.annotations.len() < annotations::MAX_PER_LAYER
|
||||
&& let Some(label) = widgets::multiline_field(ui, Id::new(("annot_new", id)), "", tr("Add an annotation for developers"), 1)
|
||||
&& let Some(label) = widgets::multiline_field(ui, Id::unique(("annot_new", id)), "", tr("Add an annotation for developers"), 1)
|
||||
&& !label.trim().is_empty()
|
||||
{
|
||||
app.run("annotation.add", json!({"id": id, "label": label}));
|
||||
@@ -636,7 +636,7 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id:
|
||||
let Some(n) = doc.node(id).filter(|n| !n.is_page()) else { return };
|
||||
let list = dev_resources::list(doc, id);
|
||||
let can_add = !components::inside_instance(doc, id) && n.dev_resources.len() < dev_resources::MAX_PER_LAYER;
|
||||
let (adding_id, editing_id) = (Id::new(("devres_adding", id)), Id::new(("devres_editing", id)));
|
||||
let (adding_id, editing_id) = (Id::unique(("devres_adding", id)), Id::unique(("devres_editing", id)));
|
||||
let mut adding = can_add && ui.data(|d| d.get_temp::<bool>(adding_id)).unwrap_or(false);
|
||||
let mut editing = ui.data(|d| d.get_temp::<usize>(editing_id)).filter(|i| *i < n.dev_resources.len());
|
||||
widgets::separator(ui);
|
||||
@@ -652,10 +652,10 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id:
|
||||
let mut edit = None;
|
||||
row(ui, 32.0, |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
if let Some(v) = widgets::text_field(ui, Id::new(("devres_name", id, i)), name, col, tr("Name")) {
|
||||
if let Some(v) = widgets::text_field(ui, Id::unique(("devres_name", id, i)), name, col, tr("Name")) {
|
||||
edit = Some(json!({"id": id, "index": i, "name": v}));
|
||||
}
|
||||
if let Some(v) = widgets::text_field(ui, Id::new(("devres_url", id, i)), url, col, "https://…") {
|
||||
if let Some(v) = widgets::text_field(ui, Id::unique(("devres_url", id, i)), url, col, "https://…") {
|
||||
edit = Some(json!({"id": id, "index": i, "url": v}));
|
||||
}
|
||||
if widgets::icon_button(ui, Icon::Check, tr("Done")) {
|
||||
@@ -692,10 +692,10 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id:
|
||||
if let Some(i) = item.index {
|
||||
let del = Rect::from_center_size(pos2(r.max.x - PAD - 4.0, r.center().y), vec2(22.0, 22.0));
|
||||
let pen = del.translate(vec2(-26.0, 0.0));
|
||||
if widgets::icon_button_at(ui, Id::new(("devres_edit", id, i)), pen, Icon::Pencil, tr("Edit dev resource")) {
|
||||
if widgets::icon_button_at(ui, Id::unique(("devres_edit", id, i)), pen, Icon::Pencil, tr("Edit dev resource")) {
|
||||
editing = Some(i);
|
||||
}
|
||||
if widgets::icon_button_at(ui, Id::new(("devres_del", id, i)), del, Icon::Minus, tr("Remove dev resource")) {
|
||||
if widgets::icon_button_at(ui, Id::unique(("devres_del", id, i)), del, Icon::Minus, tr("Remove dev resource")) {
|
||||
app.run("devResource.remove", json!({"id": id, "index": i}));
|
||||
editing = None;
|
||||
}
|
||||
@@ -704,7 +704,7 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id:
|
||||
if adding {
|
||||
let w = ui.available_width() - PAD * 2.0;
|
||||
let mut added = None;
|
||||
row(ui, 32.0, |ui| added = widgets::text_field(ui, Id::new(("devres_new", id)), "", w, tr("Paste a link (https://…)")));
|
||||
row(ui, 32.0, |ui| added = widgets::text_field(ui, Id::unique(("devres_new", id)), "", w, tr("Paste a link (https://…)")));
|
||||
if let Some(url) = added.filter(|u| !u.trim().is_empty())
|
||||
&& app.run("devResource.add", json!({"id": id, "url": url})).is_some()
|
||||
{
|
||||
@@ -810,7 +810,7 @@ fn measurement_row(ui: &mut Ui, t: &Tokens, doc: &Document, m: &Placed) -> Optio
|
||||
if resp.clicked() {
|
||||
action = Some(("select.set", json!({"ids": [m.from]})));
|
||||
}
|
||||
if widgets::icon_button_at(ui, Id::new(("measure_del", m.from, m.index)), del, Icon::Minus, tr("Remove measurement")) {
|
||||
if widgets::icon_button_at(ui, Id::unique(("measure_del", m.from, m.index)), del, Icon::Minus, tr("Remove measurement")) {
|
||||
action = Some(("devMode.removeMeasurement", json!({"from": m.from, "index": m.index})));
|
||||
}
|
||||
action
|
||||
@@ -886,7 +886,7 @@ pub fn categories_section(app: &mut FigApp, ui: &mut Ui) {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover());
|
||||
// The colour: click for the palette.
|
||||
let sw = Rect::from_center_size(pos2(r.min.x + PAD + 8.0, r.center().y), vec2(16.0, 16.0));
|
||||
let sw_resp = ui.interact(sw, Id::new(("cat_color", &c.id)), Sense::click()).tip(tr("Colour"));
|
||||
let sw_resp = ui.interact(sw, Id::unique(("cat_color", &c.id)), Sense::click()).tip(tr("Colour"));
|
||||
crate::a11y::color(&sw_resp, &c.name, &c.color.to_css_hex());
|
||||
ui.painter().rect_filled(sw, CornerRadius::same(4), doc_c32(ui.ctx(), c.color));
|
||||
if sw_resp.hovered() {
|
||||
@@ -909,14 +909,14 @@ pub fn categories_section(app: &mut FigApp, ui: &mut Ui) {
|
||||
});
|
||||
});
|
||||
let name_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y + 3.0), pos2(r.max.x - PAD - 28.0, r.max.y - 3.0));
|
||||
if let Some(name) = widgets::text_field_inline(ui, Id::new(("cat_name", &c.id)), &c.name, name_r, regular(11.0))
|
||||
if let Some(name) = widgets::text_field_inline(ui, Id::unique(("cat_name", &c.id)), &c.name, name_r, regular(11.0))
|
||||
&& name.trim() != c.name
|
||||
&& !name.trim().is_empty()
|
||||
{
|
||||
action = Some(("annotation.editCategory", json!({"id": c.id, "name": name.trim()})));
|
||||
}
|
||||
let del = Rect::from_center_size(pos2(r.max.x - PAD - 8.0, r.center().y), vec2(22.0, 22.0));
|
||||
if widgets::icon_button_at(ui, Id::new(("cat_del", &c.id)), del, Icon::Minus, tr("Delete category (its annotations stay)")) {
|
||||
if widgets::icon_button_at(ui, Id::unique(("cat_del", &c.id)), del, Icon::Minus, tr("Delete category (its annotations stay)")) {
|
||||
action = Some(("annotation.removeCategory", json!({"id": c.id})));
|
||||
}
|
||||
}
|
||||
@@ -1014,7 +1014,7 @@ pub fn ready_list(app: &mut FigApp, ui: &mut Ui) {
|
||||
CornerRadius::same(5),
|
||||
t.hover,
|
||||
);
|
||||
if widgets::icon_button_at(ui, Id::new(("ready_focus", item.id)), focus_r, Icon::Frame, tr("Open in focus view")) {
|
||||
if widgets::icon_button_at(ui, Id::unique(("ready_focus", item.id)), focus_r, Icon::Frame, tr("Open in focus view")) {
|
||||
focus = Some(item.id);
|
||||
}
|
||||
}
|
||||
@@ -1058,7 +1058,7 @@ pub fn focus_bar(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) {
|
||||
// Somewhere to go: another ready frame, or into the list from outside it.
|
||||
let can_step = list.len() > usize::from(at.is_some());
|
||||
let t = Tokens::get(ctx);
|
||||
let action = widgets::canvas_banner(ctx, Id::new("dev_focus_bar"), canvas, |ui| {
|
||||
let action = widgets::canvas_banner(ctx, Id::unique("dev_focus_bar"), canvas, |ui| {
|
||||
let mut out = None;
|
||||
let icon = crate::layers::node_icon(&doc, id);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(ui.cursor().min + vec2(7.0, 12.0), vec2(14.0, 14.0)), icon, t.icon_secondary);
|
||||
|
||||
@@ -126,8 +126,13 @@ fn playground_combo(u: &mut crate::layers_drag_tests::Ui, name: &str) -> Option<
|
||||
if centre(&combo).y < 860.0 {
|
||||
return Some(combo);
|
||||
}
|
||||
let wheel =
|
||||
Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, -100.0), phase: TouchPhase::Move, modifiers: Default::default() };
|
||||
let wheel = Event::MouseWheel {
|
||||
unit: MouseWheelUnit::Point,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
delta: vec2(0.0, -100.0),
|
||||
phase: TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
};
|
||||
u.frame(vec![Event::PointerMoved(pos2(1300.0, 500.0)), wheel]);
|
||||
for _ in 0..30 {
|
||||
u.frame(vec![]);
|
||||
|
||||
@@ -105,7 +105,7 @@ pub(crate) fn tab_strip<T: Copy + PartialEq>(ui: &mut egui::Ui, t: &Tokens, tabs
|
||||
let active = *current == tab;
|
||||
let galley = ui.painter().layout_no_wrap(label.into(), semibold(11.0), if active { t.text } else { t.text_secondary });
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 16.0, 24.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, label));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, label));
|
||||
if active || resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
@@ -179,6 +179,10 @@ pub fn keys(app: &mut FigApp, ctx: &Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the app's dialogs and cards: pending opens/imports/missing fonts, About,
|
||||
/// Preferences, shortcuts, find/replace, batch rename, menu input, variables, dev compare,
|
||||
/// branch, export preview, colour profile, plugin, live join/publish, online dialogs and
|
||||
/// close confirmation.
|
||||
pub fn show(app: &mut FigApp, ctx: &Context) {
|
||||
// Dropped files waiting to open: their card, then (once it has shown) the files.
|
||||
crate::pending_open::pending_opens(app, ctx);
|
||||
@@ -246,7 +250,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tr("Your name in comments"));
|
||||
if let Some(name) = crate::widgets::text_field(ui, egui::Id::new("pref_user_name"), &prefs.user_name, 160.0, tr("You")) {
|
||||
if let Some(name) = crate::widgets::text_field(ui, egui::Id::unique("pref_user_name"), &prefs.user_name, 160.0, tr("You")) {
|
||||
app.run("pref.userName", json!({"name": name}));
|
||||
}
|
||||
});
|
||||
@@ -256,7 +260,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
||||
// Where Share ▸ Online keeps files and join codes are asked; no key here
|
||||
// (preferences are saved as text).
|
||||
let name = tr("Server for online sharing");
|
||||
if let Some(url) = crate::widgets::text_field_named(ui, egui::Id::new("pref_publish_server"), &prefs.publish_server, 220.0, name) {
|
||||
if let Some(url) = crate::widgets::text_field_named(ui, egui::Id::unique("pref_publish_server"), &prefs.publish_server, 220.0, name) {
|
||||
app.run("pref.publishServer", json!({"url": url}));
|
||||
}
|
||||
let usual = prefs.publish_server == figcraft_engine::live::DEFAULT_SERVER;
|
||||
@@ -329,7 +333,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
||||
let mut open = true;
|
||||
let mut action = None;
|
||||
egui::Window::new(tr("Find and replace"))
|
||||
.id(egui::Id::new("Find and replace"))
|
||||
.id(egui::Id::unique("Find and replace"))
|
||||
.open(&mut open)
|
||||
.anchor(Align2::LEFT_TOP, vec2(260.0, 120.0))
|
||||
.resizable(false)
|
||||
@@ -396,7 +400,7 @@ fn menu_input_dialog(app: &mut FigApp, ctx: &Context) {
|
||||
match &mut input {
|
||||
MenuInput::Link(url) => {
|
||||
ui.horizontal(|ui| {
|
||||
let field = crate::widgets::text_input(ui, egui::Id::new("menu_input_link"), url, 250.0, "https://");
|
||||
let field = crate::widgets::text_input(ui, egui::Id::unique("menu_input_link"), url, 250.0, "https://");
|
||||
let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let ok = !url.trim().is_empty();
|
||||
if (crate::widgets::button(ui, tr("Done"), true, ok).clicked() || enter) && ok {
|
||||
@@ -406,7 +410,7 @@ fn menu_input_dialog(app: &mut FigApp, ctx: &Context) {
|
||||
}
|
||||
MenuInput::Offset(offset) => {
|
||||
ui.horizontal(|ui| {
|
||||
let id = egui::Id::new("menu_input_offset");
|
||||
let id = egui::Id::unique("menu_input_offset");
|
||||
if let Some(crate::widgets::FieldEdit::Set(v) | crate::widgets::FieldEdit::Scrub(v, _)) =
|
||||
crate::widgets::number_field(ui, id, crate::widgets::Lead::None, Some(*offset), "", 120.0, 1.0)
|
||||
{
|
||||
@@ -573,7 +577,7 @@ fn batch_rename_dialog(app: &mut FigApp, ctx: &Context) {
|
||||
let mut open = true;
|
||||
let mut cancel = false;
|
||||
let mut apply = false;
|
||||
window(tr("Rename layers"), ctx).id(egui::Id::new("Rename layers")).open(&mut open).show(ctx, |ui| {
|
||||
window(tr("Rename layers"), ctx).id(egui::Id::unique("Rename layers")).open(&mut open).show(ctx, |ui| {
|
||||
ui.set_width(320.0);
|
||||
ui.label(RichText::new(trn("{n} layers", n, &[])).color(t.text_secondary));
|
||||
ui.add_space(6.0);
|
||||
@@ -695,7 +699,11 @@ pub(crate) fn file_saved(app: &mut FigApp, result: &Value) {
|
||||
}
|
||||
|
||||
fn result_file_name(result: &Value) -> String {
|
||||
result["path"].as_str().and_then(|p| Path::new(p).file_name()).map(|f| f.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
match result["path"].as_str() {
|
||||
Some(p) => Path::new(p).file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
// A download (#969).
|
||||
None => result["name"].as_str().unwrap_or_default().to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `.fig` result stays an export: native save state is unchanged, and omissions are reported.
|
||||
@@ -711,6 +719,15 @@ pub(crate) fn fig_exported(app: &mut FigApp, result: &Value) {
|
||||
/// Save the whole file as `.fig`; the toast says how much was left out.
|
||||
fn save_fig(app: &mut FigApp, name: &str) {
|
||||
let filename = format!("{name}.fig");
|
||||
if app.services.downloads() {
|
||||
if let Some(mut r) = app.run("file.export", json!({"format": "fig"}))
|
||||
&& app.download(&filename, &r)
|
||||
{
|
||||
r["name"] = json!(filename);
|
||||
fig_exported(app, &r);
|
||||
}
|
||||
return;
|
||||
}
|
||||
app.pick_save(tr("Export"), &filename, &["fig"], |app, p| {
|
||||
if let Some(r) = app.run("file.export", json!({"format": "fig", "path": p.to_string_lossy()})) {
|
||||
fig_exported(app, &r);
|
||||
@@ -802,7 +819,7 @@ fn export_dialog(app: &mut FigApp, ctx: &Context) {
|
||||
// The PDF preset's print options.
|
||||
ui.add_space(6.0);
|
||||
caption(ui, tr("PDF print options"));
|
||||
if let Some(next) = crate::export_panel::print_options(ui, egui::Id::new("export_dialog_print"), &print) {
|
||||
if let Some(next) = crate::export_panel::print_options(ui, egui::Id::unique("export_dialog_print"), &print) {
|
||||
app.ui.pdf_print = next;
|
||||
}
|
||||
divider(ui);
|
||||
|
||||
@@ -74,13 +74,13 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo
|
||||
if let Some(size_w) = size_w {
|
||||
let presets: Vec<String> =
|
||||
ExportSize::SCALES.iter().map(|value| ExportSize::Scale { value: *value }.label()).chain(["512w".into(), "512h".into()]).collect();
|
||||
if let Some(s) = widgets::combo_field(ui, Id::new(("export_size", i)), &e.size.label(), size_w, &presets)
|
||||
if let Some(s) = widgets::combo_field(ui, Id::unique(("export_size", i)), &e.size.label(), size_w, &presets)
|
||||
&& ExportSize::parse(&s) != Some(e.size)
|
||||
{
|
||||
app.run("export.update", json!({"ids": ids, "index": i, "size": s}));
|
||||
}
|
||||
}
|
||||
if let Some(sfx) = widgets::text_field(ui, Id::new(("export_suffix", i)), &e.suffix, suffix_w, tr("Suffix"))
|
||||
if let Some(sfx) = widgets::text_field(ui, Id::unique(("export_suffix", i)), &e.suffix, suffix_w, tr("Suffix"))
|
||||
&& sfx != e.suffix
|
||||
{
|
||||
app.run("export.update", json!({"ids": ids, "index": i, "suffix": sfx}));
|
||||
@@ -96,7 +96,7 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo
|
||||
});
|
||||
let more = icons::button(ui, Icon::Dots, button, false, tr("Export settings"));
|
||||
egui::Popup::from_toggle_button_response(&more)
|
||||
.id(Id::new(("export_more", i)))
|
||||
.id(Id::unique(("export_more", i)))
|
||||
.width(216.0)
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.show(|ui| {
|
||||
@@ -142,7 +142,7 @@ fn advanced_options(ui: &mut Ui, e: &ExportSetting, text: bool) -> Option<Value>
|
||||
fn print_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &PrintSettings) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
row(ui, 26.0, |ui| {
|
||||
let open_id = Id::new(("export_print", i));
|
||||
let open_id = Id::unique(("export_print", i));
|
||||
let open = egui::Popup::is_id_open(ui.ctx(), open_id);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), FIELD_H), Sense::click());
|
||||
if resp.hovered() || open {
|
||||
@@ -156,7 +156,7 @@ fn print_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &PrintS
|
||||
.width(216.0)
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.show(|ui| {
|
||||
if let Some(next) = print_options(ui, Id::new(("export_print_fields", i)), p) {
|
||||
if let Some(next) = print_options(ui, Id::unique(("export_print_fields", i)), p) {
|
||||
let mut params = print_params(&next);
|
||||
params["ids"] = json!(ids);
|
||||
params["index"] = json!(i);
|
||||
@@ -264,7 +264,7 @@ fn preview(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId) {
|
||||
}
|
||||
let revision = app.session.ds().map(|d| d.revision).unwrap_or(0);
|
||||
let now = ui.input(|i| i.time);
|
||||
let cache_id = Id::new("export_preview_tex");
|
||||
let cache_id = Id::unique("export_preview_tex");
|
||||
let cached: Option<PreviewTex> = ui.data(|d| d.get_temp(cache_id));
|
||||
let stale = cached.as_ref().is_none_or(|c| c.id != id || c.revision != revision);
|
||||
let due = cached.as_ref().is_none_or(|c| c.id != id || now - c.at >= PREVIEW_REFRESH);
|
||||
@@ -326,6 +326,16 @@ pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId], assets: bool) {
|
||||
[] => {}
|
||||
// The suggested name keeps only the file's own name (a layer's folders apply to folders).
|
||||
[one] => save_one(app, &[one.id], &one.setting, one.file.rsplit('/').next().unwrap_or(&one.file)),
|
||||
// No folders in the browser, and it lets a page start one download at a time: the files
|
||||
// arrive together as one zip (#969).
|
||||
_ if app.services.downloads() => {
|
||||
let name = format!("{}.zip", app.session.doc().map_or("Export", |d| d.name.as_str()));
|
||||
if let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "assets": assets, "zip": true}))
|
||||
&& app.download(&name, &r)
|
||||
{
|
||||
app.toast(trn("Exported {n} files", r["files"].as_array().map_or(0, Vec::len), &[]));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let ids = ids.to_vec();
|
||||
app.pick_folder(tr("Export"), move |app, dir| {
|
||||
@@ -343,6 +353,14 @@ pub(crate) fn save_one(app: &mut FigApp, ids: &[NodeId], setting: &ExportSetting
|
||||
let ext = setting.format.ext();
|
||||
let mut params = setting_params(setting);
|
||||
params["ids"] = json!(ids);
|
||||
if app.services.downloads() {
|
||||
if let Some(r) = app.run("file.export", params)
|
||||
&& app.download(file, &r)
|
||||
{
|
||||
app.toast(trf("Exported {name}", &[("name", &file)]));
|
||||
}
|
||||
return;
|
||||
}
|
||||
app.pick_save(tr("Export"), file, &[ext], move |app, p| {
|
||||
params["path"] = json!(p.to_string_lossy());
|
||||
if app.run("file.export", params).is_some() {
|
||||
|
||||
@@ -77,7 +77,7 @@ fn export_rows_offer_a_size_for_images_only() {
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("export.add", &json!({"ids": [r]})).unwrap();
|
||||
let mut u = crate::layers_drag_tests::Ui::new(s);
|
||||
let size_menu = |u: &crate::layers_drag_tests::Ui| u.ctx.read_response(egui::Id::new(("export_size", 0)).with("menu")).is_some();
|
||||
let size_menu = |u: &crate::layers_drag_tests::Ui| u.ctx.read_response(egui::Id::unique(("export_size", 0)).with("menu")).is_some();
|
||||
u.frame(vec![]);
|
||||
assert!(size_menu(&u), "PNG has sizes");
|
||||
for format in ["svg", "pdf"] {
|
||||
@@ -108,7 +108,7 @@ fn advanced_options_offer_the_bounding_box_for_text_layers() {
|
||||
let options = |u: &mut crate::layers_drag_tests::Ui, id: NodeId| {
|
||||
u.app.run("select.set", json!({"ids": [id]}));
|
||||
u.frame(vec![]);
|
||||
egui::Popup::open_id(&u.ctx, egui::Id::new(("export_more", 0)));
|
||||
egui::Popup::open_id(&u.ctx, egui::Id::unique(("export_more", 0)));
|
||||
u.frame(vec![]);
|
||||
crate::import_report_tests::shown_texts(u.frame_output(vec![]).shapes)
|
||||
};
|
||||
|
||||
@@ -467,29 +467,33 @@ fn lead(u: &Ui, id: Id) -> Pos2 {
|
||||
u.ctx.read_response(id.with("lead")).unwrap_or_else(|| panic!("no field {id:?}")).rect.center()
|
||||
}
|
||||
|
||||
/// Asserts scrubbing the colour picker's R and alpha fields updates the colour live, each
|
||||
/// scrub adding one undo step.
|
||||
#[test]
|
||||
fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() {
|
||||
let (mut u, a, _) = setup();
|
||||
let rgba = |u: &Ui| u.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_rgba8();
|
||||
let [r0, .., a0] = rgba(&u);
|
||||
u.ctx.data_mut(|d| d.insert_temp(Id::new("cp_model"), 1usize));
|
||||
u.ctx.data_mut(|d| d.insert_temp(Id::unique("cp_model"), 1usize));
|
||||
u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] });
|
||||
// The picker window takes a frame to size itself.
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
let steps = undo_steps(&u);
|
||||
let from = lead(&u, Id::new(("cp_f", "r")));
|
||||
let from = lead(&u, Id::unique(("cp_f", "r")));
|
||||
drag(&mut u, from, -20.0, Modifiers::NONE);
|
||||
assert_eq!((rgba(&u)[0], undo_steps(&u)), (r0 - 20, steps + 1), "R");
|
||||
// The alpha field: a scrub moves it too.
|
||||
let from = lead(&u, Id::new("cp_a"));
|
||||
let from = lead(&u, Id::unique("cp_a"));
|
||||
drag(&mut u, from, -30.0, Modifiers::NONE);
|
||||
let [.., alpha] = rgba(&u);
|
||||
assert!(alpha < a0, "alpha {a0} → {alpha}");
|
||||
assert_eq!(undo_steps(&u), steps + 2, "alpha");
|
||||
}
|
||||
|
||||
/// Asserts scrubbing a prototype delay sets the value once when released and adds one undo
|
||||
/// step.
|
||||
#[test]
|
||||
fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() {
|
||||
let mut s = Session::new();
|
||||
@@ -505,7 +509,7 @@ fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
let steps = undo_steps(&u);
|
||||
let from = lead(&u, Id::new(("proto_delay", f1, 0usize)));
|
||||
let from = lead(&u, Id::unique(("proto_delay", f1, 0usize)));
|
||||
drag(&mut u, from, 30.0, Modifiers::NONE);
|
||||
let ms = match u.app.session.doc().unwrap().node(f1).unwrap().interactions[0].trigger {
|
||||
figcraft_doc::proto::Trigger::AfterDelay { ms } => ms,
|
||||
@@ -514,13 +518,14 @@ fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() {
|
||||
assert_eq!((ms, undo_steps(&u)), (800, steps + 1));
|
||||
}
|
||||
|
||||
/// Asserts scrubbing the scale panel's width scales the layer live in one undo step.
|
||||
#[test]
|
||||
fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() {
|
||||
let (mut u, a, _) = setup();
|
||||
u.app.run("tool.set", json!({"tool": "scale"}));
|
||||
u.frame(vec![]);
|
||||
let steps = undo_steps(&u);
|
||||
let from = lead(&u, Id::new("scale_w"));
|
||||
let from = lead(&u, Id::unique("scale_w"));
|
||||
drag(&mut u, from, 20.0, Modifiers::NONE);
|
||||
let (_, _, w, h) = geom(&u, a);
|
||||
assert_eq!((w, h, undo_steps(&u)), (60.0, 45.0, steps + 1), "40×30 scaled to a width of 60");
|
||||
@@ -550,14 +555,14 @@ fn the_glass_light_intensity_field_has_a_label() {
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("effect.add", &json!({"type": "glass"})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.ctx.data_mut(|d| d.insert_temp(Id::new(("fx_open", 0usize)), true));
|
||||
u.ctx.data_mut(|d| d.insert_temp(Id::unique(("fx_open", 0usize)), true));
|
||||
u.frame(vec![]);
|
||||
let shapes = u.frame_output(vec![]).shapes;
|
||||
let lead = u.ctx.read_response(Id::new(("fx_gi", 0usize)).with("lead")).expect("the intensity field is shown").rect;
|
||||
let lead = u.ctx.read_response(Id::unique(("fx_gi", 0usize)).with("lead")).expect("the intensity field is shown").rect;
|
||||
let labels: Vec<(String, bool)> =
|
||||
painted_texts(&shapes).into_iter().filter(|(_, _, at)| lead.contains(at.center())).map(|(text, cut, _)| (text, cut)).collect();
|
||||
assert_eq!(labels, [("Intensity".to_string(), false)], "labelled, and not cut short");
|
||||
let field = u.ctx.read_response(Id::new(("fx_gi", 0usize)).with("field")).expect("its value").rect;
|
||||
let field = u.ctx.read_response(Id::unique(("fx_gi", 0usize)).with("field")).expect("its value").rect;
|
||||
assert_eq!(field.max.x - lead.min.x, GLASS_FIELD_W, "the width the next test measures against");
|
||||
}
|
||||
|
||||
@@ -584,13 +589,15 @@ fn the_intensity_label_fits_whole_in_every_language() {
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// Asserts a label too long for its number field is elided before the value, while the value
|
||||
/// stays whole and both remain inside the field.
|
||||
#[test]
|
||||
fn a_label_too_long_for_its_field_ends_in_an_ellipsis_before_the_value() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut field = egui::Rect::NOTHING;
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
let lead = crate::widgets::Lead::Text("A label much wider than its field");
|
||||
let _ = crate::widgets::number_field(ui, Id::new("long"), lead, Some(80.0), "%", 60.0, 1.0);
|
||||
let _ = crate::widgets::number_field(ui, Id::unique("long"), lead, Some(80.0), "%", 60.0, 1.0);
|
||||
field = ui.min_rect();
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
@@ -623,7 +630,7 @@ fn grid_ui(grid: serde_json::Value) -> (Ui, NodeId) {
|
||||
s.execute("node.set", &json!({"ids": [f], "props": {"grids": [grid]}})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [f]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.ctx.data_mut(|d| d.insert_temp(Id::new(("grid_open", 0usize)), true));
|
||||
u.ctx.data_mut(|d| d.insert_temp(Id::unique(("grid_open", 0usize)), true));
|
||||
u.frame(vec![]);
|
||||
(u, f)
|
||||
}
|
||||
@@ -633,6 +640,7 @@ fn grid_ui(grid: serde_json::Value) -> (Ui, NodeId) {
|
||||
fn scroll_to(u: &mut Ui, id: Id) {
|
||||
let wheel = Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
delta: egui::vec2(0.0, -300.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Modifiers::NONE,
|
||||
@@ -655,7 +663,7 @@ fn scroll_to(u: &mut Ui, id: Id) {
|
||||
/// placeholder said 0 everywhere, which turned the gap's mode off.
|
||||
#[test]
|
||||
fn typing_auto_is_zero_only_where_zero_is_the_automatic_value() {
|
||||
let count_id = Id::new(("grid_field", "count", 0usize));
|
||||
let count_id = Id::unique(("grid_field", "count", 0usize));
|
||||
let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "min", "count": 3, "size": 8, "gutter": 10}));
|
||||
let count = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().grids[0].count;
|
||||
scroll_to(&mut u, count_id);
|
||||
@@ -665,7 +673,7 @@ fn typing_auto_is_zero_only_where_zero_is_the_automatic_value() {
|
||||
assert_eq!(count(&u), 4, "and a number is that count");
|
||||
// A stretched grid works its width out from the frame: the field shows Auto and takes no edit.
|
||||
let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "stretch", "count": 3, "size": 0}));
|
||||
let size_id = Id::new(("grid_field", "size", 0usize));
|
||||
let size_id = Id::unique(("grid_field", "size", 0usize));
|
||||
scroll_to(&mut u, size_id);
|
||||
type_at(&mut u, size_id, "40");
|
||||
assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids[0].size, 0.0, "a stretched grid's width isn't set");
|
||||
@@ -683,6 +691,336 @@ fn typing_auto_is_zero_only_where_zero_is_the_automatic_value() {
|
||||
u.frame(vec![]);
|
||||
let gap = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().gap;
|
||||
let before = gap(&u);
|
||||
type_at(&mut u, Id::new("al_gap"), "Auto");
|
||||
type_at(&mut u, Id::unique("al_gap"), "Auto");
|
||||
assert_eq!(gap(&u), before, "the word isn't a value where Auto is a mode");
|
||||
}
|
||||
|
||||
/// Save starts after the field commits, even if the next raw input releases a tab click first.
|
||||
#[test]
|
||||
fn a_deferred_field_save_keeps_its_document_when_the_next_input_switches_tabs() {
|
||||
for (save_as, same_batch, discard) in
|
||||
[(false, false, false), (true, false, false), (false, true, false), (true, true, false), (false, true, true), (true, true, true)]
|
||||
{
|
||||
let (mut u, a, _) = setup();
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-deferred-field-tab-{}-{save_as}-{same_batch}-{discard}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (path, copy, other) = (dir.join("a.figcraft"), dir.join("copy.figcraft"), dir.join("b.figcraft"));
|
||||
u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let owner = u.app.session.ds().unwrap().uid;
|
||||
u.app.session.execute("file.new", &json!({"name": "Deferred B"})).unwrap();
|
||||
u.app.session.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 30, "height": 30})).unwrap();
|
||||
u.app.session.execute("file.saveAs", &json!({"path": other.to_string_lossy()})).unwrap();
|
||||
u.app.session.execute("shape.rectangle", &json!({"x": 300, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let other_uid = u.app.session.ds().unwrap().uid;
|
||||
let other_name = u.app.session.doc().unwrap().name.clone();
|
||||
let other_bytes = std::fs::read(&other).unwrap();
|
||||
let index = u.app.session.docs.iter().position(|d| d.uid == owner).unwrap();
|
||||
u.app.session.execute("doc.switch", &json!({"index": index})).unwrap();
|
||||
let asked = std::rc::Rc::new(std::cell::Cell::new(0));
|
||||
u.app.services = Box::new(SaveAsPicker { path: Some(copy.clone()), asked: asked.clone() });
|
||||
u.frame(vec![]);
|
||||
let output = u.frame_output(vec![]);
|
||||
let nodes = output.platform_output.accesskit_update.unwrap().nodes;
|
||||
let tab = nodes
|
||||
.iter()
|
||||
.map(|(_, node)| node)
|
||||
.find(|node| node.role() == Role::Tab && node.label() == Some(other_name.as_str()))
|
||||
.expect("the actual B tab");
|
||||
let bounds = tab.bounds().unwrap();
|
||||
let at = pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32);
|
||||
let (field_at, _) = field(&mut u, "X");
|
||||
click(&mut u, field_at);
|
||||
assert!(crate::a11y::field_focused(&u.ctx));
|
||||
assert!(u.ctx.text_edit_focused(), "the actual field editor owns raw text");
|
||||
assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te")), "X owns the keyboard");
|
||||
if !same_batch {
|
||||
u.frame(vec![Event::Text("77".into())]);
|
||||
assert_eq!(u.ctx.data(|d| d.get_temp::<String>(Id::unique("pos_x").with("editing"))), Some("77".into()), "raw text reached X's draft");
|
||||
}
|
||||
assert_eq!(geom(&u, a).0, 10.0, "the field has not committed before the shortcut");
|
||||
let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) };
|
||||
if save_as {
|
||||
modifiers = modifiers.plus(Modifiers::SHIFT);
|
||||
}
|
||||
let button = |pressed| Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
// Pressing the tab relinquishes the real field; its release switches documents in the
|
||||
// next frame, before shortcuts::handle consumes the deferred request.
|
||||
let mut events = vec![
|
||||
Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers },
|
||||
Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers },
|
||||
Event::PointerMoved(at),
|
||||
button(true),
|
||||
];
|
||||
if same_batch {
|
||||
events.insert(0, Event::Text("77".into()));
|
||||
}
|
||||
if discard {
|
||||
events.insert(0, Event::ModifiersChanged(modifiers));
|
||||
u.frame_raw_with_discard(egui::RawInput { events, ..Default::default() });
|
||||
} else {
|
||||
u.frame_with(events, modifiers);
|
||||
}
|
||||
assert!(u.app.ui.after_field.is_some(), "the real shortcut deferred a save");
|
||||
if !discard {
|
||||
assert!(
|
||||
!u.ctx.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { key: Key::S, pressed: true, .. }))),
|
||||
"the save press was consumed once"
|
||||
);
|
||||
}
|
||||
// A discarded presented pass sees the original RawInput but does not dispatch it again;
|
||||
// pending/picker/write assertions below also prove that pass did not duplicate the save.
|
||||
assert_eq!(u.app.session.ds().unwrap().uid, owner, "press alone did not switch tabs");
|
||||
assert_eq!(geom(&u, a).0, 77.0, "the original field committed before delivery");
|
||||
u.frame(vec![button(false)]);
|
||||
assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the real tab release switched to B");
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "saving A restores the front tab B");
|
||||
assert!(u.app.session.ds().unwrap().is_dirty(), "B was not saved");
|
||||
assert_eq!(std::fs::read(&other).unwrap(), other_bytes, "B's file was not overwritten");
|
||||
assert_eq!(asked.get(), usize::from(save_as), "only the original Save as asks for a path");
|
||||
assert_eq!(saved_x(if save_as { © } else { &path }, a), 77.0, "A's typed field is saved");
|
||||
assert_eq!(saved_x(&path, a), if save_as { 10.0 } else { 77.0 });
|
||||
assert!(u.app.ui.after_field.is_none());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// Asserts a deferred field save does not retarget a closed, moved or foreign owner: the other
|
||||
/// file stays untouched, Save as is not opened, and the stale request is consumed once.
|
||||
#[test]
|
||||
fn a_deferred_field_save_does_not_retarget_a_closed_moved_or_foreign_owner() {
|
||||
for change in ["closed", "moved", "foreign"] {
|
||||
for save_as in [false, true] {
|
||||
let (mut u, a, _) = setup();
|
||||
u.ctx.set_embed_viewports(false);
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-deferred-field-invalid-{}-{change}-{save_as}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (path, other, copy) = (dir.join("a.figcraft"), dir.join("b.figcraft"), dir.join("copy.figcraft"));
|
||||
u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let owner = u.app.session.ds().unwrap().uid;
|
||||
u.app.session.execute("file.new", &json!({"name": "Deferred B"})).unwrap();
|
||||
u.app.session.execute("file.saveAs", &json!({"path": other.to_string_lossy()})).unwrap();
|
||||
u.app.session.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let other_uid = u.app.session.ds().unwrap().uid;
|
||||
let other_bytes = std::fs::read(&other).unwrap();
|
||||
let index = u.app.session.docs.iter().position(|d| d.uid == owner).unwrap();
|
||||
u.app.session.execute("doc.switch", &json!({"index": index})).unwrap();
|
||||
let asked = std::rc::Rc::new(std::cell::Cell::new(0));
|
||||
u.app.services = Box::new(SaveAsPicker { path: Some(copy.clone()), asked: asked.clone() });
|
||||
let (at, _) = field(&mut u, "X");
|
||||
click(&mut u, at);
|
||||
assert!(crate::a11y::field_focused(&u.ctx));
|
||||
assert!(u.ctx.text_edit_focused(), "the actual field editor owns raw text");
|
||||
assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te")), "X owns the keyboard");
|
||||
u.frame(vec![Event::Text("77".into())]);
|
||||
assert_eq!(u.ctx.data(|d| d.get_temp::<String>(Id::unique("pos_x").with("editing"))), Some("77".into()), "raw text reached X's draft");
|
||||
assert_eq!(geom(&u, a).0, 10.0, "typed field text is not committed before Save");
|
||||
let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) };
|
||||
if save_as {
|
||||
modifiers = modifiers.plus(Modifiers::SHIFT);
|
||||
}
|
||||
u.frame_with(
|
||||
vec![
|
||||
Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers },
|
||||
Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers },
|
||||
],
|
||||
modifiers,
|
||||
);
|
||||
assert!(u.app.ui.after_field.is_some());
|
||||
assert_eq!(geom(&u, a).0, 77.0, "field commit is still before the save");
|
||||
let owner_index = u.app.session.docs.iter().position(|d| d.uid == owner).unwrap();
|
||||
if change == "closed" {
|
||||
u.app.session.execute("file.close", &json!({"index": owner_index, "force": true})).unwrap();
|
||||
} else if change == "moved" {
|
||||
let window = crate::windows::open(&mut u.app, &u.ctx, Some(owner_index), None, None).unwrap();
|
||||
assert_ne!(window, 0);
|
||||
assert_eq!(u.app.windows.of(owner), window);
|
||||
} else {
|
||||
// An impossible/misrouted pane must fail closed too; no real window has this id.
|
||||
u.app.windows.current = u64::MAX;
|
||||
}
|
||||
let other_index = u.app.session.docs.iter().position(|d| d.uid == other_uid).unwrap();
|
||||
u.app.session.execute("doc.switch", &json!({"index": other_index})).unwrap();
|
||||
if change == "foreign" {
|
||||
// Exercise the actual consumer without begin() resetting this deliberate invalid
|
||||
// scope; the valid input/field producer above was the real app path.
|
||||
// Match this direct pass and the harness clock; egui's default predicted_dt is f32.
|
||||
let time = u.ctx.input(|i| i.time) + 1.0 / 60.0;
|
||||
u.advance_clock(time);
|
||||
u.ctx
|
||||
.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| {
|
||||
crate::shortcuts::handle(&mut u.app, ui.ctx(), false, Default::default())
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
u.app.windows.current = 0;
|
||||
} else {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
for _ in 0..2 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
assert_eq!(u.app.session.ds().unwrap().uid, other_uid);
|
||||
assert!(u.app.session.ds().unwrap().is_dirty(), "stale delivery must not save B");
|
||||
assert_eq!(std::fs::read(&other).unwrap(), other_bytes);
|
||||
assert_eq!(saved_x(&path, a), 10.0, "a cancelled owner is not saved");
|
||||
assert_eq!(asked.get(), 0, "a stale Save as must not open for B");
|
||||
assert!(!copy.exists());
|
||||
assert!(u.app.ui.after_field.is_none(), "consume stale requests once");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A save prepared for A must not force its cached X editor into B when routing changes the
|
||||
/// presented document before the fields draw, even though both panels use the same widget ID.
|
||||
#[test]
|
||||
fn a_prepared_field_save_cannot_commit_into_a_same_pass_replacement_document() {
|
||||
for route in ["control", "tab"] {
|
||||
for save_as in [false, true] {
|
||||
for discard in [false, true] {
|
||||
prepared_save_cannot_enter_a_replacement(route, save_as, discard);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One route/save-as/discard combination of the same-pass replacement test: a save prepared for A
|
||||
/// must not commit A's cached X editor into B when routing presents B before the fields draw,
|
||||
/// even though both panels use the same widget ID.
|
||||
fn prepared_save_cannot_enter_a_replacement(route: &str, save_as: bool, discard: bool) {
|
||||
let (mut u, a, _) = setup();
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-field-save-owner-{}-{route}-{save_as}-{discard}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (path, other, copy) = (dir.join("a.figcraft"), dir.join("b.figcraft"), dir.join("copy.figcraft"));
|
||||
u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let owner = u.app.session.ds().unwrap().uid;
|
||||
let owner_bytes = std::fs::read(&path).unwrap();
|
||||
u.app.session.execute("file.new", &json!({"name": "Same-pass B"})).unwrap();
|
||||
let b = id(u.app.session.execute("shape.rectangle", &json!({"x": 200, "y": 40, "width": 30, "height": 30})).unwrap());
|
||||
u.app.session.execute("file.saveAs", &json!({"path": other.to_string_lossy()})).unwrap();
|
||||
u.app.session.execute("shape.rectangle", &json!({"x": 300, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
u.app.session.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
let other_index = u.app.session.active;
|
||||
let other_uid = u.app.session.ds().unwrap().uid;
|
||||
let other_name = u.app.session.doc().unwrap().name.clone();
|
||||
let other_model = u.app.session.ds().unwrap().doc.clone();
|
||||
let before_switch_revision = u.app.session.ds().unwrap().revision;
|
||||
let other_history = u.app.session.undo_depth();
|
||||
let other_selection = u.app.session.ds().unwrap().selection.clone();
|
||||
let other_dirty = u.app.session.ds().unwrap().is_dirty();
|
||||
let other_bytes = std::fs::read(&other).unwrap();
|
||||
assert!(other_dirty, "B has independent unsaved content");
|
||||
let owner_index = u.app.session.docs.iter().position(|d| d.uid == owner).unwrap();
|
||||
u.app.session.execute("doc.switch", &json!({"index": owner_index})).unwrap();
|
||||
// Every command bumps the outgoing document's revision, even a nonediting switch.
|
||||
let other_revision = u.app.session.docs.iter().find(|d| d.uid == other_uid).unwrap().revision;
|
||||
assert_eq!(other_revision, before_switch_revision + 1, "the setup switch advances only B's command revision");
|
||||
let asked = std::rc::Rc::new(std::cell::Cell::new(0));
|
||||
u.app.services = Box::new(SaveAsPicker { path: Some(copy.clone()), asked: asked.clone() });
|
||||
let output = u.frame_output(vec![]);
|
||||
let nodes = output.platform_output.accesskit_update.unwrap().nodes;
|
||||
let tab = nodes
|
||||
.iter()
|
||||
.map(|(_, node)| node)
|
||||
.find(|node| node.role() == Role::Tab && node.label() == Some(other_name.as_str()))
|
||||
.expect("the actual B tab");
|
||||
let bounds = tab.bounds().unwrap();
|
||||
let tab_at = pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32);
|
||||
let (at, _) = field(&mut u, "X");
|
||||
click(&mut u, at);
|
||||
assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te")));
|
||||
assert!(u.ctx.text_edit_focused());
|
||||
u.frame(vec![Event::Text("77".into())]);
|
||||
assert_eq!(u.ctx.data(|d| d.get_temp::<String>(Id::unique("pos_x").with("editing"))), Some("77".into()));
|
||||
assert_eq!(geom(&u, a).0, 10.0, "A's raw draft is not committed yet");
|
||||
assert_eq!(u.app.session.ds().unwrap().uid, owner, "the save begins with A presented");
|
||||
let reply = if route == "control" {
|
||||
let (reply, rx) = std::sync::mpsc::channel();
|
||||
u.app.control_queue.push(crate::ControlRequest {
|
||||
id: json!("same-pass-switch"),
|
||||
method: "engine.execute".into(),
|
||||
params: json!({"command": "doc.switch", "params": {"index": other_index}, "window": 0}),
|
||||
reply,
|
||||
state: Default::default(),
|
||||
});
|
||||
Some(rx)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) };
|
||||
if save_as {
|
||||
modifiers = modifiers.plus(Modifiers::SHIFT);
|
||||
}
|
||||
let mut events = vec![
|
||||
Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers },
|
||||
Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers },
|
||||
];
|
||||
if route == "tab" {
|
||||
events.push(Event::PointerMoved(tab_at));
|
||||
for pressed in [true, false] {
|
||||
events.push(Event::PointerButton { pos: tab_at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
|
||||
}
|
||||
}
|
||||
if discard {
|
||||
events.insert(0, Event::ModifiersChanged(modifiers));
|
||||
u.frame_raw_with_discard(egui::RawInput { events, ..Default::default() });
|
||||
} else {
|
||||
u.frame_with(events, modifiers);
|
||||
}
|
||||
if let Some(reply) = reply {
|
||||
let reply = reply.try_recv().expect("real control::drain acknowledged doc.switch in this pass");
|
||||
assert!(reply.result.is_ok(), "{:?}", reply.result);
|
||||
}
|
||||
let unchanged = |u: &Ui| {
|
||||
assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the real same-pass route presents B");
|
||||
assert_eq!(geom(u, b), (200.0, 40.0, 30.0, 30.0), "A's draft cannot enter B's field: route={route}, save_as={save_as}, discard={discard}");
|
||||
assert!(std::sync::Arc::ptr_eq(&u.app.session.ds().unwrap().doc, &other_model), "B's model is untouched");
|
||||
assert_eq!(u.app.session.ds().unwrap().revision, other_revision);
|
||||
assert_eq!(u.app.session.undo_depth(), other_history, "B's undo/redo history is unchanged");
|
||||
assert_eq!(u.app.session.ds().unwrap().selection, other_selection);
|
||||
assert_eq!(u.app.session.ds().unwrap().is_dirty(), other_dirty);
|
||||
assert_eq!(std::fs::read(&other).unwrap(), other_bytes);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), owner_bytes);
|
||||
assert_eq!(u.app.session.docs.iter().find(|d| d.uid == owner).unwrap().doc.world_bounds(a).unwrap().x0, 10.0);
|
||||
assert_eq!(asked.get(), 0, "the cancelled request never asks for a path");
|
||||
assert!(!copy.exists());
|
||||
assert!(u.app.ui.after_field.is_none(), "cancel before late fallback or queueing");
|
||||
};
|
||||
unchanged(&u);
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
unchanged(&u);
|
||||
}
|
||||
// A cancelled draft is not a permanent veto: B accepts a later real field edit
|
||||
// and saves its own value through the same focused-field shortcut path.
|
||||
let (at, _) = field(&mut u, "X");
|
||||
click(&mut u, at);
|
||||
assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te")));
|
||||
u.frame(vec![Event::Text("88".into())]);
|
||||
assert_eq!(geom(&u, b).0, 200.0, "fresh B text still waits for a normal commit");
|
||||
assert_eq!(u.ctx.data(|d| d.get_temp::<String>(Id::unique("pos_x").with("editing"))), Some("88".into()));
|
||||
u.frame_with(
|
||||
vec![
|
||||
Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers },
|
||||
Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers },
|
||||
],
|
||||
modifiers,
|
||||
);
|
||||
assert_eq!(geom(&u, b).0, 88.0, "a fresh B field commits normally");
|
||||
assert!(u.app.ui.after_field.is_some());
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
assert_eq!(u.app.session.ds().unwrap().uid, other_uid);
|
||||
assert_eq!(saved_x(if save_as { © } else { &other }, b), 88.0, "a later valid save belongs to B");
|
||||
if save_as {
|
||||
assert_eq!(std::fs::read(&other).unwrap(), other_bytes);
|
||||
}
|
||||
assert_eq!(std::fs::read(&path).unwrap(), owner_bytes, "A stays unsaved");
|
||||
assert_eq!(asked.get(), usize::from(save_as));
|
||||
assert!(u.app.ui.after_field.is_none());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ fn key(style: &TextStyle, text: &str, ppp: f32) -> u64 {
|
||||
pub fn texture(ctx: &egui::Context, style: &TextStyle, text: &str, budget: &mut usize) -> Option<egui::TextureHandle> {
|
||||
let ppp = ctx.pixels_per_point();
|
||||
let k = key(style, text, ppp);
|
||||
let cache = ctx.data_mut(|d| d.get_temp_mut_or_default::<Cache>(Id::new("font_previews")).clone());
|
||||
let cache = ctx.data_mut(|d| d.get_temp_mut_or_default::<Cache>(Id::unique("font_previews")).clone());
|
||||
if let Some(t) = cache.0.lock().ok()?.get(&k) {
|
||||
return t.clone();
|
||||
}
|
||||
|
||||
@@ -58,10 +58,16 @@ pub fn turn_since(painter: &Painter, from: ShapeIdx, pivot: Pos2, angle: f32) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Recursively rotate shape geometry by `rot`/`angle` about `pivot`: leaves
|
||||
/// no-op/callback/band shapes alone, rotates points and centers, adds the angle to
|
||||
/// ellipses/rects/text, and rotates meshes.
|
||||
fn turn(shape: &mut Shape, rot: Rot2, angle: f32, pivot: Pos2) {
|
||||
let at = |p: &mut Pos2| *p = pivot + rot * (*p - pivot);
|
||||
match shape {
|
||||
Shape::Noop | Shape::Callback(_) => {}
|
||||
// Bands are not drawn by this UI; if one ever arrives, rotate it via its own `angle`
|
||||
// rather than approximating the samples as points.
|
||||
Shape::Band(_) => {}
|
||||
Shape::Vec(v) => v.iter_mut().for_each(|s| turn(s, rot, angle, pivot)),
|
||||
Shape::Circle(c) => at(&mut c.center),
|
||||
Shape::Ellipse(e) => {
|
||||
@@ -166,7 +172,7 @@ pub fn rename_field(app: &mut FigApp, ui: &mut egui::Ui) {
|
||||
let font = regular(11.0);
|
||||
let text_w = ui.painter().layout_no_wrap(name.clone(), font.clone(), Color32::WHITE).size().x;
|
||||
let rect = Rect::from_min_size(pos2(area.min.x, area.max.y - 16.0), vec2((text_w + 24.0).max(area.width()).clamp(80.0, 480.0), 16.0));
|
||||
let (done, open) = crate::widgets::rename_field(ui, egui::Id::new(("frame_label_rename", r.id)), &name, rect, font, r.start);
|
||||
let (done, open) = crate::widgets::rename_field(ui, egui::Id::unique(("frame_label_rename", r.id)), &name, rect, font, r.start);
|
||||
app.ui.rename_label = open.then_some(crate::layers::Rename { id: r.id, start: false });
|
||||
if let Some(new) = done {
|
||||
app.run("object.rename", serde_json::json!({"ids": [r.id], "name": new}));
|
||||
|
||||
@@ -10,6 +10,8 @@ use crate::icons::{self, Icon};
|
||||
use crate::theme::{Tokens, regular, semibold};
|
||||
use crate::widgets::{self, PAD};
|
||||
|
||||
/// Draw the expandable frame-preset categories; choosing a preset runs `frame.create`,
|
||||
/// switches to the Move tool and zooms to the new selection.
|
||||
pub fn panel(app: &mut FigApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
widgets::section_header(ui, tr("Frame"), &[], false);
|
||||
@@ -22,7 +24,7 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) {
|
||||
}
|
||||
let mut chosen = None;
|
||||
for cat in cats {
|
||||
let open_id = Id::new(("preset_cat", cat));
|
||||
let open_id = Id::unique(("preset_cat", cat));
|
||||
// The first category starts open.
|
||||
let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(cat == "Phone");
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click());
|
||||
|
||||
@@ -49,13 +49,14 @@ struct Placed {
|
||||
line: Option<[Point; 2]>,
|
||||
}
|
||||
|
||||
/// Stable egui id for guide dragging.
|
||||
fn drag_id() -> Id {
|
||||
Id::new("guide_drag")
|
||||
Id::unique("guide_drag")
|
||||
}
|
||||
|
||||
/// The dragged guide as it would land.
|
||||
fn preview_id() -> Id {
|
||||
Id::new("guide_preview")
|
||||
Id::unique("guide_preview")
|
||||
}
|
||||
|
||||
/// The page's guides and those of its top-level frames (frames last, so they're found first).
|
||||
|
||||
@@ -67,8 +67,9 @@ fn crop_corners(app: &FigApp, id: NodeId, r: (f64, f64, f64, f64)) -> Option<[Po
|
||||
])
|
||||
}
|
||||
|
||||
/// Stable egui id for handle dragging.
|
||||
fn drag_id() -> Id {
|
||||
Id::new("figcraft_handle_drag")
|
||||
Id::unique("figcraft_handle_drag")
|
||||
}
|
||||
|
||||
/// The gradient being edited (single node, picker open on a gradient paint).
|
||||
|
||||
+19
-10
@@ -188,6 +188,8 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the Home sidebar: app icon and wordmark, file/template search (Escape clears it)
|
||||
/// and section tabs; choosing a section clears the file query.
|
||||
fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) {
|
||||
let mut y = left.min.y + 20.0;
|
||||
crate::theme::paint_app_icon(ui, Rect::from_min_size(pos2(left.min.x + 12.0, y - 4.0), vec2(32.0, 32.0)));
|
||||
@@ -195,8 +197,13 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) {
|
||||
y += 52.0;
|
||||
let search = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, widgets::FIELD_H));
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(search), |ui| {
|
||||
let response =
|
||||
widgets::text_input(ui, Id::new("home_file_search"), &mut app.ui.home_view.file_query, search.width(), tr("Search files and templates"));
|
||||
let response = widgets::text_input(
|
||||
ui,
|
||||
Id::unique("home_file_search"),
|
||||
&mut app.ui.home_view.file_query,
|
||||
search.width(),
|
||||
tr("Search files and templates"),
|
||||
);
|
||||
a11y::text(&response, tr("Search files and templates"), &app.ui.home_view.file_query);
|
||||
if (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.home_view.file_query.clear();
|
||||
@@ -205,7 +212,7 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) {
|
||||
y += widgets::FIELD_H + 12.0;
|
||||
for (section, icon, label) in Section::ALL {
|
||||
let r = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, 32.0));
|
||||
let resp = ui.interact(r, ui.id().with(("home_section", label)), Sense::click());
|
||||
let resp = ui.interact(r, ui.scope_id().with(("home_section", label)), Sense::click());
|
||||
let current = app.ui.home_view.file_query.trim().is_empty() && app.ui.home_view.section == section;
|
||||
a11y::tab(&resp, tr(label), current);
|
||||
if current || resp.hovered() {
|
||||
@@ -250,7 +257,7 @@ fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
detail_size: 11.0,
|
||||
detail_ink: t.text_secondary,
|
||||
};
|
||||
if listed(ui, t, ui.id().with(("search_online", &f.server, &f.file)), 36.0, &row) {
|
||||
if listed(ui, t, ui.scope_id().with(("search_online", &f.server, &f.file)), 36.0, &row) {
|
||||
open_online = Some(f.clone());
|
||||
}
|
||||
}
|
||||
@@ -385,7 +392,7 @@ fn open_files(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize {
|
||||
detail_size: 11.0,
|
||||
detail_ink: t.text_secondary,
|
||||
};
|
||||
if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) {
|
||||
if listed(ui, t, ui.scope_id().with(("open", d.uid)), 36.0, &row) {
|
||||
switch = Some(i);
|
||||
}
|
||||
}
|
||||
@@ -423,7 +430,7 @@ fn getting_started(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
let close = Rect::from_min_size(pos2(rect.max.x - 36.0, rect.min.y + 12.0), vec2(24.0, 24.0));
|
||||
if widgets::icon_button_at(
|
||||
ui,
|
||||
ui.id().with("tips_close"),
|
||||
ui.scope_id().with("tips_close"),
|
||||
close,
|
||||
Icon::Close,
|
||||
tr("Hide these tips (Getting started in the Help menu shows them again)"),
|
||||
@@ -470,10 +477,12 @@ fn matches(template: &Template, query: &str, kind: Option<Kind>) -> bool {
|
||||
|| has(template.size))
|
||||
}
|
||||
|
||||
/// Draw template search and kind filter, show no-match guidance and a "Show all templates"
|
||||
/// button that clears the query and kind, and lay matching templates out in a grid.
|
||||
fn templates(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
heading(ui, t, tr("Templates"));
|
||||
ui.horizontal(|ui| {
|
||||
let search = widgets::text_input(ui, Id::new("home_template_search"), &mut app.ui.home_view.query, 260.0, tr("Search templates"));
|
||||
let search = widgets::text_input(ui, Id::unique("home_template_search"), &mut app.ui.home_view.query, 260.0, tr("Search templates"));
|
||||
a11y::text(&search, tr("Search templates"), &app.ui.home_view.query);
|
||||
let labels: Vec<&str> = std::iter::once(tr("All")).chain(Kind::ALL.iter().map(|k| tr(k.label()))).collect();
|
||||
let active = app.ui.home_view.kind.and_then(|k| Kind::ALL.iter().position(|x| *x == k)).map_or(0, |i| i + 1);
|
||||
@@ -506,7 +515,7 @@ fn templates(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
fn template_grid(app: &mut FigApp, ui: &mut Ui, t: &Tokens, list: &[&Template]) {
|
||||
let columns = (((ui.available_width() + GAP) / (CARD_W + GAP)).floor() as usize).max(1);
|
||||
let dark = ui.visuals().dark_mode;
|
||||
let mut thumbs: Thumbs = ui.data(|d| d.get_temp(Id::new("template_thumbs"))).unwrap_or_default();
|
||||
let mut thumbs: Thumbs = ui.data(|d| d.get_temp(Id::unique("template_thumbs"))).unwrap_or_default();
|
||||
let mut chosen = None;
|
||||
let mut rendered = false;
|
||||
for row in list.chunks(columns) {
|
||||
@@ -529,7 +538,7 @@ fn template_grid(app: &mut FigApp, ui: &mut Ui, t: &Tokens, list: &[&Template])
|
||||
});
|
||||
ui.add_space(GAP);
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(Id::new("template_thumbs"), thumbs));
|
||||
ui.data_mut(|d| d.insert_temp(Id::unique("template_thumbs"), thumbs));
|
||||
if let Some(template) = chosen
|
||||
&& app.run("file.newFromTemplate", json!({"template": template.id, "name": tr(template.name)})).is_some()
|
||||
{
|
||||
@@ -601,6 +610,6 @@ fn thumbnail(ctx: &egui::Context, template: &Template, background: figcraft_colo
|
||||
/// Tests: the templates whose pictures are cached for the light or dark theme.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn cached_thumbnails(ctx: &egui::Context, dark: bool) -> Vec<&'static str> {
|
||||
let thumbs: Thumbs = ctx.data(|d| d.get_temp(Id::new("template_thumbs"))).unwrap_or_default();
|
||||
let thumbs: Thumbs = ctx.data(|d| d.get_temp(Id::unique("template_thumbs"))).unwrap_or_default();
|
||||
thumbs.0.iter().filter(|((_, d), thumb)| *d == dark && thumb.is_some()).map(|((id, _), _)| *id).collect()
|
||||
}
|
||||
|
||||
@@ -685,7 +685,7 @@ fn heading(ui: &mut Ui, t: &Tokens, text: &str) {
|
||||
|
||||
/// The id of `path`'s card or row (stable, so the keyboard stays on it as the order changes).
|
||||
fn card_id(path: &str) -> Id {
|
||||
Id::new(("home_recent_file", path))
|
||||
Id::unique(("home_recent_file", path))
|
||||
}
|
||||
|
||||
/// Clicks, keys and the context menu on a card or row: a click selects, a double-click or Enter
|
||||
@@ -909,7 +909,7 @@ fn list(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6
|
||||
let x0 = if i == 0 { header.min.x } else { header.min.x + date_x(i) };
|
||||
let x1 = if i + 1 < columns.len() { header.min.x + date_x(i + 1) } else { header.max.x };
|
||||
let cell = Rect::from_min_max(pos2(x0, header.min.y), pos2(x1, header.max.y));
|
||||
let resp = ui.interact(cell, Id::new(("home_recents_header", key.id())), Sense::click());
|
||||
let resp = ui.interact(cell, Id::unique(("home_recents_header", key.id())), Sense::click());
|
||||
a11y::button(&resp, &trf("Sort by {column}", &[("column", label)]));
|
||||
let on = sort.key == *key;
|
||||
if resp.hovered() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user