From 2ef3ebb45d225217d1031d32e424bb61f15721aa Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Sun, 25 Oct 2026 05:17:15 +0100 Subject: [PATCH 1/7] E21: require release font coverage and stabilize capture deadline tests --- .github/workflows/ci.yml | 9 +- .github/workflows/freebsd.yml | 2 +- .github/workflows/release.yml | 9 +- ATTRIBUTION.md | 7 +- ROADMAP.md | 5 +- crates/text/build.rs | 173 +++++++++++++++--- crates/text/tests/build_input.rs | 4 + crates/text/tests/fixtures/latin-families.txt | 52 ++++++ crates/ui-egui/src/idle_tests.rs | 7 +- crates/ui-egui/src/windows_tests.rs | 4 +- docs/egui-037-local-followups.md | 51 ++++-- docs/egui-037-migration-plan.md | 4 + docs/releasing.md | 13 +- 13 files changed, 274 insertions(+), 66 deletions(-) create mode 100644 crates/text/tests/build_input.rs create mode 100644 crates/text/tests/fixtures/latin-families.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7abae66a..b79af502 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: b076417b5880bd4154bd22f00695b1d28c6bea83 + # 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 diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index e030b167..80adf16a 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -32,4 +32,4 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: run-package-check: true - craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b37ee39..f93219fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: b076417b5880bd4154bd22f00695b1d28c6bea83 + # 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: diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index bf6fdece..46b789a8 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -26,9 +26,12 @@ 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. diff --git a/ROADMAP.md b/ROADMAP.md index a27cc4df..0af8cbac 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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; 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,8 @@ 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** · 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)). - **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged. @@ -329,6 +331,7 @@ 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 | 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 | diff --git a/crates/text/build.rs b/crates/text/build.rs index f46547cd..ecca6a45 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -1,6 +1,7 @@ //! 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; @@ -10,30 +11,13 @@ fn main() { 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"] { - // Presentation faces are an optional extension of craft-fonts. Required mode - // validates the core manifest and every extension that is actually supplied. - let path = dir.join(manifest); - if manifest == "fonts/latin-manifest.txt" && !path.exists() { - watch(&path); - continue; - } - 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"); @@ -42,13 +26,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 { + 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 { @@ -56,20 +72,29 @@ fn watch(manifest: &std::path::Path) { } } -fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result { +struct Catalog { + entries: String, + faces: Vec<(String, String)>, +} + +fn craft_fonts(dir: &std::path::Path, manifest_rel: &str, wasm: bool, embed_cjk: bool) -> Result { 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 @@ -80,7 +105,95 @@ fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result 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 + } + + fn manifest(&self, name: &str, faces: &[(&str, &str)]) { + let text = faces.iter().map(|(family, style)| format!("{family} | {style} | fonts/face.ttf | Latn\n")).collect::(); + std::fs::write(self.0.join("fonts").join(name), text).unwrap(); + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[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()); + } + + #[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")); + } + + #[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")); + } + + #[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")); + } + + #[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")); + } } diff --git a/crates/text/tests/build_input.rs b/crates/text/tests/build_input.rs new file mode 100644 index 00000000..6f87ac1f --- /dev/null +++ b/crates/text/tests/build_input.rs @@ -0,0 +1,4 @@ +//! Exercise the actual build-script catalog policy without changing process environment. +#[allow(dead_code)] +#[path = "../build.rs"] +mod build_script; diff --git a/crates/text/tests/fixtures/latin-families.txt b/crates/text/tests/fixtures/latin-families.txt new file mode 100644 index 00000000..e9db6f41 --- /dev/null +++ b/crates/text/tests/fixtures/latin-families.txt @@ -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 diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 4c35ae40..c5da205c 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -345,12 +345,15 @@ fn cjk_names_are_looked_for_in_edits_not_on_every_pan() { fn a_hidden_window_answers_at_once() { // #269, #720: minimised or covered, the window draws no frames, but the app's logic still runs: // it answers edits, inspection, saves and the interface's state; what needs a frame (a capture - // of the window) is an error at once instead of waiting for the window to show. + // of the window) queues for a forced hidden paint and otherwise expires at its deadline. let dir = std::env::temp_dir().join(format!("figcraft-hidden-save-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("hidden.figcraft"); let (mut h, _) = document(); h.settle(); + // Exercise admission well after startup, independently of settling speed. + h.time += 120.0; + h.app.clock.set_test_now(Some(h.time)); let (tx, rx) = std::sync::mpsc::channel(); let request = |id: u64, method: &str, params: Value| ControlRequest { id: json!(id), @@ -376,7 +379,7 @@ fn a_hidden_window_answers_at_once() { assert_eq!(h.app.capture.shots.len(), 1, "one admitted capture"); assert!(h.app.control_queue.is_empty(), "nothing waits for the window"); // Its deadline expires it once, with the same error the transport used to get. - h.app.clock.set_test_now(Some(crate::control::SHOT_TIMEOUT + 1.0)); + h.app.clock.set_test_now(Some(h.time + crate::control::SHOT_TIMEOUT + 1.0)); let _ = ctx.run_logic(&hidden, |ctx| crate::control::background(&mut h.app, ctx)); let shot = rx.try_recv().expect("expired"); assert_eq!(shot.id, json!(3)); diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index 1537f426..6da3a011 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -137,6 +137,8 @@ fn another_hidden_window_answers_requests_that_need_no_frame_in_its_own_file() { // A capture that needs a frame is admitted and asks the hidden window to paint, rather than // failing at once; only its deadline expires it (once). + // Model a request admitted long after startup, without waiting in real time. + app.clock.set_test_now(Some(120.0)); let shot = request(&mut app, "ui.screenshot", json!({"window": w, "path": "unused.png"})); let unknown = request(&mut app, "window.list", json!({"window": 99})); logic(&ctx, &mut app, &[ViewportId::ROOT, other]); @@ -145,7 +147,7 @@ fn another_hidden_window_answers_requests_that_need_no_frame_in_its_own_file() { assert!(unknown.try_recv().expect("answered").result.unwrap_err().contains("no window 99")); assert!(app.control_queue.is_empty()); // The whole request lifetime is over: one timeout reply, and nothing retained. - app.clock.set_test_now(Some(crate::control::SHOT_TIMEOUT + 1.0)); + app.clock.set_test_now(Some(app.clock.now() + crate::control::SHOT_TIMEOUT + 1.0)); logic(&ctx, &mut app, &[ViewportId::ROOT, other]); assert!(shot.try_recv().expect("answered").result.unwrap_err().contains("didn't deliver a screenshot")); assert!(app.capture.shots.is_empty(), "released after its deadline"); diff --git a/docs/egui-037-local-followups.md b/docs/egui-037-local-followups.md index dde4e5c1..670fc30f 100644 --- a/docs/egui-037-local-followups.md +++ b/docs/egui-037-local-followups.md @@ -1,7 +1,24 @@ # egui 0.37 local implementation — stage 1 notes and follow-ups +Current candidate font pin: `37827adc41fd165f9a9234ac40f94dbad353aa69` restores the complete prior Latin catalog +alongside the migration fallbacks. Central font tests and license checks passed; +publication is pending approval. Managed run `egui037-figcraft-latin-ci-20261025-0711` +passed with required fonts: five targeted build-input tests plus full CI, including +WASM and plugin builds (3,257 passing executions, zero failures, nine ignored across +72 summaries; repeated tests are counted). This supersedes the optional-font-only +qualification below for the restored catalog. + ## Current local qualification — 2026-10-25 +The published refresh preserves main `9f788753` and passed managed full CI +`egui037-figcraft-main-refresh-ci-20261025-0618`: 3,247 passing executions, zero +failures and nine ignored. The 3,242 counts below describe the earlier `10702311` +qualification, before five additional main-branch tests. These are execution counts, +not distinct regressions. The release font input is under review separately; a +passing optional-font host build does not qualify the required web font catalog. + +### Earlier qualification at `10702311` + The migration preserves captured main `10702311f345393ee1d8868c3257679befcfa14d` (53 commits and 114 paths beyond the first integration). The final review commit records both the migration history and that upstream target as parents. The selected egui revision is @@ -147,19 +164,19 @@ an unhandled command for hidden tabs. Hidden-browser-tab `ui.screenshot`/`ui.inp unqualified even though the native path now queues and settles them. This is a forkfix prerequisite, not a passing claim. -## Local dependency and lock prerequisites (parent) +## Dependency and lock prerequisites -- Parent config `local-integration-config.toml` patches the egui family and winit, and `craft-ui`/ - `craft-print`. It does **not** patch `craft-window`. FigCraft now disables craft-window's optional - `egui` feature (it only needs the frame probe), but Cargo still resolves a package's optional - dependencies into the lock, and that rev declares `egui = "0.36"`. The managed graph must - therefore also patch/repoint `craft-window` to the reviewed craft-libs revision whose workspace - targets 0.37 (or otherwise prove the lock has exactly one egui family); otherwise the lock will - contain a second egui 0.36. -- No `[patch]` rows or path dependencies live in this repository's manifests; the parent generates - `Cargo.lock`. Do not publish this tree's resolved paths. -- Fonts: production keeps its central `CRAFT_FONTS_DIR`/system-font policy; no font binaries are - added here. Color-emoji claims still require a separate `epaint/color_fonts` feature job. +- The committed root `[patch.crates-io]` table pins the egui family and winit to + immutable published fork revisions. `craft-window` is pinned to the reviewed + craft-libs revision targeting egui 0.37; its optional `egui` feature stays disabled + because FigCraft needs only the frame probe. +- `Cargo.lock` is committed and resolves the canonical Git sources without local + path overrides. Use `--locked`; the earlier parent integration config is not + required to reproduce this graph. +- Fonts remain an optional central `CRAFT_FONTS_DIR` input with native system-font + fallback; no font binaries are added here. Required release-font qualification + is separate from optional-font host CI. Color emoji still needs a separate + `epaint/color_fonts` feature check. ## Qualification gaps and baseline backports/perf operations @@ -182,6 +199,12 @@ operations to measure once the parent can build: `#8731` text tessellation, `#87 startup, `#8512` font discovery, `#8763`/PR `#8764` TextEdit click-away, `#8748` hidden deferred children. None of these were applied, removed or claimed as fixed. -## Optional Latin manifest compatibility +## Required web font catalog -The pinned central font catalog has a required `fonts/manifest.txt` and no optional `fonts/latin-manifest.txt`. Required-font builds now validate the core and all supplied extension assets, while an absent opt-in Latin manifest is watched but not required. This follows the existing build script documentation and keeps malformed or incomplete supplied manifests fatal in required mode. No font binary was copied into FigCraft. Both required-core and no-font configurations remain validation gates. +Native optional-font builds retain the system-font policy. A required web build must +embed Inter Regular and SemiBold plus Noto Sans Georgian Regular after filtering; +missing required face/style coverage fails the build, regardless of which manifest supplies it. Required mode +also rejects an unset `CRAFT_FONTS_DIR`. The migration's original `b076417` catalog +lacks Inter and is not a qualified required-web input. A combined central font pin restores the prior Latin catalog while preserving the new +core fallbacks. Source Sans 3 and Source Serif 4 are excluded on every target; no font binary belongs in this repository. Required-web, required-native +and optional-font configurations remain separate validation gates. diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md index fc7ecbc4..27ce7c5c 100644 --- a/docs/egui-037-migration-plan.md +++ b/docs/egui-037-migration-plan.md @@ -1188,6 +1188,10 @@ validation and generates the lockfile from its local dependency config. ## Current local qualification — 2026-10-25 +Historical qualification: the following counts apply to the earlier `10702311` +integration. The current `9f788753` refresh and its 3,247 passing executions are +recorded in the status update above. + The migration preserves captured main `10702311f345393ee1d8868c3257679befcfa14d` (53 commits and 114 paths beyond the first integration). The final review commit records both the migration history and that upstream target as parents. The selected egui revision is diff --git a/docs/releasing.md b/docs/releasing.md index 07492f6c..3dbc0fbf 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -101,11 +101,14 @@ builds with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUIRED=1`, so releases embed it rather than ship without them. The browser has no system fonts, so a web build without craft-fonts can't draw text. `crates/text/build.rs` reads both `fonts/manifest.txt` and `fonts/latin-manifest.txt`, so the pin -must be a commit that has the Latin manifest (today `4391305`, on craft-fonts' -`pr/latin-presentation-fonts` branch; re-pin to `main` once that lands). That commit has no -Noto Sans Georgian (craft-fonts' main has it but no Latin manifest), so desktop Georgian uses an -installed font; re-pin when one commit has both (craft-fonts#42). The web build embeds only Inter, -Noto Sans Georgian and the Archivo SemiBold wordmark, when the pin has them. Packages carry each embedded font's licence as `OFL-.txt` +must combine the Latin presentation catalog and the current core UI faces. Required web builds +validate the emitted Inter Regular/SemiBold and Noto Sans Georgian Regular faces after filtering; +a missing required face, style or asset fails the required web build. Excluded Adobe rows +are skipped before resolving their asset paths. +Native builds retain the permitted Latin presentation faces. Source Sans 3 and Source Serif 4 +are filtered on every target under FigCraft's asset policy. The web build keeps its smaller +allowlist: Inter, Noto Sans Georgian and Archivo SemiBold when supplied. Packages carry each +embedded font's licence as `OFL-.txt` (`copy_font_licences` in `packaging/env.sh`; `Contents/Resources/Licenses` in the macOS app). ### macOS From d7c7986347c85d27b2fe80411dd5b04003e108c0 Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Sun, 25 Oct 2026 05:32:03 +0100 Subject: [PATCH 2/7] E21: separate capture, IME and field-save regression phases --- crates/ui-egui/src/control.rs | 414 +++++++++++++++++------------- crates/ui-egui/src/field_tests.rs | 280 ++++++++++---------- crates/ui-egui/src/shortcuts.rs | 300 ++++++++++++---------- 3 files changed, 547 insertions(+), 447 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index fdecaf18..b7ca225d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -455,171 +455,18 @@ pub fn drain(app: &mut FigApp, ctx: &Context) { let viewport = ctx.viewport_id(); // This pass's geometry defines the epoch: a resize, DPI change or zoom change (back or not) // invalidates captures issued under the old one. - let spec = current_spec(ctx, viewport); - if let Some(spec) = spec { - let changed = app.capture.geometry.get(&viewport).is_none_or(|(old, _)| *old != spec); - if changed { - app.capture.epoch = app.capture.epoch.wrapping_add(1); - app.capture.geometry.insert(viewport, (spec, app.capture.epoch)); - } - } - let epoch = app.capture.geometry.get(&viewport).map_or(0, |(_, e)| *e); + let (spec, epoch) = pass_geometry(app, ctx, viewport); expire(app, ctx, now); // Drain completed captures first, so their budget is free before new requests are admitted. - let mut i = 0; - while i < app.capture.shots.len() { - // Each viewport drains its own captures: their geometry and document differ. - if app.capture.shots[i].viewport != viewport { - i += 1; - continue; - } - let Some(slot) = app.capture.shots[i].slot.clone() else { - i += 1; - continue; - }; - let Some(image) = slot.take() else { - i += 1; - continue; - }; - let mut r = app.capture.shots.remove(i); - r.release(); - let cancelled = r.cancelled.load(std::sync::atomic::Ordering::Relaxed); - let current = current_spec(ctx, viewport); - let (document, presentation) = app.windows.presentation(&app.session, r.window); - if cancelled { - // The transport stopped waiting: no reply, no file. - } else if now >= r.deadline { - reply(&mut r, Err("the window didn't deliver a screenshot in time (is it visible?)".into())); - } else if r.window != app.windows.current || r.document != document || r.presentation != presentation { - reply(&mut r, Err("the document changed while the screenshot was being captured".into())); - } else if current.is_none() { - // Fail closed: with no representable current geometry the pixels cannot be proven to - // belong to the surface that was admitted, so they are never the capture's reply. - reply(&mut r, Err("the window's current geometry is unavailable (is it minimised?)".into())); - } else if let Some(current) = current - && (r.epoch != epoch || r.spec.is_none_or(|s| s.size_px != current.size_px || s.pixels_per_point != current.pixels_per_point)) - { - // Geometry moved between issuance and arrival (even back): retry against the new pass, - // never accept the old pixels. Document changes are errors above. - if r.retries >= 2 { - reply(&mut r, Err("the window kept changing size while the screenshot was captured".into())); - } else { - r.retries += 1; - r.spec = Some(current); - r.epoch = epoch; - r.reserved = r.spec.and_then(|s| s.cost()).unwrap_or(0); - r.settle = 0; - r.settle_frame = None; - r.issued = false; - app.capture.shots.push(r); - } - } else if let (Some(current), Some(spec)) = (current, r.spec) - // The admitted surface is in whole UI points; the readback is in physical pixels. Up - // to one pixel of rounding either way is the same surface, not a scale change. The - // difference is absolute: an undersized readback is a scale mismatch too. - && (image.size[0].abs_diff(spec.size_px[0]) > 1 || image.size[1].abs_diff(spec.size_px[1]) > 1) - { - if r.retries >= 2 { - reply( - &mut r, - Err(format!( - "the screenshot is {}x{} but the window is {}x{} (a scale mismatch)", - image.size[0], image.size[1], spec.size_px[0], spec.size_px[1] - )), - ); - } else { - r.retries += 1; - r.spec = Some(current); - r.epoch = epoch; - r.reserved = r.spec.and_then(|s| s.cost()).unwrap_or(0); - r.settle = 0; - r.settle_frame = None; - r.issued = false; - app.capture.shots.push(r); - } - } else { - let result = shot_reply(&image, r.path.as_deref()); - reply(&mut r, result); - } - ctx.request_repaint(); - } + drain_completed(app, ctx, viewport, now, epoch); // Admit queued captures for the window drawing now; other windows keep waiting for their frame. - for r in std::mem::take(&mut app.control_queue) { - if r.is_cancelled() { - continue; - } - let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused); - if target != app.windows.current { - if app.windows.exists(target) { - ctx.request_repaint_of(crate::windows::viewport(target)); - app.control_queue.push(r); - } else { - let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); - } - continue; - } - if r.method == "ui.screenshot" { - admit(app, r, target, now); - continue; - } - // Every control-channel gesture is fenced from the user's system clipboard (the flag is - // restored so a batch already in a frame's raw input keeps its own provenance). - let before = app.control_synthetic; - app.control_synthetic = true; - let res = handle(app, ctx, &r.method, &r.params); - app.control_synthetic = before; - if r.method == "ui.input" && std::mem::take(&mut app.control_input_queued) { - // Acknowledge once the pass that applies the batch is presented, never from a sizing - // pass whose input is thrown away. - app.input_acks.push(crate::PendingAck { viewport, tx: r.reply, reply: ControlReply { id: r.id, result: res } }); - } else { - let _ = r.reply.send(ControlReply { id: r.id, result: res }); - } - ctx.request_repaint(); - } + admit_queued(app, ctx, viewport, now); // Fill in this pass's geometry, enforce the byte budget (a post-callback check alone would // not bound peak memory), and release anything over it before issuing. - let mut i = 0; - while i < app.capture.shots.len() { - let total: usize = app.capture.shots.iter().map(|s| s.reserved).sum(); - let mut remove = false; - { - let r = &mut app.capture.shots[i]; - if r.viewport != viewport { - i += 1; - continue; - } - if !r.issued && r.spec != spec { - // A request can resize during its settling frames. Reserve and validate this - // actual surface before issuance, never the old (possibly much smaller) one. - r.spec = spec; - r.settle = 0; - r.settle_frame = None; - if spec.is_none() { - r.reserved = 0; - } - } - if let Some(spec) = r.spec { - let raw = spec.bytes().unwrap_or(usize::MAX); - let cost = spec.cost().unwrap_or(usize::MAX); - if raw > MAX_SHOT_IMAGE_BYTES || total.saturating_sub(r.reserved).saturating_add(cost) > MAX_SHOT_TOTAL_BYTES { - reply(r, Err(format!("the screenshot needs {cost} bytes, over the capture budget"))); - remove = true; - } else if r.reserved != cost { - r.reserved = cost; - } - } - } - if remove { - let mut r = app.capture.shots.remove(i); - r.release(); - } else { - i += 1; - } - } + enforce_capture_budget(app, viewport, spec); // Settle on completed UI frames only, then issue once. (The screenshot command itself forces // the paint that delivers it.) The request's document and window were fixed at admission and @@ -630,6 +477,243 @@ pub fn drain(app: &mut FigApp, ctx: &Context) { // first pass calls `request_discard` is therefore counted once, after it presents its layout, // not when its discarded sizing pass runs. Passes after the first (`current_pass_index > 0`) // belong to such a frame and never add another count. + settle_and_issue(app, ctx, viewport, now, epoch, spec); + request_paint(app, ctx); +} + +/// Establish this pass's screenshot geometry and epoch: a resize, DPI change or zoom change (even +/// back) bumps the epoch captures issued under the old one were admitted against. +fn pass_geometry(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId) -> (Option, u64) { + let spec = current_spec(ctx, viewport); + if let Some(spec) = spec { + let changed = app.capture.geometry.get(&viewport).is_none_or(|(old, _)| *old != spec); + if changed { + app.capture.epoch = app.capture.epoch.wrapping_add(1); + app.capture.geometry.insert(viewport, (spec, app.capture.epoch)); + } + } + let epoch = app.capture.geometry.get(&viewport).map_or(0, |(_, e)| *e); + (spec, epoch) +} + +/// Take the image delivery for the shot at `index`; `None` when no image has arrived yet. +fn arrived_shot(app: &mut FigApp, index: usize) -> Option<(ShotRequest, Arc)> { + let slot = app.capture.shots.get(index)?.slot.clone()?; + let image = slot.take()?; + let mut r = app.capture.shots.remove(index); + r.release(); + Some((r, image)) +} + +/// Drain images delivered for this viewport: reply, re-queue for a geometry retry, or drop a +/// cancelled capture. Each viewport drains its own captures: their geometry and document differ. +fn drain_completed(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, epoch: u64) { + let mut i = 0; + while i < app.capture.shots.len() { + if app.capture.shots[i].viewport != viewport { + i += 1; + continue; + } + let Some((r, image)) = arrived_shot(app, i) else { + i += 1; + continue; + }; + settle_arrival(app, ctx, viewport, now, epoch, r, &image); + ctx.request_repaint(); + } +} + +/// Whether the pass geometry differs from the one the capture was issued at (even back to an old +/// size): the pixels cannot be accepted for the admitted surface. +fn geometry_moved(r: &ShotRequest, current: CaptureSpec, epoch: u64) -> bool { + r.epoch != epoch || r.spec.is_none_or(|s| s.size_px != current.size_px || s.pixels_per_point != current.pixels_per_point) +} + +/// The admitted surface is in whole UI points; the readback is in physical pixels. Up to one +/// pixel of rounding either way is the same surface, not a scale change. The difference is +/// absolute: an undersized readback is a scale mismatch too. +fn scale_mismatch(image: &egui::ColorImage, spec: CaptureSpec) -> bool { + image.size[0].abs_diff(spec.size_px[0]) > 1 || image.size[1].abs_diff(spec.size_px[1]) > 1 +} + +/// Re-queue a capture against the new pass, or hand it back when its retries are exhausted. +fn requeue_capture(app: &mut FigApp, mut r: ShotRequest, current: CaptureSpec, epoch: u64) -> Option { + if r.retries >= 2 { + return Some(r); + } + r.retries += 1; + r.spec = Some(current); + r.epoch = epoch; + r.reserved = r.spec.and_then(|s| s.cost()).unwrap_or(0); + r.settle = 0; + r.settle_frame = None; + r.issued = false; + app.capture.shots.push(r); + None +} + +/// Settle an arrived image: reply with it, retry against the new pass on a geometry/scale change, +/// or drop it when the transport cancelled or the window/document moved on. +fn settle_arrival(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, epoch: u64, mut r: ShotRequest, image: &egui::ColorImage) { + let cancelled = r.cancelled.load(std::sync::atomic::Ordering::Relaxed); + let current = current_spec(ctx, viewport); + let (document, presentation) = app.windows.presentation(&app.session, r.window); + if cancelled { + // The transport stopped waiting: no reply, no file. + return; + } + if now >= r.deadline { + reply(&mut r, Err("the window didn't deliver a screenshot in time (is it visible?)".into())); + return; + } + if r.window != app.windows.current || r.document != document || r.presentation != presentation { + reply(&mut r, Err("the document changed while the screenshot was being captured".into())); + return; + } + let Some(current) = current else { + // Fail closed: with no representable current geometry the pixels cannot be proven to + // belong to the surface that was admitted, so they are never the capture's reply. + reply(&mut r, Err("the window's current geometry is unavailable (is it minimised?)".into())); + return; + }; + if geometry_moved(&r, current, epoch) { + // Geometry moved between issuance and arrival (even back): retry against the new pass, + // never accept the old pixels. Document changes are errors above. + if let Some(mut r) = requeue_capture(app, r, current, epoch) { + reply(&mut r, Err("the window kept changing size while the screenshot was captured".into())); + } + return; + } + if let Some(spec) = r.spec + && scale_mismatch(image, spec) + { + if let Some(mut r) = requeue_capture(app, r, current, epoch) { + reply( + &mut r, + Err(format!( + "the screenshot is {}x{} but the window is {}x{} (a scale mismatch)", + image.size[0], image.size[1], spec.size_px[0], spec.size_px[1] + )), + ); + } + return; + } + let result = shot_reply(image, r.path.as_deref()); + reply(&mut r, result); +} + +/// Admit queued captures for the window drawing now; other windows keep waiting for their frame. +fn admit_queued(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64) { + for r in std::mem::take(&mut app.control_queue) { + route_queued(app, ctx, viewport, now, r); + } +} + +/// Route one queued control request: retarget another window, admit a capture, or run a +/// state-only request for the window drawing now. +fn route_queued(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, r: ControlRequest) { + if r.is_cancelled() { + return; + } + let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused); + if target != app.windows.current { + if app.windows.exists(target) { + ctx.request_repaint_of(crate::windows::viewport(target)); + app.control_queue.push(r); + } else { + let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); + } + return; + } + if r.method == "ui.screenshot" { + admit(app, r, target, now); + return; + } + // Every control-channel gesture is fenced from the user's system clipboard (the flag is + // restored so a batch already in a frame's raw input keeps its own provenance). + let before = app.control_synthetic; + app.control_synthetic = true; + let res = handle(app, ctx, &r.method, &r.params); + app.control_synthetic = before; + if r.method == "ui.input" && std::mem::take(&mut app.control_input_queued) { + // Acknowledge once the pass that applies the batch is presented, never from a sizing + // pass whose input is thrown away. + app.input_acks.push(crate::PendingAck { viewport, tx: r.reply, reply: ControlReply { id: r.id, result: res } }); + } else { + let _ = r.reply.send(ControlReply { id: r.id, result: res }); + } + ctx.request_repaint(); +} + +/// Reserve this request's actual surface against the capture budget; `true` when it must be +/// removed for exceeding it. +fn reserve_for_spec(r: &mut ShotRequest, spec: Option, total: usize) -> bool { + if !r.issued && r.spec != spec { + // A request can resize during its settling frames. Reserve and validate this + // actual surface before issuance, never the old (possibly much smaller) one. + r.spec = spec; + r.settle = 0; + r.settle_frame = None; + if spec.is_none() { + r.reserved = 0; + } + } + let Some(spec) = r.spec else { return false }; + let raw = spec.bytes().unwrap_or(usize::MAX); + let cost = spec.cost().unwrap_or(usize::MAX); + if raw > MAX_SHOT_IMAGE_BYTES || total.saturating_sub(r.reserved).saturating_add(cost) > MAX_SHOT_TOTAL_BYTES { + reply(r, Err(format!("the screenshot needs {cost} bytes, over the capture budget"))); + return true; + } + if r.reserved != cost { + r.reserved = cost; + } + false +} + +/// Fill in this pass's geometry and enforce the byte budget (a post-callback check alone would +/// not bound peak memory), releasing anything over it before issuing. +fn enforce_capture_budget(app: &mut FigApp, viewport: egui::ViewportId, spec: Option) { + let mut i = 0; + while i < app.capture.shots.len() { + let total: usize = app.capture.shots.iter().map(|s| s.reserved).sum(); + let remove = { + let r = &mut app.capture.shots[i]; + if r.viewport != viewport { + i += 1; + continue; + } + reserve_for_spec(r, spec, total) + }; + if remove { + let mut r = app.capture.shots.remove(i); + r.release(); + } else { + i += 1; + } + } +} + +/// Count one completed frame into the capture's settling, reporting whether it may now be issued. +fn settled_for_issue(r: &mut ShotRequest, frame_nr: u64) -> bool { + match r.settle_frame { + None => { + // Anchor the count at the first pass that sees this request's geometry; this + // frame has not completed yet. + r.settle_frame = Some(frame_nr); + false + } + Some(seen) if frame_nr > seen => { + r.settle_frame = Some(frame_nr); + r.settle = r.settle.saturating_add(1); + r.settle >= SHOT_SETTLE_PASSES + } + _ => false, + } +} + +/// Settle on completed UI frames only, then issue once (against this pass's surface). +fn settle_and_issue(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, epoch: u64, spec: Option) { let first_pass = ctx.current_pass_index() == 0; let frame_nr = ctx.cumulative_frame_nr_for(viewport); for r in &mut app.capture.shots { @@ -639,20 +723,7 @@ pub fn drain(app: &mut FigApp, ctx: &Context) { if r.cancelled.load(std::sync::atomic::Ordering::Relaxed) || now >= r.deadline { continue; } - match r.settle_frame { - None => { - // Anchor the count at the first pass that sees this request's geometry; this - // frame has not completed yet. - r.settle_frame = Some(frame_nr); - continue; - } - Some(seen) if frame_nr > seen => { - r.settle_frame = Some(frame_nr); - r.settle = r.settle.saturating_add(1); - } - _ => continue, - } - if r.settle < SHOT_SETTLE_PASSES { + if !settled_for_issue(r, frame_nr) { continue; } let slot = CaptureSlot::new(ctx, viewport); @@ -665,7 +736,6 @@ pub fn drain(app: &mut FigApp, ctx: &Context) { r.issued = true; ctx.send_viewport_cmd_to(viewport, egui::ViewportCommand::Screenshot(CaptureSlot::callback(&slot))); } - request_paint(app, ctx); } /// A screenshot's reply: the PNG written to `path` (through the automation roots, when the app diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 68a6564b..e09df06e 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -872,148 +872,146 @@ 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] { - 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::(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, - cancelled: 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::(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(); + 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::(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, + cancelled: 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::(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(); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 9aa72c12..2abb1cfc 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -674,153 +674,185 @@ fn text_keys(app: &mut FigApp, ctx: &Context, events: &[Event], ime_input: Optio } match ev { Event::Text(t) => { - if skip_text { - skip_text = false; - continue; - } - let before = ime_stamp(app); - if let Err(e) = figcraft_engine::textedit::insert(&mut app.session, t) { - app.toast(e.to_string()); - } - if ime_stamp(app) != before { - note_ime_application_change(app, ctx); + if !std::mem::take(&mut skip_text) { + insert_text(app, ctx, t); } } - // An accepted IME commit earns an explicit receipt only around the exact mutation. - // List AutoFormat inside the same command is an application transformation. - Event::Ime(egui::ImeEvent::Commit(t)) => { - let before = ime_stamp(app); - match figcraft_engine::textedit::insert_ime_commit(&mut app.session, t) { - Ok(ImeInsertion::Plain) => ime_receipt(app, ctx, before), - Ok(ImeInsertion::Transformed) => note_ime_application_change(app, ctx), - Ok(ImeInsertion::Nothing) => {} - Err(e) => app.toast(e.to_string()), - } - } - // The composition shows inline at the caret; the document gets it on Commit. - Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => { - let cursor = active_range_chars.as_ref().map(|r| r.start); - if let Err(e) = figcraft_engine::textedit::preedit(&mut app.session, text, cursor) { - app.toast(e.to_string()); - } - } - // An IME replacing text around the caret (0.37's incoming byte-counted deletion) - // deletes it before the paired Commit inserts the replacement. - Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }) => { - let before = ime_stamp(app); - match figcraft_engine::textedit::delete_surrounding_bytes(&mut app.session, *before_bytes, *after_bytes) { - Ok(true) => ime_receipt(app, ctx, before), - Ok(false) => {} - Err(e) => app.toast(e.to_string()), - } - } - Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) => { - let before = ime_stamp(app); - match figcraft_engine::textedit::delete_surrounding_chars(&mut app.session, *before_chars, *after_chars) { - Ok(true) => ime_receipt(app, ctx, before), - Ok(false) => {} - Err(e) => app.toast(e.to_string()), - } - } - Event::Paste(t) => { - // Plain text only: the engine normalises line endings and drops control chars. - let before = ime_stamp(app); - if let Err(e) = figcraft_engine::textedit::insert(&mut app.session, t) { - app.toast(e.to_string()); - } - if ime_stamp(app) != before { - note_ime_application_change(app, ctx); - } - } - Event::Copy | Event::Cut => { - let before = ime_stamp(app); - let selected = figcraft_engine::textedit::selected_text(&app.session).filter(|t| !t.is_empty()); - // Input injected through the control channel must never read or replace the user's - // system clipboard. It may still edit the document: a synthetic Cut deletes the - // selection exactly like a user's Cut; only the OS clipboard write is fenced. - if !app.control_synthetic - && let Some(text) = &selected - { - app.clipboard_set_text(text); - } - if matches!(ev, Event::Cut) && selected.is_some() { - let _ = figcraft_engine::textedit::key(&mut app.session, "Delete", false, false, false); - } - if ime_stamp(app) != before { - note_ime_application_change(app, ctx); - } - } - Event::Key { key, physical_key, pressed: true, modifiers: m, .. } => { - // Keys during a composition are the IME's (Enter picks a candidate, Escape cancels). - if app.session.ds().is_ok_and(|d| d.text_edit.as_ref().is_some_and(|te| te.preedit.is_some())) { - continue; - } - if m.command || m.alt || m.ctrl { - let before = ime_stamp(app); - if text_shortcut(app, *key, *physical_key, *m) { - // A text shortcut that changed the committed text/selection (a list, - // Bold, alignment) is an application edit; Save and friends are not. - if ime_stamp(app) != before { - note_ime_application_change(app, ctx); - } - skip_text = true; - continue; - } - } - let name = match key { - Key::Backspace => "Backspace", - Key::Delete => "Delete", - Key::ArrowLeft => "Left", - Key::ArrowRight => "Right", - Key::ArrowUp => "Up", - Key::ArrowDown => "Down", - Key::Home => "Home", - Key::End => "End", - Key::Enter => "Enter", - Key::Escape => "Escape", - Key::Tab => "Tab", - Key::A if m.command => "SelectAll", - Key::Z if m.command => { - note_ime_application_change(app, ctx); - let _ = figcraft_engine::textedit::end(&mut app.session); - app.run(if m.shift { "edit.redo" } else { "edit.undo" }, json!({})); - continue; - } - _ => continue, - }; - if name == "Enter" && m.command { - let _ = figcraft_engine::textedit::end(&mut app.session); - continue; - } - let before = ime_stamp(app); - let (word, line) = keymap::text_motion(platform(), name, mods(*m)); - if let Err(e) = figcraft_engine::textedit::key(&mut app.session, name, m.shift, word, line) { - app.toast(e.to_string()); - } - if ime_stamp(app) != before { - note_ime_application_change(app, ctx); - } + Event::Ime(ime) => ime_event(app, ctx, ime), + Event::Paste(t) => insert_text(app, ctx, t), + Event::Copy => copy_or_cut(app, ctx, false), + Event::Cut => copy_or_cut(app, ctx, true), + Event::Key { key, physical_key, pressed: true, modifiers: m, .. } if text_key_event(app, ctx, *key, *physical_key, *m) => { + skip_text = true; } _ => {} } } // Triple-click on the edited text selects its line (double-click is handled by the engine). - if ctx.input(|i| i.pointer.button_triple_clicked(egui::PointerButton::Primary)) - && let Some(pos) = ctx.input(|i| i.pointer.interact_pos()) - && app.canvas.rect.contains(pos) - && let Some(te) = app.session.ds().ok().and_then(|d| d.text_edit.clone()) - { - let p = app.to_page(pos); - let inside = app.session.doc().ok().and_then(|d| d.world_bounds(te.id)).is_some_and(|b| b.contains(p)); - if inside && let Ok(idx) = app.session.text_index_at(te.id, p) { + triple_click_line(app, ctx); + app.sync_preedit(); +} + +/// Insert plain text (the engine normalises line endings and drops control chars) into the edit, +/// noting the application change the mutation caused. +fn insert_text(app: &mut FigApp, ctx: &Context, t: &str) { + let before = ime_stamp(app); + if let Err(e) = figcraft_engine::textedit::insert(&mut app.session, t) { + app.toast(e.to_string()); + } + if ime_stamp(app) != before { + note_ime_application_change(app, ctx); + } +} + +/// An IME commit, preedit or surrounding-text deletion for the edit. +fn ime_event(app: &mut FigApp, ctx: &Context, ev: &egui::ImeEvent) { + match ev { + // An accepted IME commit earns an explicit receipt only around the exact mutation. + // List AutoFormat inside the same command is an application transformation. + egui::ImeEvent::Commit(t) => { let before = ime_stamp(app); - app.run("text.edit.selectLine", json!({"index": idx})); + match figcraft_engine::textedit::insert_ime_commit(&mut app.session, t) { + Ok(ImeInsertion::Plain) => ime_receipt(app, ctx, before), + Ok(ImeInsertion::Transformed) => note_ime_application_change(app, ctx), + Ok(ImeInsertion::Nothing) => {} + Err(e) => app.toast(e.to_string()), + } + } + // The composition shows inline at the caret; the document gets it on Commit. + egui::ImeEvent::Preedit { text, active_range_chars } => { + let cursor = active_range_chars.as_ref().map(|r| r.start); + if let Err(e) = figcraft_engine::textedit::preedit(&mut app.session, text, cursor) { + app.toast(e.to_string()); + } + } + // An IME replacing text around the caret (0.37's incoming byte-counted deletion) + // deletes it before the paired Commit inserts the replacement. + egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes } => { + let before = ime_stamp(app); + match figcraft_engine::textedit::delete_surrounding_bytes(&mut app.session, *before_bytes, *after_bytes) { + Ok(true) => ime_receipt(app, ctx, before), + Ok(false) => {} + Err(e) => app.toast(e.to_string()), + } + } + egui::ImeEvent::DeleteSurrounding { before_chars, after_chars } => { + let before = ime_stamp(app); + match figcraft_engine::textedit::delete_surrounding_chars(&mut app.session, *before_chars, *after_chars) { + Ok(true) => ime_receipt(app, ctx, before), + Ok(false) => {} + Err(e) => app.toast(e.to_string()), + } + } + // Legacy Enabled/Disabled events were ignored by the original event dispatcher too. + _ => {} + } +} + +/// Copy or cut the edit's selection. Input injected through the control channel must never read +/// or replace the user's system clipboard; it may still edit the document: a synthetic Cut +/// deletes the selection exactly like a user's Cut; only the OS clipboard write is fenced. +fn copy_or_cut(app: &mut FigApp, ctx: &Context, cut: bool) { + let before = ime_stamp(app); + let selected = figcraft_engine::textedit::selected_text(&app.session).filter(|t| !t.is_empty()); + if !app.control_synthetic + && let Some(text) = &selected + { + app.clipboard_set_text(text); + } + if cut && selected.is_some() { + let _ = figcraft_engine::textedit::key(&mut app.session, "Delete", false, false, false); + } + if ime_stamp(app) != before { + note_ime_application_change(app, ctx); + } +} + +/// A text-editing key press. `true` when it ran a shortcut, whose following Text event is +/// swallowed (a shortcut that changed committed text/selection is also an application edit). +fn text_key_event(app: &mut FigApp, ctx: &Context, key: Key, physical: Option, m: Modifiers) -> bool { + // Keys during a composition are the IME's (Enter picks a candidate, Escape cancels). + if app.session.ds().is_ok_and(|d| d.text_edit.as_ref().is_some_and(|te| te.preedit.is_some())) { + return false; + } + if m.command || m.alt || m.ctrl { + let before = ime_stamp(app); + if text_shortcut(app, key, physical, m) { + // A text shortcut that changed the committed text/selection (a list, + // Bold, alignment) is an application edit; Save and friends are not. if ime_stamp(app) != before { note_ime_application_change(app, ctx); } + return true; } } - app.sync_preedit(); + if key == Key::Z && m.command { + note_ime_application_change(app, ctx); + let _ = figcraft_engine::textedit::end(&mut app.session); + app.run(if m.shift { "edit.redo" } else { "edit.undo" }, json!({})); + return false; + } + let Some(name) = text_key_name(key, m.command) else { return false }; + if name == "Enter" && m.command { + let _ = figcraft_engine::textedit::end(&mut app.session); + return false; + } + let before = ime_stamp(app); + let (word, line) = keymap::text_motion(platform(), name, mods(m)); + if let Err(e) = figcraft_engine::textedit::key(&mut app.session, name, m.shift, word, line) { + app.toast(e.to_string()); + } + if ime_stamp(app) != before { + note_ime_application_change(app, ctx); + } + false +} + +/// The text-edit command a named key runs, if any. +fn text_key_name(key: Key, command: bool) -> Option<&'static str> { + Some(match key { + Key::Backspace => "Backspace", + Key::Delete => "Delete", + Key::ArrowLeft => "Left", + Key::ArrowRight => "Right", + Key::ArrowUp => "Up", + Key::ArrowDown => "Down", + Key::Home => "Home", + Key::End => "End", + Key::Enter => "Enter", + Key::Escape => "Escape", + Key::Tab => "Tab", + Key::A if command => "SelectAll", + _ => return None, + }) +} + +/// Triple-click on the edited text selects its line (double-click is handled by the engine). +fn triple_click_line(app: &mut FigApp, ctx: &Context) { + if !ctx.input(|i| i.pointer.button_triple_clicked(egui::PointerButton::Primary)) { + return; + } + let Some(pos) = ctx.input(|i| i.pointer.interact_pos()).filter(|pos| app.canvas.rect.contains(*pos)) else { + return; + }; + let Some(te) = app.session.ds().ok().and_then(|d| d.text_edit.clone()) else { return }; + let p = app.to_page(pos); + if let Some(idx) = text_line_index(app, te.id, p) { + let before = ime_stamp(app); + app.run("text.edit.selectLine", json!({"index": idx})); + if ime_stamp(app) != before { + note_ime_application_change(app, ctx); + } + } +} + +/// The text index of the line under `p`, when the edit's node contains it. +fn text_line_index(app: &FigApp, id: figcraft_doc::NodeId, p: figcraft_geom::Point) -> Option { + let inside = app.session.doc().ok().and_then(|d| d.world_bounds(id)).is_some_and(|b| b.contains(p)); + if !inside { + return None; + } + app.session.text_index_at(id, p).ok() } /// Shortcuts live while editing text (Bold, lists, alignment, size/weight/spacing steps, Save…). From fc344de33c33e52baff87d348186fd468cafcdde Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Sun, 25 Oct 2026 08:10:37 +0100 Subject: [PATCH 3/7] M0: correct the migration qualification reference --- docs/egui-037-migration-plan.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md index 6b8c5059..6cf1bb09 100644 --- a/docs/egui-037-migration-plan.md +++ b/docs/egui-037-migration-plan.md @@ -1189,8 +1189,9 @@ validation and generates the lockfile from its local dependency config. ## Historical local qualification — 2026-10-25 The following counts apply to the earlier `10702311` integration, not the later -`2a3b7d4f` qualification in the status update above. The intervening `9f788753` -refresh passed 3,247 executions; the runs listed below passed 3,242. +`2a3b7d4f` qualification documented in `docs/egui-037-local-followups.md`. The +status update above covers the subsequent `92682728` integration. The intervening +`9f788753` refresh passed 3,247 executions; the runs listed below passed 3,242. The migration preserves captured main `10702311f345393ee1d8868c3257679befcfa14d` (53 commits and 114 paths beyond the first integration). The final review commit records From b751271c2ac0588a13a67781e7bae8dfc1023202 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 14:52:12 +0100 Subject: [PATCH 4/7] M-egui037: document input lifecycle and UI regression contracts --- apps/figcraft-web/src/web.rs | 3 ++ apps/figcraft/src/control_server.rs | 3 ++ apps/figcraft/src/control_server_tests.rs | 9 ++++ apps/figcraft/src/main.rs | 3 ++ crates/collab-net/src/sessions_tests.rs | 3 ++ crates/engine/src/cmd/edit.rs | 4 ++ crates/engine/src/text_behaviour_tests.rs | 5 +++ crates/engine/src/textedit.rs | 32 +++++++++++++ crates/engine/src/textfit_tests.rs | 2 + crates/engine/src/tools.rs | 4 ++ crates/text/build.rs | 21 +++++++++ crates/ui-egui/src/a11y.rs | 2 + crates/ui-egui/src/a11y_tests.rs | 2 + crates/ui-egui/src/assets_panel.rs | 13 ++++++ crates/ui-egui/src/canvas.rs | 4 ++ crates/ui-egui/src/clipboard_fence_tests.rs | 16 +++++++ crates/ui-egui/src/colorpicker.rs | 9 ++++ crates/ui-egui/src/control.rs | 13 ++++++ crates/ui-egui/src/control_capture_tests.rs | 45 +++++++++++++++++++ crates/ui-egui/src/credits_tests.rs | 2 + crates/ui-egui/src/dialogs.rs | 4 ++ crates/ui-egui/src/field_tests.rs | 9 ++++ crates/ui-egui/src/frame_labels.rs | 3 ++ crates/ui-egui/src/frame_presets.rs | 2 + crates/ui-egui/src/guides.rs | 1 + crates/ui-egui/src/handles.rs | 1 + crates/ui-egui/src/home.rs | 4 ++ crates/ui-egui/src/idle_tests.rs | 7 +++ crates/ui-egui/src/ime_tests.rs | 42 +++++++++++++++++ crates/ui-egui/src/layers.rs | 8 ++++ crates/ui-egui/src/layers_behaviour_tests.rs | 3 ++ crates/ui-egui/src/layers_rename_tests.rs | 2 + crates/ui-egui/src/layout_panel.rs | 2 + crates/ui-egui/src/lib.rs | 10 +++++ crates/ui-egui/src/live_join_tests.rs | 1 + crates/ui-egui/src/live_server_tests.rs | 1 + crates/ui-egui/src/live_share_tests.rs | 5 +++ crates/ui-egui/src/live_tests.rs | 2 + crates/ui-egui/src/menu_aim_tests.rs | 3 ++ crates/ui-egui/src/menu_input_tests.rs | 2 + crates/ui-egui/src/menus.rs | 6 +++ crates/ui-egui/src/panel_remove_tests.rs | 5 +++ crates/ui-egui/src/panel_reorder_tests.rs | 2 + crates/ui-egui/src/perf_tests.rs | 3 ++ crates/ui-egui/src/plugin_panel_tests.rs | 3 ++ crates/ui-egui/src/plugins_ui.rs | 5 +++ crates/ui-egui/src/present_tests.rs | 2 + crates/ui-egui/src/present_view_tests.rs | 3 ++ crates/ui-egui/src/proto_canvas.rs | 3 ++ crates/ui-egui/src/proto_editor.rs | 1 + crates/ui-egui/src/proto_editor_tests.rs | 5 +++ crates/ui-egui/src/proto_panel.rs | 10 +++++ crates/ui-egui/src/proto_reorder_tests.rs | 1 + crates/ui-egui/src/right.rs | 18 ++++++++ crates/ui-egui/src/rtl_names_tests.rs | 11 +++++ crates/ui-egui/src/section_panel.rs | 3 ++ crates/ui-egui/src/select_all_tests.rs | 6 +++ crates/ui-egui/src/selection_colors.rs | 10 +++++ crates/ui-egui/src/selection_spacing.rs | 6 +++ crates/ui-egui/src/selection_spacing_tests.rs | 3 ++ crates/ui-egui/src/shortcuts.rs | 11 +++++ crates/ui-egui/src/style_picker_tests.rs | 2 + crates/ui-egui/src/tabs.rs | 2 + crates/ui-egui/src/theme.rs | 2 + crates/ui-egui/src/theme_startup_tests.rs | 2 + crates/ui-egui/src/titlebar_tests.rs | 2 + crates/ui-egui/src/toolbar.rs | 3 ++ crates/ui-egui/src/type_settings_tests.rs | 2 + crates/ui-egui/src/typography.rs | 5 +++ crates/ui-egui/src/typography_scrub_tests.rs | 3 ++ crates/ui-egui/src/variable_ui.rs | 1 + crates/ui-egui/src/variables_panel.rs | 9 ++++ crates/ui-egui/src/vector_panel.rs | 2 + crates/ui-egui/src/versions.rs | 2 + crates/ui-egui/src/widgets.rs | 3 ++ crates/ui-egui/src/windows_tests.rs | 14 ++++++ 76 files changed, 478 insertions(+) diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 802d229a..40451d43 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -71,6 +71,9 @@ struct WebShell { impl eframe::App for WebShell { // 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)); } diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs index 2af5610d..ada44c0f 100644 --- a/apps/figcraft/src/control_server.rs +++ b/apps/figcraft/src/control_server.rs @@ -97,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))?; diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs index 6144cdf3..f29a7773 100644 --- a/apps/figcraft/src/control_server_tests.rs +++ b/apps/figcraft/src/control_server_tests.rs @@ -28,6 +28,7 @@ fn reply(reader: &mut BufReader) -> 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, state: Default::default() } } @@ -115,6 +116,8 @@ 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())); @@ -139,6 +142,7 @@ fn a_committed_reply_wins_over_the_transport_timeout() { 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(); @@ -147,6 +151,8 @@ fn a_timeout_cancels_an_open_request() { 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(); @@ -268,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}; diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index be380c60..4a1488ad 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -62,6 +62,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); diff --git a/crates/collab-net/src/sessions_tests.rs b/crates/collab-net/src/sessions_tests.rs index 3c39e4d8..cf569a6d 100644 --- a/crates/collab-net/src/sessions_tests.rs +++ b/crates/collab-net/src/sessions_tests.rs @@ -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"); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 044ae884..38b24ea6 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -601,6 +601,10 @@ pub fn design_clipboard_buffer(html: &str) -> Option> { 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 { const CMD: &str = "edit.pasteDesignLayers"; let bytes = match (string(p, "html"), string(p, "data")) { diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index d88066cb..8cd8b145 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -134,6 +134,8 @@ 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 @@ -156,6 +158,9 @@ fn ime_delete_surrounding_bytes_is_exact_and_one_undo_step() { 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 diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 9c62c331..6551783c 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -69,6 +69,8 @@ pub struct ImeSurrounding { /// A mutex (rather than an atomic) also works on wasm32 targets without 64-bit atomics. static EDITING_SESSION: LazyLock> = 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, @@ -143,6 +145,9 @@ 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); @@ -230,6 +235,11 @@ pub fn insert_ime_commit(s: &mut Session, text: &str) -> Result { 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 { // 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`. @@ -894,6 +904,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"); @@ -1132,6 +1145,8 @@ 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"); @@ -1146,6 +1161,8 @@ mod tests { 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"); @@ -1162,6 +1179,8 @@ mod tests { 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); @@ -1182,6 +1201,8 @@ mod tests { 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"); @@ -1197,6 +1218,8 @@ mod tests { 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"); @@ -1215,6 +1238,8 @@ mod tests { 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"); @@ -1235,6 +1260,7 @@ mod tests { 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"); @@ -1250,6 +1276,8 @@ mod tests { 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("-"); @@ -1261,6 +1289,8 @@ mod tests { 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"); @@ -1283,6 +1313,8 @@ mod tests { 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"); diff --git a/crates/engine/src/textfit_tests.rs b/crates/engine/src/textfit_tests.rs index f9508942..e35f810c 100644 --- a/crates/engine/src/textfit_tests.rs +++ b/crates/engine/src/textfit_tests.rs @@ -64,6 +64,8 @@ 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(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index e6776d2c..e65afe2c 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -2010,6 +2010,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); diff --git a/crates/text/build.rs b/crates/text/build.rs index ecca6a45..ba087983 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -6,6 +6,9 @@ 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"); @@ -77,6 +80,10 @@ struct Catalog { 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 { let manifest = dir.join(manifest_rel); watch(&manifest); @@ -121,6 +128,8 @@ mod tests { 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(); @@ -131,6 +140,7 @@ mod tests { 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::(); std::fs::write(self.0.join("fonts").join(name), text).unwrap(); @@ -138,11 +148,15 @@ mod tests { } 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(); @@ -160,6 +174,7 @@ mod tests { 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(); @@ -168,6 +183,8 @@ mod tests { 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(); @@ -180,6 +197,8 @@ mod tests { 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(); @@ -189,6 +208,8 @@ mod tests { 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(); diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs index 79a28118..3c5fafb6 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -253,6 +253,8 @@ struct Shared { name: String, } +/// Run `f` on the per-context temporary `Shared` accessibility state keyed by +/// `figcraft_a11y`. fn shared(ctx: &Context, f: impl FnOnce(&mut Shared) -> R) -> R { ctx.data_mut(|d| f(d.get_temp_mut_or_default::(Id::unique("figcraft_a11y")))) } diff --git a/crates/ui-egui/src/a11y_tests.rs b/crates/ui-egui/src/a11y_tests.rs index a2c54cbe..14be637d 100644 --- a/crates/ui-egui/src/a11y_tests.rs +++ b/crates/ui-egui/src/a11y_tests.rs @@ -404,6 +404,8 @@ fn the_align_buttons_are_off_for_a_layer_alone_on_the_page() { 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 diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index e9f0ffce..db0cc0f7 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -164,6 +164,7 @@ enum Act { Hide(NodeId, bool), } +/// Stable egui id for the assets drag payload. fn drag_id() -> Id { Id::unique("asset_drag") } @@ -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 { @@ -773,6 +778,8 @@ fn drag_feedback(app: &mut FigApp, ui: &mut Ui, doc: &Document, libs: &HashMap) { let t = Tokens::get(ui.ctx()); let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::unique("asset_ghost"))); @@ -831,6 +838,10 @@ 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::unique("asset_publish_form"); let mut form: PublishForm = ui.data(|d| d.get_temp(form_id)).unwrap_or_default(); @@ -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")); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4211b3cc..a6770823 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -163,6 +163,10 @@ 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; diff --git a/crates/ui-egui/src/clipboard_fence_tests.rs b/crates/ui-egui/src/clipboard_fence_tests.rs index 9211a4d4..76ddf81c 100644 --- a/crates/ui-egui/src/clipboard_fence_tests.rs +++ b/crates/ui-egui/src/clipboard_fence_tests.rs @@ -25,14 +25,17 @@ struct Recorded { struct Clipboard(Arc>); 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 { 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; } @@ -46,6 +49,7 @@ struct Win { } 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(); @@ -54,6 +58,7 @@ impl Win { 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()))); @@ -64,6 +69,7 @@ impl Win { win } + /// Run one production UI frame with `events` at the next 60 Hz test time. fn frame(&mut self, events: Vec) { self.time += 1.0 / 60.0; self.app.clock.set_test_now(Some(self.time)); @@ -74,12 +80,15 @@ impl Win { 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(); @@ -106,6 +115,8 @@ fn a_synthetic_copy_shortcut_leaves_the_system_clipboard_alone() { 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(); @@ -120,6 +131,7 @@ fn a_copy_command_invoked_for_a_synthetic_click_still_keeps_the_clipboard() { assert_eq!(w.writes(), (1, 0)); } +/// 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(), @@ -127,6 +139,8 @@ fn edit_text(w: &Win, id: NodeId) -> String { } } +/// 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(); @@ -147,6 +161,8 @@ fn a_synthetic_cut_deletes_the_selection_but_leaves_the_users_clipboard_alone() 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(); diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index 90d00c3f..f1cde02f 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -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(); @@ -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(); @@ -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(); @@ -441,6 +447,9 @@ pub fn window_id() -> Id { 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. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4c3be3de..97e62d1f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -70,18 +70,22 @@ pub struct RequestGate { } impl RequestGate { + /// Create a request gate in its default `Open` state. pub fn new() -> Self { Self::default() } + /// Return a copy of the gate's current request state. pub fn state(&self) -> RequestState { *self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } + /// Whether the gate is `Cancelled`. pub fn is_cancelled(&self) -> bool { self.state() == RequestState::Cancelled } + /// Whether the gate is `Replied`. pub fn is_replied(&self) -> bool { self.state() == RequestState::Replied } @@ -121,6 +125,7 @@ pub struct ControlRequest { } impl ControlRequest { + /// Whether this request's gate is `Cancelled`. pub fn is_cancelled(&self) -> bool { self.state.is_cancelled() } @@ -147,6 +152,7 @@ struct SlotInner { } impl CaptureSlot { + /// Create an empty capture slot for `viewport`, holding a clone of the UI context. fn new(ctx: &Context, viewport: egui::ViewportId) -> Arc { Arc::new(Self { inner: Mutex::new(SlotInner::default()), ctx: ctx.clone(), viewport }) } @@ -161,6 +167,8 @@ impl CaptureSlot { }) } + /// Store the first delivered image only while the slot is open and untaken, ignoring later + /// or closed deliveries, then wake the viewport after releasing the lock. pub(crate) fn complete(&self, image: Arc) { { let mut inner = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); @@ -214,6 +222,7 @@ pub struct CaptureSpec { const SHOT_PNG_OVERHEAD: usize = 64 * 1024; impl CaptureSpec { + /// Return the checked pixel byte count (`width × height × 4`), or `None` on overflow. fn bytes(&self) -> Option { self.size_px[0].checked_mul(self.size_px[1])?.checked_mul(4) } @@ -272,6 +281,7 @@ pub struct ShotRequest { } impl ShotRequest { + /// Close and drop the capture slot, if any, and zero the reserved byte count. fn release(&mut self) { if let Some(slot) = self.slot.take() { slot.close(); @@ -327,6 +337,7 @@ fn current_spec(ctx: &Context, viewport: egui::ViewportId) -> Option) { let _ = r.reply.send(ControlReply { id: r.id.clone(), result }); } @@ -819,6 +830,8 @@ enum EncodedShot { } impl EncodedShot { + /// Deliver the encoded shot: for `File`, write the PNG through `file_access` and return its + /// path and dimensions; for `Data`, return the prebuilt result. fn finish(self) -> Result { match self { Self::File { path, png, width, height } => { diff --git a/crates/ui-egui/src/control_capture_tests.rs b/crates/ui-egui/src/control_capture_tests.rs index ce4df1aa..6068ce52 100644 --- a/crates/ui-egui/src/control_capture_tests.rs +++ b/crates/ui-egui/src/control_capture_tests.rs @@ -12,6 +12,7 @@ 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 { 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() }); @@ -38,10 +39,13 @@ fn resized_frame(u: &mut Ui, size: egui::Vec2) -> egui::FullOutput { 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()); @@ -67,6 +71,7 @@ fn two_captures_correlate_by_request_across_reverse_completion() { 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()); @@ -89,6 +94,7 @@ fn commit_dir(tag: &str) -> std::path::PathBuf { 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"); @@ -107,6 +113,7 @@ fn a_cancellation_after_encoding_prevents_the_final_write() { 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()); @@ -129,6 +136,8 @@ fn a_real_arrival_that_crosses_the_deadline_does_not_write_its_png() { 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"); @@ -149,6 +158,8 @@ fn an_expiry_during_encoding_prevents_the_final_write_and_reports_timeout() { 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"); @@ -172,6 +183,8 @@ fn a_commit_before_the_deadline_writes_and_queues_its_reply_once() { 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()); @@ -191,6 +204,8 @@ fn a_capture_after_its_deadline_replies_once_and_late_pixels_are_inert() { 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()); @@ -213,6 +228,7 @@ fn a_snapshot_batch_is_capped_before_issuing_over_the_count_budget() { 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()); @@ -230,6 +246,7 @@ fn an_undersized_readback_is_reissued_instead_of_accepted() { 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()); @@ -245,6 +262,7 @@ fn a_one_pixel_rounding_difference_is_still_the_same_surface() { 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(); @@ -268,6 +286,8 @@ fn a_document_a_to_b_to_a_switch_is_not_the_admitted_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()); @@ -288,6 +308,8 @@ fn a_geometry_change_reissues_instead_of_accepting_old_pixels() { 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]); @@ -298,6 +320,8 @@ fn a_translucent_pixel_round_trips_straight_rgba() { 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()); @@ -313,6 +337,8 @@ fn a_capture_is_not_issued_before_three_completed_real_ui_frames() { 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()); @@ -327,6 +353,8 @@ fn a_discarded_sizing_pass_does_not_count_as_a_settled_capture_frame() { 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()); @@ -354,6 +382,8 @@ fn a_hidden_window_that_delivers_pixels_is_asked_to_paint_the_draining_pass() { 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()); @@ -386,6 +416,8 @@ fn a_capture_for_a_window_with_unknown_visibility_is_admitted_with_a_deadline() 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()); @@ -412,6 +444,7 @@ fn a_completed_capture_is_rejected_when_current_geometry_is_unavailable() { 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(); @@ -435,6 +468,8 @@ fn a_capture_belongs_to_the_document_admitted_not_the_one_showing_when_it_arrive 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 }; @@ -462,6 +497,8 @@ fn the_capture_budget_counts_the_encoding_peak_not_only_the_pixels() { 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()); @@ -481,6 +518,8 @@ fn resizing_settling_captures_rechecks_the_aggregate_reserved_bytes() { 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()); @@ -518,6 +557,8 @@ fn push_input(u: &mut Ui, id: u64) -> std::sync::mpsc::Receiver { 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()); @@ -534,6 +575,8 @@ fn injected_input_is_acknowledged_only_after_the_pass_that_applies_it() { 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()); @@ -565,6 +608,8 @@ fn a_discarded_pass_does_not_acknowledge_or_lose_an_injected_batch() { 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 diff --git a/crates/ui-egui/src/credits_tests.rs b/crates/ui-egui/src/credits_tests.rs index 8b10a622..fb3993ad 100644 --- a/crates/ui-egui/src/credits_tests.rs +++ b/crates/ui-egui/src/credits_tests.rs @@ -51,10 +51,12 @@ 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::unique("credits_view"), v)); } + /// Read the stored About table view, defaulting when unset. fn view(&self) -> View { self.ctx.data(|d| d.get_temp::(egui::Id::unique("credits_view"))).unwrap_or_default() } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3ec562a2..8d4d8fdb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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); diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 4e724553..341e7cca 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -467,6 +467,8 @@ 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(); @@ -490,6 +492,8 @@ fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() { 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(); @@ -514,6 +518,7 @@ 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(); @@ -584,6 +589,8 @@ 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(); @@ -780,6 +787,8 @@ fn a_deferred_field_save_keeps_its_document_when_the_next_input_switches_tabs() } } +/// 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"] { diff --git a/crates/ui-egui/src/frame_labels.rs b/crates/ui-egui/src/frame_labels.rs index 4c4b0de1..a87fc9f4 100644 --- a/crates/ui-egui/src/frame_labels.rs +++ b/crates/ui-egui/src/frame_labels.rs @@ -58,6 +58,9 @@ 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 { diff --git a/crates/ui-egui/src/frame_presets.rs b/crates/ui-egui/src/frame_presets.rs index 0d1c4b91..9eb28162 100644 --- a/crates/ui-egui/src/frame_presets.rs +++ b/crates/ui-egui/src/frame_presets.rs @@ -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); diff --git a/crates/ui-egui/src/guides.rs b/crates/ui-egui/src/guides.rs index 827597ac..3200ca42 100644 --- a/crates/ui-egui/src/guides.rs +++ b/crates/ui-egui/src/guides.rs @@ -49,6 +49,7 @@ struct Placed { line: Option<[Point; 2]>, } +/// Stable egui id for guide dragging. fn drag_id() -> Id { Id::unique("guide_drag") } diff --git a/crates/ui-egui/src/handles.rs b/crates/ui-egui/src/handles.rs index b7a575fa..d29d59ee 100644 --- a/crates/ui-egui/src/handles.rs +++ b/crates/ui-egui/src/handles.rs @@ -67,6 +67,7 @@ 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::unique("figcraft_handle_drag") } diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index ebea682e..9c809b99 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -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))); @@ -475,6 +477,8 @@ fn matches(template: &Template, query: &str, kind: Option) -> 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| { diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index cc19d6a9..ee25124a 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -248,6 +248,8 @@ fn a_finished_background_render_wakes_the_ui() { assert!(worker.poll().is_some(), "the woken frame finds the image"); } +/// Asserts a control request wakes an idle UI and is answered, and screenshots keep frames +/// coming until delivered before the UI rests again. #[test] fn control_requests_and_screenshots_wake_an_idle_ui() { let (mut h, _) = document(); @@ -341,6 +343,8 @@ fn cjk_names_are_looked_for_in_edits_not_on_every_pan() { assert_eq!(h.app.cjk_needed(&ctx, 0), crate::cjk::HANGUL); } +/// Asserts a hidden window answers edits and inspection at once, while a screenshot is +/// parked with a deadline and times out once instead of waiting for a frame. #[test] fn a_hidden_window_answers_at_once() { // #269, #720: minimised or covered, the window draws no frames, but the app's logic still runs: @@ -424,6 +428,8 @@ fn auto_save_writes_a_changed_file_then_rests() { let _ = std::fs::remove_dir_all(&dir); } +/// Asserts a hidden logic tick runs background work and advances the autosave deadline +/// without counting a UI frame, and is idempotent at the same time. #[test] fn a_hidden_logic_tick_runs_background_work_but_counts_no_ui_frame() { let (mut h, _) = document(); @@ -444,6 +450,7 @@ fn a_hidden_logic_tick_runs_background_work_but_counts_no_ui_frame() { assert_eq!(h.app.ui.next_autosave, deadline, "idempotent at the same monotonic time"); } +/// Asserts a discarded sizing pass counts one drawn frame, not one per pass. #[test] fn a_discarded_sizing_pass_sets_up_and_counts_one_frame_only() { let (mut h, _) = document(); diff --git a/crates/ui-egui/src/ime_tests.rs b/crates/ui-egui/src/ime_tests.rs index e699dda5..e6c2f698 100644 --- a/crates/ui-egui/src/ime_tests.rs +++ b/crates/ui-egui/src/ime_tests.rs @@ -48,6 +48,7 @@ fn ime_descriptor(u: &mut Ui, events: Vec) -> IMEOutput { u.frame_raw(RawInput { events, ..Default::default() }).platform_output.ime.expect("the IME stays on while editing") } +/// Return the available surrounding-text payload of an IME output, panicking when unavailable. fn available(ime: &IMEOutput) -> &IMESurroundingTextPayload { match &ime.surrounding_text { IMESurroundingText::Available(payload) => payload, @@ -55,6 +56,7 @@ fn available(ime: &IMEOutput) -> &IMESurroundingTextPayload { } } +/// Create a text layer with `text` and return its node id. fn text_layer(s: &mut Session, text: &str) -> NodeId { NodeId(s.execute("text.create", &json!({"text": text, "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap()) } @@ -103,6 +105,8 @@ fn pinyin_composes_inline_and_commits_once_with_the_ime_on_every_frame() { assert_eq!(text(&u, t), "Hi你好\n!"); } +/// Asserts an empty IME commit ends the composition, inserts nothing, keeps the editor +/// focused, and the next pass observes the committed text. #[test] fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { // Safari's egui web integration sends `Commit("")` (not `Preedit("")`) when the last @@ -126,6 +130,8 @@ fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { assert_eq!(text(&u, t), "Hello"); } +/// Asserts byte deletion before the paired commit replaces the exact UTF-8 range, and one undo +/// restores the pre-IME text. #[test] fn an_ime_replacing_text_deletes_in_bytes_before_the_paired_commit() { // 0.37 sends `DeleteSurroundingBytes` immediately before the commit that replaces it. The @@ -146,6 +152,8 @@ fn an_ime_replacing_text_deletes_in_bytes_before_the_paired_commit() { assert_eq!(text(&u, t), "ab\u{4F60}\u{597D}cd", "one undo restores the pre-IME text"); } +/// Asserts a caret click invalidates the whole stale IME batch (both byte and character +/// deletions) while a later direct commit still works. #[test] fn a_caret_click_rejects_the_stale_delete_and_commit_in_the_same_input_batch() { figcraft_text::scan_system_fonts(); @@ -185,6 +193,8 @@ fn a_caret_click_rejects_the_stale_delete_and_commit_in_the_same_input_batch() { } } +/// Asserts a control-channel document switch rejects the old composition's delete and commit +/// batch, while a direct commit to the new target remains valid. #[test] fn a_document_switch_rejects_the_old_compositions_delete_and_commit_batch() { figcraft_text::scan_system_fonts(); @@ -214,6 +224,8 @@ fn a_document_switch_rejects_the_old_compositions_delete_and_commit_batch() { assert_eq!(text(&u, b), "BravoX", "a direct commit to the new noncomposing target remains valid"); } +/// Asserts the first observation and ordinary typing are `Other`, while an accepted commit is +/// `InputMethod` with UTF-8 offsets, the same owner and generation. #[test] fn ime_cause_is_input_method_only_for_an_accepted_commit() { figcraft_text::scan_system_fonts(); @@ -240,6 +252,8 @@ fn ime_cause_is_input_method_only_for_an_accepted_commit() { assert_eq!(text(&u, t), "Hi你好x"); } +/// Asserts a frame mixing IME and application edits is `Other`, and a later pure commit is +/// `InputMethod` without lingering state. #[test] fn ime_cause_reports_mixed_application_and_ime_mutations_as_other() { figcraft_text::scan_system_fonts(); @@ -267,6 +281,7 @@ fn ime_cause_reports_mixed_application_and_ime_mutations_as_other() { assert_eq!(text(&u, t), "Hiあbうcえおdか"); } +/// Asserts a control-channel text edit is an application change (`Other`). #[test] fn a_control_channel_text_edit_is_an_application_change() { figcraft_text::scan_system_fonts(); @@ -283,6 +298,8 @@ fn a_control_channel_text_edit_is_an_application_change() { assert_eq!(available(&out).text, "Hiz"); } +/// Asserts a byte delete followed by a commit is `InputMethod` and describes the +/// post-mutation text and caret. #[test] fn ime_delete_then_commit_is_input_method_and_describes_the_post_mutation_text() { figcraft_text::scan_system_fonts(); @@ -302,6 +319,8 @@ fn ime_delete_then_commit_is_input_method_and_describes_the_post_mutation_text() assert_eq!(text(&u, t), "abXd"); } +/// Asserts preedit text is excluded from surrounding text and earns no receipt, while the +/// following commit is `InputMethod`. #[test] fn preedit_is_not_surrounding_text_but_the_commit_that_follows_is_input_method() { figcraft_text::scan_system_fonts(); @@ -322,6 +341,8 @@ fn preedit_is_not_surrounding_text_but_the_commit_that_follows_is_input_method() assert_eq!(available(&committed).cursor, 8); } +/// Asserts surrounding text is logical UTF-8 with byte offsets and keeps a reversed +/// selection. #[test] fn ime_surrounding_text_is_logical_utf8_and_keeps_a_reversed_selection() { figcraft_text::scan_system_fonts(); @@ -339,6 +360,8 @@ fn ime_surrounding_text_is_logical_utf8_and_keeps_a_reversed_selection() { assert!(payload.cursor < payload.anchor, "the reversed selection is not sorted"); } +/// Asserts empty text is available while a selection wider than the 4000-byte budget is +/// explicitly unavailable but still has a nonzero IME context generation. #[test] fn empty_text_is_available_while_a_too_wide_selection_is_explicitly_unavailable() { figcraft_text::scan_system_fonts(); @@ -361,6 +384,7 @@ fn empty_text_is_available_while_a_too_wide_selection_is_explicitly_unavailable( assert!(wide.context_generation > 0, "the owner stays known when only the text is unavailable"); } +/// Asserts replacing a too-wide selection with a commit is still an `InputMethod` receipt. #[test] fn a_commit_replacing_a_too_wide_selection_is_still_an_input_method_receipt() { figcraft_text::scan_system_fonts(); @@ -382,6 +406,8 @@ fn a_commit_replacing_a_too_wide_selection_is_still_an_input_method_receipt() { assert_eq!((available(&out).cursor, available(&out).anchor), (1, 1)); } +/// Asserts another text node is another owner, and re-entering the same node changes the +/// context generation while keeping the owner key. #[test] fn a_new_edit_session_and_another_text_node_are_new_ime_owners() { figcraft_text::scan_system_fonts(); @@ -405,6 +431,8 @@ fn a_new_edit_session_and_another_text_node_are_new_ime_owners() { assert_ne!(again.context_generation, first.context_generation, "re-entering is a replaced target"); } +/// Asserts an accepted commit survives a discarded rerun without inserting twice and keeps +/// its `InputMethod` cause, while a mixed rerun is `Other`. #[test] fn an_accepted_commit_survives_a_discarded_pass_without_reapplying_or_losing_its_cause() { figcraft_text::scan_system_fonts(); @@ -428,6 +456,9 @@ fn an_accepted_commit_survives_a_discarded_pass_without_reapplying_or_losing_its assert_eq!(mixed.change_cause, ImeChangeCause::Other); } +/// Asserts IME context state is scoped per viewport: each viewport has its own generation, +/// the root keeps its context across an aux pass, and a closed aux viewport does not inherit +/// the old generation. #[test] fn ime_context_state_is_scoped_per_viewport() { figcraft_text::scan_system_fonts(); @@ -480,6 +511,7 @@ fn ime_context_state_is_scoped_per_viewport() { assert_ne!(aux_again.context_generation, aux_ime.context_generation); } +/// Asserts the engine's IME surrounding-text byte limit equals egui's wire limit. #[test] fn the_engine_surrounding_limit_matches_the_wire_limit() { // A divergence would truncate or overrun the bounded payload silently. @@ -488,6 +520,8 @@ fn the_engine_surrounding_limit_matches_the_wire_limit() { assert_eq!(engine, wire); } +/// Asserts a frame observes the IME exactly once in both the editor pass and the Home +/// early-return pass. #[test] fn a_frame_observes_the_ime_once_even_when_it_ends_early() { figcraft_text::scan_system_fonts(); @@ -506,6 +540,8 @@ fn a_frame_observes_the_ime_once_even_when_it_ends_early() { assert_eq!(u.app.ime_observations - before, 1, "one idle observation on the home screen"); } +/// Asserts a double-click inside an existing edit selects the word without changing the IME +/// owner or generation. #[test] fn a_double_click_inside_the_edit_keeps_the_ime_generation() { figcraft_text::scan_system_fonts(); @@ -536,6 +572,8 @@ fn a_double_click_inside_the_edit_keeps_the_ime_generation() { assert_eq!(available(&after).text, "Hello world"); } +/// Asserts a `window.new` that moves the edited file leaves the root window emitting no IME +/// descriptor. #[test] fn a_window_new_that_moves_the_edited_file_leaves_the_root_ime_idle() { figcraft_text::scan_system_fonts(); @@ -560,6 +598,8 @@ fn a_window_new_that_moves_the_edited_file_leaves_the_root_ime_idle() { assert!(out.platform_output.ime.is_none(), "the root must not emit a descriptor for a file it no longer shows"); } +/// Asserts a CRLF-normalized commit is application-origin (`Other`), and bare-newline and +/// control-only commits insert nothing. #[test] fn a_transformed_commit_is_application_origin_and_a_bare_newline_does_nothing() { figcraft_text::scan_system_fonts(); @@ -582,6 +622,8 @@ fn a_transformed_commit_is_application_origin_and_a_bare_newline_does_nothing() assert!(matches!(control.surrounding_text, IMESurroundingText::Available(_))); } +/// Asserts character deletion keeps the selection for the paired commit, which replaces it +/// and reports `InputMethod`. #[test] fn ime_delete_surrounding_chars_keeps_the_selection_for_the_paired_commit() { figcraft_text::scan_system_fonts(); diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 42a9569e..0ce4fa7a 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -134,6 +134,8 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) { } } +/// Draw the left panel: versions when open, otherwise the file/Assets header with in-place +/// file rename and collapse control, followed by Pages and Layers or the Assets panel. pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { if app.ui.versions.open { crate::versions::panel(app, ui); @@ -173,6 +175,8 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { } } +/// Draw the page list with open/search/add controls, filtering, selection, rename, +/// reorder/drag and layer drop-to-page; Dev Mode allows opening pages but blocks mutations. fn pages(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); // Dev Mode only inspects: rows open pages, but don't add, rename, reorder, duplicate or delete. @@ -444,6 +448,7 @@ struct Sweep { ids: Vec, } +/// Stable egui id for the eye/padlock sweep state. fn sweep_id() -> Id { Id::unique("layer_toggle_sweep") } @@ -654,6 +659,7 @@ fn rename_layer(app: &mut FigApp, id: NodeId) { app.ui.layers_seen_sel = app.session.selection(); } +/// Stable egui id for a layer row's rename field. fn rename_id(id: NodeId) -> Id { Id::unique(("rename", id)) } @@ -722,6 +728,8 @@ fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut } } +/// Draw the layer tree with search, cached rows, reveal-selection expansion and scrolling, +/// rename/Tab navigation, selection and drag/drop, sweep toggles and Dev Mode restrictions. fn layers(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); // Dev Mode only inspects: rows select and expand, but don't rename, reorder, hide or lock. diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index e801ad00..0f312499 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -312,6 +312,7 @@ fn pages_down_to(u: &mut Ui, to: f32) -> Vec { opened } +/// Asserts a 60-page list scrolls with the wheel to its last page, past its first pages. #[test] fn a_long_pages_list_scrolls_and_leaves_room_for_the_layers() { let mut s = Session::new(); @@ -383,6 +384,8 @@ fn double_click_checking_focus(u: &mut Ui, p: Pos2) { } } +/// Asserts double-clicking a page selects and renames it, with the keyboard in its name +/// field for the screen-reader tree. #[test] fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/layers_rename_tests.rs b/crates/ui-egui/src/layers_rename_tests.rs index 6ffa9696..c1c229eb 100644 --- a/crates/ui-egui/src/layers_rename_tests.rs +++ b/crates/ui-egui/src/layers_rename_tests.rs @@ -144,6 +144,8 @@ fn rename_without_a_name_renames_one_row_or_opens_the_dialog() { assert!(std::sync::Arc::ptr_eq(&doc, &s.ds().unwrap().doc)); } +/// Asserts a layer rename continues after its row is scrolled out of view and commits on +/// Enter. #[test] fn a_rename_scrolled_out_of_view_goes_on() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index eef37755..0874da2f 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -283,6 +283,8 @@ fn add_slot(ui: &mut Ui, label: &str, tip: &str, width: f32) -> egui::Response { resp } +/// Draw the 3×3 main/cross alignment grid, auto-gap modes, grid column/row gaps and the +/// horizontal baseline checkbox. fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let t = Tokens::get(ui.ctx()); let grid_mode = l.mode == LayoutMode::Grid; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d3c17453..6f0effd8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -488,6 +488,7 @@ pub struct FrameClock { } impl Default for FrameClock { + /// Start the frame clock at the current web time with no injected test time. fn default() -> Self { Self { origin: web_time::Instant::now(), test_now: None } } @@ -722,6 +723,9 @@ pub struct UiState { } impl Default for UiState { + /// Seed the default UI state (panel widths, tabs, pages, Home, presenters, pickers and + /// dialogs off, and the rest of the transient UI fields); `FigApp::new` overrides panel + /// widths and the pages height from preferences. fn default() -> Self { Self { left_width: PANEL_DEFAULT, @@ -915,6 +919,9 @@ pub struct FigApp { pub type Done = Box; impl FigApp { + /// Construct `FigApp` from a session and services, taking panel widths and pages height + /// from preferences and initializing UI, canvas, clocks, capture/IME and transient channel + /// state. pub fn new(session: Session, services: Box) -> Self { // The main window's panels open as wide as they were left. let (left_width, right_width) = session.prefs.panel_widths; @@ -2044,6 +2051,9 @@ impl FigApp { } } + /// Draw the toast: start its lifetime on the frame **after** the first drawn frame, request + /// that frame and the frame that hides it, expire and remove it at the deadline, and place it + /// at the bottom centre. fn toast_ui(&mut self, ctx: &egui::Context) { let Some((msg, clock)) = &mut self.ui.toast else { return }; let now = ctx.input(|i| i.time); diff --git a/crates/ui-egui/src/live_join_tests.rs b/crates/ui-egui/src/live_join_tests.rs index 81eddc76..bc1db8ad 100644 --- a/crates/ui-egui/src/live_join_tests.rs +++ b/crates/ui-egui/src/live_join_tests.rs @@ -14,6 +14,7 @@ fn escape(h: &mut Harness) { } } +/// Whether the Join link field currently has focus. fn link_focused(h: &Harness) -> bool { h.ctx.memory(|m| m.has_focus(egui::Id::unique("live_join_link"))) } diff --git a/crates/ui-egui/src/live_server_tests.rs b/crates/ui-egui/src/live_server_tests.rs index 0bf1e631..014be152 100644 --- a/crates/ui-egui/src/live_server_tests.rs +++ b/crates/ui-egui/src/live_server_tests.rs @@ -20,6 +20,7 @@ fn key(h: &mut Harness, key: Key) { } } +/// Whether the widget with the given id currently has focus. fn focused(h: &Harness, id: &str) -> bool { h.ctx.memory(|m| m.has_focus(egui::Id::unique(id))) } diff --git a/crates/ui-egui/src/live_share_tests.rs b/crates/ui-egui/src/live_share_tests.rs index 06d902bd..d7fc61c8 100644 --- a/crates/ui-egui/src/live_share_tests.rs +++ b/crates/ui-egui/src/live_share_tests.rs @@ -385,6 +385,9 @@ fn without_a_code_the_link_is_shared_instead() { assert_eq!(script.lock().unwrap().copied.last().map(String::as_str), Some(PLAIN)); } +/// Asserts the join field accepts a typed code or invite link, joins on Enter, clears after +/// joining, shows failure/retry, rejects an invalid code with an error, and accepts local +/// codes and links. #[test] fn the_join_field_takes_a_code_or_a_link_and_enter_joins() { let (mut h, script) = harness(); @@ -450,6 +453,8 @@ fn the_home_screen_joins_with_a_code_and_opens_the_file() { assert_eq!(h.app.session.docs.len(), 2); } +/// Asserts the join dialog uses the same shared join field, focuses it, and closes once the +/// join starts. #[test] fn the_join_dialog_is_the_same_field() { let (mut h, script) = harness(); diff --git a/crates/ui-egui/src/live_tests.rs b/crates/ui-egui/src/live_tests.rs index eaff7354..63c3c42a 100644 --- a/crates/ui-egui/src/live_tests.rs +++ b/crates/ui-egui/src/live_tests.rs @@ -290,6 +290,8 @@ fn cursors_follow_pages_in_a_shared_document() { assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Hana left")); } +/// Asserts clicking a peer's avatar follows that peer, and Escape or scrolling the canvas +/// stops following. #[test] fn clicking_an_avatar_follows_and_escape_stops() { let (mut h, _) = Harness::new(); diff --git a/crates/ui-egui/src/menu_aim_tests.rs b/crates/ui-egui/src/menu_aim_tests.rs index ed167e12..6f7c4b8a 100644 --- a/crates/ui-egui/src/menu_aim_tests.rs +++ b/crates/ui-egui/src/menu_aim_tests.rs @@ -87,6 +87,7 @@ impl Ui { const TOPS: [&str; 6] = ["File", "Edit", "View", "Object", "Text", "Arrange"]; +/// Stable id for a top-level menu in the menu-aim test harness. fn top(name: &str) -> Id { Id::unique(("menu", name)) } @@ -145,6 +146,8 @@ fn a_reopened_main_menu_shows_no_stale_submenu() { assert!(u.tops_shown().is_empty(), "reopened with {:?} open", u.tops_shown()); } +/// Asserts leaving a top menu and returning to it does not restore the submenu that was open +/// before. #[test] fn a_submenu_one_level_down_reopens_closed() { let mut u = Ui::new(); diff --git a/crates/ui-egui/src/menu_input_tests.rs b/crates/ui-egui/src/menu_input_tests.rs index a50173bc..6fc65533 100644 --- a/crates/ui-egui/src/menu_input_tests.rs +++ b/crates/ui-egui/src/menu_input_tests.rs @@ -69,6 +69,8 @@ fn batch_rename_opens_its_existing_dialog() { assert!(ui.app.ui.last_error.is_none()); } +/// Asserts `file.rename` opens the left panel's inline file-name editor (not a dialog), +/// focuses it, and commits the typed name on Enter. #[test] fn rename_file_edits_the_file_name_in_place() { let mut ui = Ui::new(Session::new()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e73d242d..e5ddb899 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -478,10 +478,12 @@ struct At { path: String, } +/// Scoped egui id for the current menu level. fn at_key(ui: &Ui) -> Id { ui.scope_id().with("menu_level") } +/// Return the current menu root/path, defaulting to the main menu root. fn level_at(ui: &Ui) -> At { ui.data(|d| d.get_temp::(at_key(ui))).unwrap_or(At { root: Id::unique("menus"), path: String::new() }) } @@ -817,6 +819,9 @@ const TOP_MENUS: &[&str] = &[ tr_noop("Help"), ]; +/// Draw the main and context menu trees: forget closed/stale submenu state, apply +/// control-channel Select-layer open/close, close on outside click or Escape, then draw the +/// colour picker and palette popups. pub fn popups(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); // A closed menu forgets its open submenus (and a context menu opened somewhere else starts @@ -1020,6 +1025,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { /// The context menu's Select layer submenu (its key in the context menu's tree). const SELECT_LAYER: &str = "Select layer"; +/// Stable egui id for the context menu's pending Select-layer open request. fn select_layer_ask_id() -> Id { Id::unique("ctx_select_layer_ask") } diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs index a2e8d3ee..1608902d 100644 --- a/crates/ui-egui/src/panel_remove_tests.rs +++ b/crates/ui-egui/src/panel_remove_tests.rs @@ -64,10 +64,13 @@ fn remove_button(u: &mut Ui, list: &str, index: usize) -> Pos2 { .unwrap_or_else(|| panic!("no Remove button in {list} row {index} {row:?} {all:?}")) } +/// Read a temporary boolean open-settings flag keyed by `salt` and row index. fn flag(u: &Ui, salt: &'static str, i: usize) -> bool { u.ctx.data(|d| d.get_temp::(Id::unique((salt, i)))).unwrap_or(false) } +/// Asserts removing a fill keeps the open picker on the surviving paint (with its index +/// shifted), and removing the paint being edited closes the picker. #[test] fn removing_a_fill_keeps_the_picker_on_its_paint() { let mut s = Session::new(); @@ -93,6 +96,8 @@ fn removing_a_fill_keeps_the_picker_on_its_paint() { assert_eq!(u.app.ui.picker, None); } +/// Asserts removing effects or grids moves open settings and an open colour picker to the +/// surviving row, or closes them when their own row is removed. #[test] fn removing_effects_and_grids_keeps_open_settings_with_their_rows() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/panel_reorder_tests.rs b/crates/ui-egui/src/panel_reorder_tests.rs index 54caa687..ffeb948e 100644 --- a/crates/ui-egui/src/panel_reorder_tests.rs +++ b/crates/ui-egui/src/panel_reorder_tests.rs @@ -73,6 +73,8 @@ fn escape_cancels_a_row_drag() { assert_eq!(u.app.session.ds().unwrap().selection, [r], "the Escape only cancels the drag"); } +/// Asserts effect and layout-grid rows reorder by drag, while a mixed fill list exposes no +/// drag handle. #[test] fn effects_and_grids_drag_too_and_mixed_lists_dont() { let (mut u, r) = setup(); diff --git a/crates/ui-egui/src/perf_tests.rs b/crates/ui-egui/src/perf_tests.rs index 7b5d1212..eba5e68c 100644 --- a/crates/ui-egui/src/perf_tests.rs +++ b/crates/ui-egui/src/perf_tests.rs @@ -68,6 +68,8 @@ impl Bench { } } +/// Build and print a synthetic UI benchmark report for the given layer count; it makes no +/// assertions. fn report(layers: usize) { let mut b = Bench::new(layers); let mut rows: Vec<(&str, f64)> = Vec::new(); @@ -180,6 +182,7 @@ fn design_system(icons: usize, instances: usize) -> (Document, figcraft_engine:: figcraft_engine::synthetic_ds::design_system_with_instances(&spec, instances).unwrap() } +/// Build a test mouse-wheel event for the given vertical delta. fn wheel(d: f32) -> Event { Event::MouseWheel { unit: egui::MouseWheelUnit::Point, diff --git a/crates/ui-egui/src/plugin_panel_tests.rs b/crates/ui-egui/src/plugin_panel_tests.rs index 3b0f2746..53b1ba56 100644 --- a/crates/ui-egui/src/plugin_panel_tests.rs +++ b/crates/ui-egui/src/plugin_panel_tests.rs @@ -89,6 +89,9 @@ fn an_open_plugin_panel_draws_no_frames_while_idle() { Harness::new(session()).assert_idle("a plugin panel"); } +/// Asserts a plugin colour field's swatch opens the picker on that field, a picked colour +/// reaches the plugin, and a second plugin's panel opens while the first remains (two panels +/// present). #[test] fn a_colour_field_opens_the_colour_picker_and_two_panels_show_side_by_side() { let mut u = Ui::new(session()); diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 83295359..26479c4d 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -245,6 +245,9 @@ const INSTALL: &str = "plugin.directory.install"; const MANAGE_W: f32 = 400.0; const ROW_TEXT_W: f32 = 300.0; +/// Draw the Plugins window with installed and browse tabs, dispatching remove, import, +/// browse, confirm and stop actions; closing the window cancels its directory requests and +/// clears confirmation. fn manage_window(app: &mut FigApp, ctx: &Context) { let t = Tokens::get(ctx); let mut open = true; @@ -509,6 +512,8 @@ fn summary(p: &InstalledPlugin) -> String { parts.join(" · ") } +/// Draw a plugin command's parameter form: close it when the plugin or command disappeared, +/// edit values and colour picks, run `plugin.run` on Run, and close on Cancel. fn form_window(app: &mut FigApp, ctx: &Context) { let Some(mut form) = app.ui.plugin_form.clone() else { return }; let Some((name, command)) = app diff --git a/crates/ui-egui/src/present_tests.rs b/crates/ui-egui/src/present_tests.rs index 4679c15d..93729922 100644 --- a/crates/ui-egui/src/present_tests.rs +++ b/crates/ui-egui/src/present_tests.rs @@ -137,6 +137,8 @@ fn preview_pane_opens_follows_edits_and_the_selection() { assert!(u.app.ui.preview.is_none()); } +/// Asserts the preview's device viewport and scroll range, wheel and drag scrolling, the +/// end clamp with spring-back, and that navigation resets scroll to the top. #[test] fn the_frame_scrolls_on_the_device_screen_by_wheel_and_drag() { let (mut u, a, b, _) = setup(); diff --git a/crates/ui-egui/src/present_view_tests.rs b/crates/ui-egui/src/present_view_tests.rs index 97fbba03..361a4caf 100644 --- a/crates/ui-egui/src/present_view_tests.rs +++ b/crates/ui-egui/src/present_view_tests.rs @@ -135,6 +135,8 @@ fn num(v: &Value) -> f64 { v.as_f64().unwrap() } +/// Asserts presenter fit/fit-width/fill/actual scaling, pan ranges, wheel and drag panning, +/// option carry-over to the next presentation, and R restarting the flow. #[test] fn presenter_scales_by_its_options_and_pans_what_overflows() { let (mut u, big) = big(); @@ -272,6 +274,7 @@ fn scrolling_keeps_its_momentum_and_springs_back_from_the_ends() { assert_eq!(u.preview()["flinging"], json!(false)); } +/// Asserts wheel momentum continues after input stops and then comes to rest. #[test] fn the_wheel_coasts_after_it_stops() { let (mut u, a, ..) = setup(); diff --git a/crates/ui-egui/src/proto_canvas.rs b/crates/ui-egui/src/proto_canvas.rs index 1faba513..b5d402b4 100644 --- a/crates/ui-egui/src/proto_canvas.rs +++ b/crates/ui-egui/src/proto_canvas.rs @@ -20,6 +20,7 @@ struct Drag { pos: Pos2, } +/// Stable egui id for prototype connection dragging. fn drag_id() -> Id { Id::unique("proto_canvas_drag") } @@ -59,6 +60,8 @@ struct Sources { ids: Arc>, } +/// Return the cached subtree nodes with interactions for `page`, recomputing only when the +/// document pointer or page changes. pub(crate) fn sources(ctx: &egui::Context, doc: &Arc, page: NodeId) -> Arc> { ctx.data_mut(|d| { let c = d.get_temp_mut_or_default::(Id::unique("proto_canvas_sources")); diff --git a/crates/ui-egui/src/proto_editor.rs b/crates/ui-egui/src/proto_editor.rs index cc3c352b..7163807e 100644 --- a/crates/ui-egui/src/proto_editor.rs +++ b/crates/ui-egui/src/proto_editor.rs @@ -129,6 +129,7 @@ fn unavailable(label: &str) -> &'static str { } } +/// Stable egui id for a node interaction's open action path. fn open_id(cx: &Cx) -> Id { Id::unique(("proto_open_action", cx.node, cx.k)) } diff --git a/crates/ui-egui/src/proto_editor_tests.rs b/crates/ui-egui/src/proto_editor_tests.rs index e6838a0a..63c152e1 100644 --- a/crates/ui-egui/src/proto_editor_tests.rs +++ b/crates/ui-egui/src/proto_editor_tests.rs @@ -78,6 +78,9 @@ fn condition(u: &Ui, btn: NodeId, block: usize) -> String { blocks[block].condition.as_ref().map(|c| c.display(&doc.variables)).unwrap_or_default() } +/// Asserts the interaction editor shows conditional blocks and nested actions, restarts a +/// condition when a variable of another type is picked, ignores a right-hand-side variable pick +/// for a complex condition, and one undo restores the previous condition. #[test] fn the_interaction_editor_shows_conditionals_and_picks_their_variables() { let (mut s, _, b, btn) = setup(); @@ -154,6 +157,8 @@ fn the_builder_takes_variable_operator_value_conditions() { } } +/// Asserts invalid typed conditions keep their draft and show errors without saving, a valid +/// one clears the error and saves, and Enter does not leak to shortcuts. #[test] fn typed_conditions_are_checked_and_errors_show_until_fixed() { let (mut s, _, b, btn) = setup(); diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 972f068b..1b2e2df9 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -426,6 +426,8 @@ fn flows_overview(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { } } +/// Draw prototype settings: device and rotation, device colour, custom size, responsive +/// toggle and page background, running `proto.settings`. fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { if widgets::section_header(ui, tr("Prototype settings"), &[(Icon::Preview, tr("Open prototype preview"))], false).is_some() { app.run("proto.preview", json!({})); @@ -552,6 +554,7 @@ fn custom_size(app: &mut FigApp, ui: &mut Ui, model: Option) { }); } +/// Show, create or preview the flow starting at `frame` and edit its description. fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, frame: NodeId) { let flows = flows_of(doc, page); let mine = flows.iter().find(|f| f.start == frame).cloned(); @@ -579,6 +582,8 @@ fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, fra } } +/// List a node's interactions, add a default click navigation (or Back), open the editor +/// popover and remove interactions. fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, node: &Node) { let t = Tokens::get(ui.ctx()); let id = node.id; @@ -696,6 +701,7 @@ fn animates(a: &Action) -> bool { ) } +/// Stable egui id for the prototype key-capture flag. fn capture_id() -> Id { Id::unique("proto_key_capture") } @@ -704,6 +710,8 @@ fn capturing(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::(capture_id())).unwrap_or(false) } +/// Draw an interaction's trigger controls: trigger picker, mouse/delay fields, key capture +/// and controller-button selection, patching the interaction. fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Interaction) { let t = Tokens::get(ui.ctx()); widgets::sublabels(ui, tr("Trigger"), None); @@ -1021,6 +1029,8 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: } } +/// Draw an interaction's animation controls: transition, direction, easing, duration and +/// custom easing fields, plus an animated curve preview. fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Interaction) { let t = Tokens::get(ui.ctx()); widgets::sublabels(ui, tr("Animation"), None); diff --git a/crates/ui-egui/src/proto_reorder_tests.rs b/crates/ui-egui/src/proto_reorder_tests.rs index 1f70f48d..d69be4f6 100644 --- a/crates/ui-egui/src/proto_reorder_tests.rs +++ b/crates/ui-egui/src/proto_reorder_tests.rs @@ -44,6 +44,7 @@ fn setup() -> (Ui, NodeId) { (u, btn) } +/// Stable id for a prototype node's open action path in the reorder tests. fn open_id(btn: NodeId) -> egui::Id { egui::Id::unique(("proto_open_action", btn, 0usize)) } diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index e2dca4a6..a084b9d0 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -78,6 +78,8 @@ impl FigApp { } } +/// Draw the right panel: people, present and share controls, Design/Prototype tabs and zoom +/// menu, then route the scroll area to comments, frame presets, design, prototype or inspect. pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); @@ -393,6 +395,8 @@ fn design(app: &mut FigApp, ui: &mut Ui) { crate::export_panel::section(app, ui, &doc, &sel); } +/// Draw the page section: background and colour profile, local variables/styles, and the +/// page export section. fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { let t = Tokens::get(ui.ctx()); let Ok(page) = app.session.page() else { return }; @@ -455,6 +459,8 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { crate::export_panel::page_section(app, ui, doc, page); } +/// Draw the selection header: type name (or Mixed), frame/group conversion, orientation +/// swap and frame presets, component/mask/boolean quick actions, and instance details. fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let t = Tokens::get(ui.ctx()); let label = match common(doc, sel, |n| n.kind.label()) { @@ -589,6 +595,8 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ui.add_space(8.0); } +/// Draw alignment and distribution, X/Y and rotation with flip/rotate controls, the +/// absolute-position toggle and frame constraints. fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let col = widgets::col_width(ui.available_width()); @@ -807,6 +815,8 @@ fn pretty(s: &str) -> String { } } +/// Draw visibility/blend/mask, opacity, uniform or per-corner radius, corner smoothing and +/// the blend-mode row. fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let visible = common(doc, sel, |n| n.visible).unwrap_or(true); @@ -1080,6 +1090,8 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: }); } +/// Draw a selected-text range fill, or the fill/stroke paint rows with add, style, reorder, +/// stroke align/weight, per-side weights and advanced stroke settings. fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: bool) { if fills && let Some((id, a, b)) = figcraft_engine::textedit::selected_range(&app.session) @@ -1641,6 +1653,8 @@ fn paint_label(p: &Paint) -> String { } } +/// Draw the effect list with add/style/type/toggle/remove/reorder, open settings, and keep an +/// open picker and settings flags aligned when rows move or are removed. fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let list = common(doc, sel, |n| n.effects.clone()); @@ -1723,6 +1737,8 @@ fn fx_field(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, prop: &str, crate::variable_ui::number_field(app, ui, ids, &format!("effects/{i}/{prop}"), Id::unique(("fx", prop, i)), lead, Some(value), "", width) } +/// Draw type-specific effect fields for shadows, blurs, noise, texture and glass, using live +/// scrub edits that stay one undo step and bindable colour fields. fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect) { let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0; let set = |app: &mut FigApp, e2: Effect, scrub: Option| { @@ -1959,6 +1975,8 @@ fn fx_color(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect, } } +/// Draw the layout-grid list with add/style/type/toggle/remove/reorder, per-grid settings, +/// and keep open-settings flags aligned when rows move or are removed. fn grids(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let list = common(doc, sel, |n| n.grids.clone()); diff --git a/crates/ui-egui/src/rtl_names_tests.rs b/crates/ui-egui/src/rtl_names_tests.rs index c88ab267..22f59817 100644 --- a/crates/ui-egui/src/rtl_names_tests.rs +++ b/crates/ui-egui/src/rtl_names_tests.rs @@ -34,6 +34,7 @@ fn caret_x(ctx: &egui::Context, text: &str, cursor: CCursor) -> f32 { ctx.fonts_mut(|f| f.layout_no_wrap(text.to_string(), regular(11.0), Color32::WHITE).pos_from_cursor(cursor).min.x) } +/// Read the primary character cursor of a test text field. fn cursor(ctx: &egui::Context, id: Id) -> CCursor { egui::TextEdit::load_state(ctx, id).unwrap().cursor.char_range().unwrap().primary } @@ -75,6 +76,8 @@ fn fonts() -> Option { Some(ctx) } +/// Asserts names keep their logical text while glyphs paint in visual order, including +/// ellipsis placement at the overflow end and mixed-direction rows. #[test] fn names_keep_logical_text_and_paint_in_visual_order() { let Some(ctx) = fonts() else { return }; @@ -137,6 +140,8 @@ fn names_keep_logical_text_and_paint_in_visual_order() { }); } +/// Asserts Arabic letters take joined contextual forms and truncation puts the ellipsis at +/// the visual end of a right-to-left row. #[test] fn arabic_joining_and_truncation_use_the_visual_end() { let Some(ctx) = fonts() else { return }; @@ -177,6 +182,8 @@ fn arabic_joining_and_truncation_use_the_visual_end() { }); } +/// Asserts the shaper mirrors right-to-left parentheses in both glyph order and the atlas +/// shapes chosen for them. #[test] fn rtl_brackets_are_mirrored_by_the_shaper() { let Some(ctx) = fonts() else { return }; @@ -211,6 +218,8 @@ fn rtl_brackets_are_mirrored_by_the_shaper() { }); } +/// Asserts Right/Left move the caret visually while logical offsets jump, Shift extends +/// visually, and typing inserts at the logical caret; Latin text keeps egui's own caret. #[test] fn rename_caret_moves_visually_with_logical_offsets() { let Some(ctx) = fonts() else { return }; @@ -266,6 +275,8 @@ fn rename_caret_moves_visually_with_logical_offsets() { assert_eq!(caret(&ctx, lid).0, 2); } +/// Asserts clicks map right-to-left visual positions to logical carets, so a click at the +/// visual left end leaves the caret at the logical end and typing appends there. #[test] fn clicks_place_the_logical_caret_by_what_is_shown() { let Some(ctx) = fonts() else { return }; diff --git a/crates/ui-egui/src/section_panel.rs b/crates/ui-egui/src/section_panel.rs index 586a5597..f3240d57 100644 --- a/crates/ui-egui/src/section_panel.rs +++ b/crates/ui-egui/src/section_panel.rs @@ -48,6 +48,9 @@ pub fn status_dropdown(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[Nod }); } +/// Draw section colour and hide-contents controls, Dev status, changed-reason Resolve, the +/// last-change note and Resize to fit; returns for an empty selection or one without a Dev +/// status. pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let sections = !sel.is_empty() && sel.iter().all(|i| is_section(doc, *i)); if sel.is_empty() || !sel.iter().all(|i| has_status(doc, *i)) { diff --git a/crates/ui-egui/src/select_all_tests.rs b/crates/ui-egui/src/select_all_tests.rs index a68a6854..d86de12d 100644 --- a/crates/ui-egui/src/select_all_tests.rs +++ b/crates/ui-egui/src/select_all_tests.rs @@ -45,6 +45,7 @@ fn selection(ctx: &egui::Context, te: Id) -> Option<(usize, usize)> { Some((a.min(b), a.max(b))) } +/// Asserts clicking a hex field focuses it and selects all six characters. #[test] fn a_click_into_a_hex_field_selects_it_all() { let ctx = egui::Context::default(); @@ -57,6 +58,7 @@ fn a_click_into_a_hex_field_selects_it_all() { assert_eq!(selection(&ctx, id.with("te")), Some((0, 6))); } +/// Asserts clicking an inline field selects it all and typing replaces the selection. #[test] fn a_click_into_an_inline_field_selects_it_all() { // A fill's hex in the Design panel, a variable's value, a rename. @@ -75,6 +77,7 @@ fn a_click_into_an_inline_field_selects_it_all() { assert_eq!(out.as_deref(), Some("red")); } +/// Asserts clicking a search box selects its whole query. #[test] fn a_click_into_a_search_box_selects_its_query() { let ctx = egui::Context::default(); @@ -87,6 +90,7 @@ fn a_click_into_a_search_box_selects_its_query() { assert_eq!(selection(&ctx, id), Some((0, 6))); } +/// Asserts Tab focus selects a field's whole value. #[test] fn tab_into_a_field_selects_it_all() { let ctx = egui::Context::default(); @@ -106,6 +110,7 @@ fn tab_into_a_field_selects_it_all() { assert_eq!(selection(&ctx, id), Some((0, 7))); } +/// Asserts a drag selects a non-empty proper subrange rather than the whole value. #[test] fn a_drag_selects_only_what_it_covers() { let ctx = egui::Context::default(); @@ -120,6 +125,7 @@ fn a_drag_selects_only_what_it_covers() { assert!(!(a == 0 && b == q.chars().count()), "not all of it: {a}..{b}"); } +/// Asserts re-clicking a field after focus was lost mid-edit selects all. #[test] fn a_field_left_mid_edit_selects_all_on_the_next_click() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/selection_colors.rs b/crates/ui-egui/src/selection_colors.rs index c699788b..682b37fe 100644 --- a/crates/ui-egui/src/selection_colors.rs +++ b/crates/ui-egui/src/selection_colors.rs @@ -24,6 +24,9 @@ struct Cache { colors: Arc>, } +/// Return the cached selection colours when the document and selection match. During an +/// interaction reuse an existing matching cache, or return an empty list without discovering +/// colours; otherwise discover and cache them. fn colors(ui: &Ui, document: u64, doc: &Arc, ids: &[NodeId], interacting: bool) -> Arc> { let key = Id::unique("selection_colors_cache"); if let Some(cache) = ui.data(|d| d.get_temp::(key)) @@ -49,6 +52,9 @@ pub(crate) fn open_picker(app: &mut FigApp, targets: Vec) { app.ui.picker = Some(PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid }); } +/// Draw the selection-colour groups (at least two): swatch opens the picker, hex and opacity +/// edits, style/variable detach/apply/bind, and “select layers with this color”; disabled +/// during an interaction. pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId]) { let Ok(ds) = app.session.ds() else { return }; let interacting = ds.interaction.is_some(); @@ -249,6 +255,8 @@ mod tests { assert!(!p.frame(vec![]).iter().any(|(s, _)| s == "Selection colors")); } + /// Asserts the panel reuses cached colours through a live interaction and refreshes the cache + /// after the commit. #[test] fn panel_keeps_cached_colors_during_interaction_and_refreshes_after_commit() { let (mut p, a, _) = Panel::new(); @@ -268,6 +276,8 @@ mod tests { assert!(!Arc::ptr_eq(&before.colors, &after.colors)); } + /// Asserts no cached colours are discovered during an interaction, and cancelling restores + /// the panel with the original colours. #[test] fn panel_defers_discovery_without_a_matching_cache_during_interaction() { let (mut p, a, _) = Panel::new(); diff --git a/crates/ui-egui/src/selection_spacing.rs b/crates/ui-egui/src/selection_spacing.rs index 7d1d27b4..414348bb 100644 --- a/crates/ui-egui/src/selection_spacing.rs +++ b/crates/ui-egui/src/selection_spacing.rs @@ -24,6 +24,8 @@ struct LayoutCache { reorder: Option>, } +/// Return one cached spacing/reorder calculation per document, page, selection and frame, +/// refreshing next frame after an edit and reusing an unchanged document snapshot. fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc { let Ok(ds) = app.session.ds() else { return Arc::new(SelectionSpacing::default()) }; let key = Id::unique("selection_spacing_layout"); @@ -52,11 +54,13 @@ fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc Option> { layout(app, ctx, ids); ctx.data(|d| d.get_temp::(Id::unique("selection_spacing_layout"))).and_then(|c| c.reorder) } +/// Stable egui id for spacing/reorder drag state. fn drag_id() -> Id { Id::unique("selection_spacing_drag") } @@ -107,6 +111,8 @@ fn start_drag(app: &mut FigApp, ctx: &egui::Context, ids: Vec, p: Pos2, } } +/// Draw horizontal and vertical spacing fields, running `arrange.spacing` with +/// `fromGesture`. pub fn panel(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId]) { let layout = layout(app, ui.ctx(), ids); if layout.horizontal.is_none() && layout.vertical.is_none() { diff --git a/crates/ui-egui/src/selection_spacing_tests.rs b/crates/ui-egui/src/selection_spacing_tests.rs index aaeaabc3..b4b384d0 100644 --- a/crates/ui-egui/src/selection_spacing_tests.rs +++ b/crates/ui-egui/src/selection_spacing_tests.rs @@ -43,6 +43,7 @@ fn drag(u: &mut Ui, p: Pos2, by: egui::Vec2, release: bool) { } } +/// Asserts typed and scrubbed spacing changes take effect with exactly one undo step. #[test] fn spacing_fields_type_scrub_and_keep_one_undo_step() { let (mut u, ids) = scene(true); @@ -88,6 +89,8 @@ fn canvas_gap_drag_keeps_selection_and_undo_redo_for_both_grid_axes() { } } +/// Asserts Escape rolls a spacing drag back, and releasing outside the window commits once +/// while keeping the selection. #[test] fn escape_rolls_back_and_an_outside_release_finishes_the_gesture() { let (mut u, ids) = scene(false); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 2abb1cfc..d47d9607 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -205,6 +205,9 @@ pub struct AfterFieldSave { } impl AfterFieldSave { + /// Run a deferred field-save binding only if the originating window still exists and owns + /// the document; temporarily activate that document, run the binding, then restore the + /// previously front tab. fn run(self, app: &mut FigApp) { // A closed file or one moved to another window cannot turn this into a save of the // current tab. The UiState normally stays in its window's pane; validate that too. @@ -299,6 +302,7 @@ pub(crate) fn revalidate_field_save(app: &FigApp, ctx: &Context, save: Option egui::Id { egui::Id::unique(("field_save_cancelled", ctx.viewport_id(), editor)) } @@ -644,12 +648,16 @@ pub(crate) struct ImeInputBaseline { } impl ImeInputBaseline { + /// Capture an IME baseline only while a text edit and an active preedit exist, recording + /// the IME target and the caret/anchor. pub(crate) fn capture(app: &FigApp) -> Option { let te = app.session.ds().ok()?.text_edit.as_ref()?; te.preedit.as_ref()?; Some(Self { target: ime_target(app)?, caret: te.caret, anchor: te.anchor }) } + /// Whether the IME target changed, editing or the preedit ended, or the caret/anchor moved + /// since the baseline was captured. fn interrupted(&self, app: &FigApp) -> bool { ime_target(app) != Some(self.target) || app @@ -661,6 +669,9 @@ impl ImeInputBaseline { } } +/// Apply a batch of text, IME, paste, copy, cut and key events: reject all IME events for an +/// interrupted composition, swallow text produced by a key that ran a shortcut, then finish +/// with triple-click line selection and preedit sync. fn text_keys(app: &mut FigApp, ctx: &Context, events: &[Event], ime_input: Option) { // Validate once before the batch: an accepted DeleteSurrounding deliberately clears preedit, // but its following Commit still belongs to the same valid transaction. An interrupted diff --git a/crates/ui-egui/src/style_picker_tests.rs b/crates/ui-egui/src/style_picker_tests.rs index f86ddd73..b732c11d 100644 --- a/crates/ui-egui/src/style_picker_tests.rs +++ b/crates/ui-egui/src/style_picker_tests.rs @@ -95,6 +95,8 @@ fn text_session() -> (Session, figcraft_doc::NodeId) { (s, t) } +/// Asserts hovering a text style previews it without an edit, leaving the popover restores +/// the value, and clicking applies one undo step and closes the picker. #[test] fn text_styles_preview_on_hover_and_apply_on_click() { let (s, t) = text_session(); diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 92ff9f63..6f17910a 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -97,6 +97,8 @@ fn tab_drop_position(visible: &[(usize, Rect)], from: usize, pointer_x: f32) -> } } +/// Draw the top tab bar: Home, document and presenter tabs, new-tab and window controls, +/// open/close/reorder animation and tab closing, pausing the presenter when going Home. pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::top("tab_bar").exact_size(HEIGHT).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 80d1399c..9a384294 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -173,6 +173,7 @@ impl Tokens { } } +/// Stable id for temporary theme tokens. fn tokens_id() -> egui::Id { egui::Id::unique("figcraft_tokens") } @@ -436,6 +437,7 @@ pub fn c32(c: figcraft_color::Color) -> Color32 { Color32::from_rgba_unmultiplied(r, g, b, a) } +/// Stable id for the current document-to-screen colour conversion. fn screen_conversion_id() -> egui::Id { egui::Id::unique("figcraft_screen_conversion") } diff --git a/crates/ui-egui/src/theme_startup_tests.rs b/crates/ui-egui/src/theme_startup_tests.rs index 1ae40f58..c128a597 100644 --- a/crates/ui-egui/src/theme_startup_tests.rs +++ b/crates/ui-egui/src/theme_startup_tests.rs @@ -84,6 +84,8 @@ fn unavailable_system_theme_uses_the_existing_light_fallback() { assert_eq!(Tokens::get(&ctx), Tokens::light()); } +/// Checks that the test graph enables egui's `default_fonts` and `monochrome_emoji_fonts` +/// features by asserting both a Latin and an emoji glyph are available. #[test] fn tests_have_the_explicit_default_and_monochrome_emoji_font_features() { // 0.37 egui has no default features, and kittest's own dev-features do not propagate to this diff --git a/crates/ui-egui/src/titlebar_tests.rs b/crates/ui-egui/src/titlebar_tests.rs index 8fa4ad47..fff303ef 100644 --- a/crates/ui-egui/src/titlebar_tests.rs +++ b/crates/ui-egui/src/titlebar_tests.rs @@ -81,6 +81,8 @@ fn window_buttons_minimise_maximise_and_close() { assert!(u.frame(vec![]).contains(&ViewportCommand::Close)); } +/// Asserts closing the custom titlebar over a presenter with unsaved changes asks with the +/// unsaved-changes dialog instead of emitting `Close`. #[test] fn titlebar_close_asks_over_the_presenter_when_a_file_is_unsaved() { let mut u = Ui::new(TitleBar::Custom); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 7af3356a..8f8f6cc1 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -91,6 +91,7 @@ const BOARD_GROUPS: &[Group] = &[ Group::tools("comment", tr_noop("Comment"), &[(ToolId::Comment, Icon::Comment)]), ]; +/// Return the last tool index remembered for a toolbar group, falling back to `default`. fn remembered(ui: &Ui, key: &str, default: ToolId) -> ToolId { ui.data(|d| d.get_temp::(egui::Id::unique(("tb_last", key)))).and_then(|i| ToolId::ALL.get(i as usize).copied()).unwrap_or(default) } @@ -535,6 +536,8 @@ fn options_flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::P } } +/// Draw a tool group's flyout above its button, with keyboard/hover highlighting, remembered +/// selection when a tool is clicked, and Escape to close. fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) { let t = Tokens::get(ui.ctx()); let item_h = 32.0; diff --git a/crates/ui-egui/src/type_settings_tests.rs b/crates/ui-egui/src/type_settings_tests.rs index cbde6cc1..421c1ce2 100644 --- a/crates/ui-egui/src/type_settings_tests.rs +++ b/crates/ui-egui/src/type_settings_tests.rs @@ -2,6 +2,8 @@ //! de paragraphe") stays inside the popover, which otherwise widened a little every frame. use egui::{Area, Id, Pos2, RawInput, Rect, vec2}; +/// Asserts a long type-settings row label does not widen the popover beyond 264 pt across +/// frames. #[test] fn a_long_label_does_not_widen_the_type_settings_popover() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 33c0d5a0..f61aa183 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -1167,10 +1167,13 @@ const ROW: f32 = 26.0; /// The font list's tallest (it scrolls past that). const LIST_HEIGHT: f32 = 380.0; +/// Return the persisted list of recently used font families. fn recent_families(ctx: &egui::Context) -> Vec { ctx.data_mut(|d| d.get_persisted::>(Id::unique(RECENT_KEY))).unwrap_or_default() } +/// Remember a font family at the front of the recent list, removing an earlier +/// case-insensitive match and keeping at most eight entries. fn remember_family(ctx: &egui::Context, family: &str) { ctx.data_mut(|d| { let v = d.get_persisted_mut_or_default::>(Id::unique(RECENT_KEY)); @@ -1568,6 +1571,8 @@ mod variable_tests { assert!(p.app.ui.last_error.is_none(), "{:?}", p.app.ui.last_error); } + /// Asserts bound font family and style chips replace the dropdowns, a selected text range + /// offers no variable binding, and detaching restores the last values. #[test] fn bound_font_family_and_style_show_their_variables_and_detach() { let mut p = Panel::new(); diff --git a/crates/ui-egui/src/typography_scrub_tests.rs b/crates/ui-egui/src/typography_scrub_tests.rs index 1e629acf..9b95b8e5 100644 --- a/crates/ui-egui/src/typography_scrub_tests.rs +++ b/crates/ui-egui/src/typography_scrub_tests.rs @@ -44,6 +44,7 @@ impl Fields { out.textures_delta.clear(); out.platform_output.cursor_icon } + /// Screen position of a typography field's label/lead, used to start a scrub. fn lead(&self, key: &str) -> Pos2 { let rect = self.ctx.read_response(Id::unique(key).with("click")).unwrap().rect; Pos2::new(rect.min.x + 12.0, rect.center().y) @@ -157,6 +158,8 @@ fn typography_icon_scrub_changes_only_the_selected_characters_without_ending_tex assert_eq!(f.value("ty_ls"), 0.0); } +/// Asserts typography spacing fields accept typed percent units, commit on Enter, and +/// Escape restores the previous value without adding an undo step. #[test] fn typography_spacing_fields_still_accept_typed_units_and_escape_cancels() { let mut f = Fields::new(); diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index 776d8aae..fe822a1f 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -40,6 +40,7 @@ struct Picker { at: Pos2, } +/// Stable egui id for the variable picker. fn picker_id() -> Id { Id::unique("variable_picker") } diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index 2a9e3c04..372e3f04 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -173,6 +173,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) { app.ui.variables = open.then_some(st); } +/// Draw the variables header: the title at the left, and the import/export/Close controls at the +/// right. fn header(app: &mut FigApp, ui: &mut Ui, open: &mut bool) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); @@ -253,6 +255,7 @@ fn extend(app: &mut FigApp, lib: &str, c: &str, st: &mut Editor) { } } +/// Stable egui id for the rename field of a `Rename` target. fn rename_id(target: &Rename) -> Id { Id::unique(("var_rename", target)) } @@ -281,6 +284,8 @@ fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String { (1..10_000).map(|k| if k == 1 { base.to_string() } else { format!("{base} {k}") }).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) } +/// Draw the collections sidebar with library headings, collection rows and a create-collection +/// button. fn sidebar(app: &mut FigApp, ui: &mut Ui, views: &[View], local: &Variables, st: &mut Editor) { let t = Tokens::get(ui.ctx()); let (hr, _) = ui.allocate_exact_size(vec2(SIDEBAR_W, 40.0), Sense::hover()); @@ -505,6 +510,8 @@ fn group_of(name: &str) -> &str { name.rsplit_once('/').map(|(g, _)| g).unwrap_or("") } +/// Draw the variables table for the selected collection: search, library/extended notes, +/// grouped rows, mode columns and the create-variable row when editable. fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) { let t = Tokens::get(ui.ctx()); // A sidebar click this frame picked another source: shown next frame. @@ -594,6 +601,8 @@ fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) { }); } +/// Draw a collection's mode header row with rename, duplicate, reorder and delete actions +/// and an add-mode button when the collection is editable and not extended. fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: &mut Editor, width: f32) { let t = Tokens::get(ui.ctx()); // An extended collection has the modes of the one it extends. diff --git a/crates/ui-egui/src/vector_panel.rs b/crates/ui-egui/src/vector_panel.rs index 29ae8726..07dda3ff 100644 --- a/crates/ui-egui/src/vector_panel.rs +++ b/crates/ui-egui/src/vector_panel.rs @@ -72,6 +72,8 @@ fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { ui.add_space(8.0); } +/// Draw the selected points' position, dimensions, corner radius, mirroring and per-point +/// stroke cap/join controls; the width tool delegates to its own panel. pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { if ve.tool == vectoredit::VTool::Width { width_point(app, ui, doc, ve); diff --git a/crates/ui-egui/src/versions.rs b/crates/ui-egui/src/versions.rs index 3519b610..19eb6a04 100644 --- a/crates/ui-egui/src/versions.rs +++ b/crates/ui-egui/src/versions.rs @@ -403,6 +403,8 @@ fn current_row(ui: &mut Ui, more: bool, active: bool) -> bool { !active && resp.on_hover_text(tr("Back to the current version")).clicked() } +/// Draw one version row with timeline, name, time, up to three description lines and a +/// more/context menu; returns the chosen preview, restore, edit, duplicate or delete action. fn version_row(ui: &mut Ui, row: &Row, last: bool, previewing: bool) -> Option { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index f356b797..a5dd2c7b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -223,6 +223,9 @@ fn cancel_retargeted_field_save(ui: &mut Ui, id: Id) { ui.memory_mut(|m| m.surrender_focus(editor)); } +/// Implement the shared number field: label/icon lead, scrubbing, keyboard stepping and +/// typing, select-all when a click starts editing, bindable values and deferred-save +/// cancellation; returns an edit and/or bind event. fn number_field_impl( ui: &mut Ui, id: Id, diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index 55a7babd..0f5884dd 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -45,6 +45,8 @@ fn names(app: &FigApp) -> Vec { app.session.docs.iter().map(|d| d.doc.name.clone()).collect() } +/// Asserts closing a window in the presenter shows the unsaved-changes dialog and does not +/// dismiss the preview. #[test] fn closing_a_window_in_the_presenter_shows_the_unsaved_changes_dialog() { let mut session = Session::new(); @@ -104,6 +106,8 @@ fn windows_hold_tabs_and_close_with_their_files() { assert_eq!(app.session.ds().unwrap().doc.name, "A"); } +/// Asserts a hidden other window answers edits and inspection in its own file while the main +/// window keeps its file, and a capture parks with a deadline and times out once. #[test] fn another_hidden_window_answers_requests_that_need_no_frame_in_its_own_file() { let mut s = Session::empty(); @@ -180,6 +184,8 @@ fn a_request_for_a_window_on_screen_wakes_it_while_the_main_window_is_hidden() { assert!(woken.lock().unwrap().contains(&other), "the other window is asked to draw: {:?}", woken.lock().unwrap()); } +/// Asserts every direct UI frame clears the logic-tick marker so background work runs each +/// frame. #[test] fn every_direct_ui_frame_runs_background_work() { // Direct `App::ui` harnesses never call `App::logic`: the tick marker the UI pass consumes @@ -192,6 +198,8 @@ fn every_direct_ui_frame_runs_background_work() { } } +/// Asserts synthetic input for a child window reaches the pane that draws it, leaving the +/// main window's pane untouched. #[test] fn synthetic_input_for_a_child_window_reaches_the_pane_that_draws_it() { let mut s = Session::empty(); @@ -214,6 +222,8 @@ fn synthetic_input_for_a_child_window_reaches_the_pane_that_draws_it() { assert_eq!(crate::windows::take_input(&mut app, other), events, "the child's next frame takes its batch"); } +/// Asserts a child raw-input hook with no batch does not error or drain the root window's +/// pending acknowledgement. #[test] fn a_frame_without_a_batch_does_not_error_another_windows_acknowledgement() { let mut s = Session::empty(); @@ -238,6 +248,8 @@ fn a_frame_without_a_batch_does_not_error_another_windows_acknowledgement() { assert_eq!(app.input_acks[0].reply.id, json!(5)); } +/// Asserts a child window runs `viewport_setup` and its pane keeps the dark theme after the +/// swap back. #[test] fn a_child_window_runs_the_ui_setup_the_main_window_does() { // eframe calls `App::ui` only for the root window, so a child that draws while the root does @@ -263,6 +275,8 @@ fn a_child_window_runs_the_ui_setup_the_main_window_does() { assert_eq!(again, Some(json!(true))); } +/// Asserts a closed and later reused viewport does not inherit its old IME context +/// generation. #[test] fn an_emptied_window_forgets_its_ime_context() { // `windows::begin` ends the viewport when a window loses its last file; a later window that From 4eb5224f023a7baf70df5f3df9b785480324f6f8 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 17:13:00 +0100 Subject: [PATCH 5/7] M0: correct egui 0.37 status docs and stabilize join-code regression --- crates/collab-net/src/code_tests.rs | 5 ++++- crates/ui-egui/src/windows.rs | 2 +- docs/egui-037-migration-plan.md | 8 ++++---- docs/gaps.md | 8 +++++--- docs/hardware-parity.md | 7 ++++--- docs/ui-parity.md | 5 +++-- 6 files changed, 21 insertions(+), 14 deletions(-) diff --git a/crates/collab-net/src/code_tests.rs b/crates/collab-net/src/code_tests.rs index 7fe766ec..182f3782 100644 --- a/crates/collab-net/src/code_tests.rs +++ b/crates/collab-net/src/code_tests.rs @@ -103,7 +103,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 = 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::()})).unwrap_err().to_string(); assert!(e.contains("typing mistake"), "{e}"); // A view-only code doesn't exist here, and says where it does. diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index c9c9fdce..95ec721d 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -248,7 +248,7 @@ fn exchange(app: &mut FigApp, p: &mut Pane) { /// file, at screen point `at` (else beside this window). Returns its id. With one window (the /// browser) or a screen reader running, a file opens as a tab here instead (and the error says so). pub fn open(app: &mut FigApp, ctx: &Context, index: Option, path: Option<&str>, at: Option) -> Result { - // shortcut: eframe 0.36 gives only the main window an AccessKit adapter, so other windows have + // shortcut: eframe 0.37 gives only the main window an AccessKit adapter, so other windows have // no screen-reader tree; while a screen reader runs files stay tabs here. Upgrade when eframe // adapts every viewport. let one = if ctx.embed_viewports() { diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md index 6cf1bb09..93fd815b 100644 --- a/docs/egui-037-migration-plan.md +++ b/docs/egui-037-migration-plan.md @@ -1088,10 +1088,10 @@ agent/model/tool signature; contributor credit remains username-only (`@skylarta real evidence exists. 12. **Lockfile resolution:** one egui family, one winit, one wgpu, one craft-window. If Cargo chooses two versions, stop and fix the pins rather than accepting a duplicate. -13. **AccessKit per-viewport:** 0.37's eframe integrations initialize adapters per created viewport - in the inspected source; this may remove the 0.36 “main window only” limitation, but it has not - been exercised with a screen reader here. Keep the tab fallback until a real test says - otherwise. +13. **AccessKit per-viewport:** the pinned 0.37 fork initializes the native AccessKit adapter only + for the root viewport; creating a secondary viewport does not initialize an adapter. The + “main window only” limitation remains, and no real screen-reader qualification is claimed. + Keep the tab fallback until secondary-window accessibility is implemented and tested. 14. **Automation screenshot thread:** the callback must not write files outside `file_access` confinement. The shared completion-slot design in 6.3 is required, not optional. diff --git a/docs/gaps.md b/docs/gaps.md index e76731e7..280b04c5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -439,8 +439,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe needs to; closing one asks about unsaved changes; `window.new`, `window.list`, `window.focus` and `window` on any control request. - **Missing:** wide-gamut colour on the canvas (egui draws sRGB, so P3 colours outside sRGB clip on - screen; exports keep them); a screen-reader tree in windows other than the main one (eframe 0.36 - gives only the main window one); window positions restored at the next start. + screen; exports keep them); a screen-reader tree in windows other than the main one (eframe 0.37 + gives only the main window one; verified in the fork's viewport setup); window positions restored at + the next start. - **Estimate:** 4–8 h. ### 19. AI features @@ -487,6 +488,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | egui/eframe 0.37 verified for gap 18: the remaining limitations are unchanged. egui-wgpu 0.37 still picks an 8-bit sRGB surface with no colour-space option, so P3 colours outside sRGB still clip on screen; eframe 0.37 still gives only the main window an AccessKit adapter, so other windows have no screen-reader tree. Numbers unchanged | | 2026-10-25 | minor | Known gaps from the #1311 review: gap 4, the export preview follows a slice's first export setting; gap 14, the scopes a paint style's color takes are FigCraft's own rule; gap 15, `proto.linkNotice` from the palette or control channel isn't saved until the next preferences save; gap 16, the file name field still renames in Dev Mode (to check against Figma first). Ranking and hours unchanged | | 2026-10-24 | minor | 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. Left: publishing mappings with a CLI; 2–4 h → 1–2 h | | 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. New gap 24 (the board tail, 30–60 h); the ranking above it is unchanged | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 1b2536dc..61282d53 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -15,10 +15,10 @@ since 2025). FigCraft renders the canvas on the CPU and presents it through wgpu | Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom; when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it (#312); shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path | | Effects (blurs, glass, noise) | GPU | CPU | in the above | | HiDPI / Retina | yes | yes (all); a canvas image rendered at the screen's density is placed on whole device pixels, so fractional pans, zooms and display scales don't blur it (#359; geometry tests, the snapping not judged by eye) | — | -| Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB: eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no colour space option, although wgpu 30 can present Display P3 or extended sRGB on Metal, DX12, Vulkan and WebGPU (see gaps #18). The colour picker warns about colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas | 2–5 | +| Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB: eframe/egui-wgpu 0.37 configure an 8-bit sRGB surface with no colour space option, although wgpu 30 can present Display P3 or extended sRGB on Metal, DX12, Vulkan and WebGPU (see gaps #18). The colour picker warns about colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas | 2–5 | | Trackpad pinch zoom and two-finger pan | yes | yes (egui zoom delta, smooth scroll) | — | | Pen tablet pressure (pencil, Figma Draw brushes) | yes (forum reports of regressions in 2025) | Windows pens (winit pointer input) and browser styluses that send touch events with a force (an Apple Pencil in Safari); the Pencil records it as the stroke's width profile. A mouse, macOS and Linux tablets and pens sending only pointer events in a browser report none: constant width | 1–3 | -| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | desktop: File ▸ New window, a tab's Open in new window or a tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it needs to and asks about unsaved changes when it closes; a minimised or covered window still answers control and MCP requests, while screenshots with hidden or unknown visibility request a hidden paint and expire at their 60-second admission deadline if unfinished; injected pointer/key/text input waits for a real UI frame (#313, #332, #720). Browser and Wayland hidden-paint behavior remain unqualified. Only the main window has a screen-reader tree (eframe 0.36), so files stay tabs while a screen reader runs; positions aren't restored at the next start; the presenter stays inside its window; one window in the browser | 1–2 | +| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | desktop: File ▸ New window, a tab's Open in new window or a tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it needs to and asks about unsaved changes when it closes; a minimised or covered window still answers control and MCP requests, while screenshots with hidden or unknown visibility request a hidden paint and expire at their 60-second admission deadline if unfinished; injected pointer/key/text input waits for a real UI frame (#313, #332, #720). Browser and Wayland hidden-paint behavior remain unqualified. Only the main window has a screen-reader tree (eframe 0.37), so files stay tabs while a screen reader runs; positions aren't restored at the next start; the presenter stays inside its window; one window in the browser | 1–2 | | Screen readers | partial | AccessKit tree on desktop (names, roles, states), never tested with a real screen reader; none in the browser | 4–8 + human QA | | Keyboard layouts (non-US) | yes | keymap uses logical keys; not checked on AZERTY/QWERTZ/JIS | 1–2 | @@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | egui/eframe 0.37 dependency migration: the workspace pins the egui family at 0.37, and the wide-gamut and several-windows rows now cite 0.37. The limitations are unchanged: egui-wgpu 0.37 offers no surface colour-space option, so the screen still shows sRGB; eframe 0.37 still gives only the main window an AccessKit adapter, so files stay tabs while a screen reader runs. Numbers unchanged | | 2026-10-25 | minor | Hidden or unknown-visibility screenshots request a hidden paint and expire at their 60-second admission deadline if unfinished; injected pointer/key/text input waits for a real UI frame. Browser and Wayland hidden-paint behavior remain unqualified. Numbers unchanged. | | 2026-10-15 | minor | Community fixes (#290, #306, #312, #313, #328, #332, #346, #474): coarse zoom previews when renders from scratch are slow; a minimised or covered window answers control and MCP requests (screenshots and input fail at once while it is hidden); `.figcraft` files open from Finder on macOS; a message box when the window can't open on Windows; on Linux the window id matches the desktop entry, the RPM requires glibc 2.35 and user fonts under `XDG_DATA_HOME` are found. Numbers unchanged | | 2026-10-18 | minor | macOS quit crash (#781): on Macs with a Touch Bar the app could crash as it quit (an exception in AppKit's Touch Bar support while the window's content view, which the accessibility adapter subclasses, was torn down). Quitting now keeps everything (preferences, this run's recovery copies, live sessions ended) and then ends the process before that teardown. Quit from the menu bar ends the app without the unsaved-changes question, so unsaved files' recovery copies now stay and reopen at the next start instead of being removed. Numbers unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e1c219c6..74b056d8 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -20,7 +20,7 @@ interaction feel not yet compared. | Minimize UI (Cmd+\\) | done | | | Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points | | Actions palette (Cmd+K, Cmd+/) | done | tabs: All (every command, grouped by menu, or Other), Assets (components and styles) and Plugins & widgets; results ranked prefix first, then shortest, at most 60 rows; Up/Down move a highlight and Enter runs it; Recents list what was run from the palette (matching translated labels); one open/toggle path from every entry point (`palette_tests`). Ranking and the Assets results not compared with Figma's | -| Context menus | done | canvas and layers; submenus as in the main menu below, and a right-click in a new spot starts a fresh menu (Select layer closed); Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) | +| Context menus | partial | canvas and layers; submenus as in the main menu below, and a right-click in a new spot starts a fresh menu (Select layer closed); Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key reaches egui as `Key::Menu` (egui-winit 0.37 maps the native key), but FigCraft's keymap has no binding for `Menu`, so it does nothing yet | | Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder | | Main menu from the logo | done | generated from `catalog.rs`; popups sized to content. Submenus open after 0.15 s on their row (a click or the keyboard opens one at once) and close at once when another row is hovered; while the pointer heads for an open submenu inside the triangle to its near edge, the rows it crosses don't take over, and resting 0.3 s on one hands over; closing the menu, however it closes, clears which submenus were open, so it reopens with none (`menu_aim_tests`, 10 tests; timings tuned by feel, not measured against Figma). Menu and palette items that need a value ask for it first: Create link, Offset path and Move to page in a dialog of the shared controls, the language, user name, server and nudge items in Preferences, Open recent on the home screen's Recents; File ▸ Rename edits the file name in place | | Keyboard shortcuts panel | done | closes when the home screen shows (#548); docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings; a label too long for its column ends with "…" and shows in full on hover | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Context menus: the Menu key is no longer an egui limitation at 0.37 (egui-winit maps the native Menu/ContextMenu key to egui's `Key::Menu`), but FigCraft normalizes no binding for `Menu`, so it does not open a context menu yet; Shift+F10 still does, with the same behaviour. The row moves from done to partial. Numbers unchanged | | 2026-10-24 | minor | Selection colors: a row's Styles menu also lists the document's color variables, and picking one binds it to every fill and stroke the row stands for, replacing a color style there (`selection.bindColorVariable`; a text range with fills of its own takes the variable's value); a row linked to a style or variable has a Detach button that unlinks it and keeps the color (`selection.detachColor`). Still partial: no library styles or variables, no new style or variable from a row | | 2026-10-24 | minor | The Text on a path tool (`tool.textOnPath`, in the Text flyout): a click on a shape's or vector's path puts new text on it, starting at the click, with the path's fill (when one shows) and effects, as the help center's Guide to text describes; the path layer is removed, and an edit left empty puts it back with no undo step (`draw_tools_tests`, `cursor_tests`) | | 2026-10-24 | minor | Boards (M12): eight board bindings pinned (tool keys Q sticky, B shapes, X connector, M marker, U highlighter, E eraser, W stamp, and Shift+Cmd/Ctrl+Enter for the next sticky; boards only, on keys no design-file shortcut uses): 49 → 57 pinned | From abfdaa24366b8bf81c05268efd9205401391715a Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Sun, 25 Oct 2026 20:14:50 +0100 Subject: [PATCH 6/7] M-egui037: retain one explicit session switch in the qualified regression --- crates/collab-net/src/sessions_tests.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/collab-net/src/sessions_tests.rs b/crates/collab-net/src/sessions_tests.rs index 5da0e020..4a2579f8 100644 --- a/crates/collab-net/src/sessions_tests.rs +++ b/crates/collab-net/src/sessions_tests.rs @@ -134,10 +134,6 @@ fn two_files_are_hosted_and_joined_independently() { 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. - // 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"); let in_a = rect(&mut host); host.execute("doc.switch", &json!({"index": 1})).unwrap(); let in_b = rect(&mut host); From b7a0d892d728a79e36a6bff67e583cf40f2ab3ef Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Sun, 25 Oct 2026 20:39:43 +0100 Subject: [PATCH 7/7] M14: cover synthetic Copy link clipboard isolation --- crates/ui-egui/src/clipboard_fence_tests.rs | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/ui-egui/src/clipboard_fence_tests.rs b/crates/ui-egui/src/clipboard_fence_tests.rs index 76ddf81c..9b7ed4fe 100644 --- a/crates/ui-egui/src/clipboard_fence_tests.rs +++ b/crates/ui-egui/src/clipboard_fence_tests.rs @@ -131,6 +131,37 @@ fn a_copy_command_invoked_for_a_synthetic_click_still_keeps_the_clipboard() { 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 {