mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 14:07:34 -06:00
1553 lines
124 KiB
Markdown
1553 lines
124 KiB
Markdown
# LightCraft: egui 0.37 migration plan
|
||
|
||
Current reconciliation (2026-10-25): this tree incorporates main `31ec62cd0387abecd8f2a2da034aee591447897d`. Managed CI `egui037-light-main31-ci-20261025-0829` passed all eight stages, including strict lint, HEIF and WASM checks: 5,086 passing test executions, zero failures and 42 ignored across 89 summaries. The prerequisite report separately identifies 149 tests that returned without exercising their checks (corpus 137, models 8, fonts 2, GPU 1, system 1); these are coverage gaps. Independent reconciliation review found no source blocker; its stale-status and pending-CI findings are addressed by this record. Native/browser GUI, platform, packaging/licence coverage and paired performance checks remain open. Human review and human merge are required; publication needs separate approval.
|
||
|
||
Historical landing status before this reconciliation, 2026-10-25: the migration integrates main `38f8d2508700fa4938295e865d3fc21be07266ba` and uses egui `6d518fc799681571963842f95b55a6b68431b15a`, winit `a517b3846ee8457d84ee4975110bfda6797c44dc` and craft-ui `25da1314d443171b4adeaad2cd9454b273f35c6f`. Cargo.lock SHA-256 is `6336b4aa2ac9df57cb8feaafb097b30fa7228ef427f5e213fac1a2a7875e4181`; the independent craft-docpath pin remains selected in the lockfile. Release workflows use craft-fonts `b076417b5880bd4154bd22f00695b1d28c6bea83`.
|
||
|
||
Fresh static review verified preservation of the captured main and the final dependency graph. The final managed CI run `egui037-lightcraft-library-frame-ci-20261025-0551` passed all eight stages. Including its focused library tests, the log records 5,083 passing test executions, zero failures and 42 ignored across 142 summaries. The CI prerequisite report separately identifies 149 tests that returned without exercising their checks (corpus137, models8, fonts2, GPU1, system1); these are coverage gaps. Earlier passing counts below qualify their historical snapshots only. The icon-font attribution URL now identifies the selected egui source; font bytes are unchanged. Visible GUI, browser/platform runtime, native IME, final packages and paired performance remain separate acceptance gates. Human review and human merge are required.
|
||
|
||
The review packet separately records inherited default-font notice coverage and Inter licence staging gaps. Nix font-pin drift is fixed as described below; the other inherited gaps are not established as fixed by the migration or by source-level notice routing checks.
|
||
|
||
Current status note (2026-10-25): `flake.nix` now points at craft-fonts `b076417b5880bd4154bd22f00695b1d28c6bea83`, matching CI, the release workflow and the standalone packaging helpers, and `flake.lock` was regenerated by Nix 2.26.3 using `nix flake update craft-fonts` against the canonical GitHub revision. Only the craft-fonts node changed; nixpkgs stayed pinned. Linux x86_64 flake evaluation passed with `nix flake check --no-build --no-write-lock-file` (job `egui037-lightcraft-nix-eval-20261025-0495`). No Nix package build, executed formatting derivation, or other architecture is claimed. The inherited gaps above (egui default-font licence coverage for Hack, Ubuntu-Light and NotoEmoji-Regular; the Inter OFL packaging omission) are not migration-introduced, and this landing updates the craft-fonts notices and attribution text only.
|
||
|
||
Font notice and Inter staging follow-up (2026-10-25): the inherited gaps above are now addressed at
|
||
source level only. `NOTICE` reproduces the pinned upstream texts verbatim for Hack Regular (MIT and the
|
||
Bitstream Vera License), Ubuntu Light (Ubuntu Font Licence 1.0) and Noto Emoji Regular (SIL OFL 1.1),
|
||
next to the existing egui-icons/emoji-icon-font block; `assets/ATTRIBUTION.md` gains rows for
|
||
`epaint_default_fonts::HACK_REGULAR`, `UBUNTU_LIGHT` and `NOTO_EMOJI_REGULAR`; `packaging/env.sh`
|
||
`copy_font_licences` now always stages `assets/fonts/OFL-Inter.txt` as `OFL-Inter.txt` before the optional
|
||
craft-fonts OFLs (Linux, macOS app and CLI, FreeBSD and web use it), and the Flatpak from-source manifest
|
||
installs it directly. The release Flatpak bundle repackages the Linux tarball and so inherits its documents;
|
||
Windows and Nix already staged Inter. Managed staging checks verified the three upstream licence texts
|
||
byte-for-byte in `NOTICE`, copied Inter with and without an optional craft-fonts input, and confirmed
|
||
that a missing Inter licence makes staging fail. These checks exercise the shared document-copy helper;
|
||
actual release artifacts (deb/rpm/tar, macOS app and CLI, FreeBSD, web zip, Flatpak, MSI/portable zip, Nix)
|
||
and their installed notice contents remain unverified.
|
||
|
||
Library-check results now remain visible for the frame in which they arrive, so a zero-delay refresh cannot immediately replace a fresh missing-library answer. The scheduling fence uses the root UI frame number across all layout passes; focused regressions cover the single-pass and forced-multipass cases with isolated temporary directories. Fresh static review accepted the delivery-frame behavior.
|
||
|
||
The original planning and earlier qualification record follows. Its pins, hashes, paths and passing counts are historical.
|
||
|
||
|
||
## Current implementation status — 2026-10-24
|
||
|
||
The implementation is now pinned to egui `173cc7375d35dc5fbc5b5d0994fdf787f547c5d1`,
|
||
winit `ae48c756a3b81125eb796c0cc6dccf60990d1181`, craft-libs
|
||
`c488caa9fbc2fb1a86e4674887e9b80b598ebbfa`, craft-fonts
|
||
`b076417b5880bd4154bd22f00695b1d28c6bea83` and craft-i18n
|
||
`e5bfe19b95d30f50f250985bdc3aeec47c05af09`. Cargo-generated lock SHA-256:
|
||
`2b6c3550997fd4962df7865ec038cc409604aafaaf2267fa416ad9d98df91da9`.
|
||
These are exact local reviewed revisions; public fetchability still requires the separately
|
||
approved foundation publication. Earlier planning and source-only validation statements below
|
||
are historical and are superseded by this status.
|
||
|
||
The mandatory `cargo xtask ci` passed all eight stages (fmt, strict clippy, HEIF, tests,
|
||
parity, layers, assets and WASM). Its log reports 4,747 passing test invocations, zero failures
|
||
and 40 ignored tests. The CI prerequisite report separately lists 149 checks that returned
|
||
without exercising their checks: corpus 137, models 8, fonts 2, GPU 1 and system 1. These are
|
||
coverage gaps, not qualified behavior. Canonical source identities were resolved through local
|
||
mirrors; no public remote-fetch acceptance is claimed.
|
||
|
||
Fresh review and regression coverage include capture-parent symlink retargeting: completion
|
||
re-resolves the lexical parent and refuses a changed destination before encoding or writing.
|
||
The two standalone packaging helpers now default to the same craft-fonts pin as release CI;
|
||
explicit font-directory overrides and existing checkouts retain their behavior.
|
||
|
||
The current-main integration includes `4ae3089420cb57d4c839e51a735d4f48271cbf17` and
|
||
preserves its catalog hardening and other changes. Independent review caught a bad-chain
|
||
memoization error: a descendant visited before a broken ancestor could be detached unnecessarily.
|
||
The repair caches only proven valid chains and removes the offending parent edge, preserving
|
||
valid descendants. Regressions cover child-before-cycle, child-before-missing-parent and a valid
|
||
513-folder chain through actual catalog snapshot/reopen paths. All nine focused catalog repair
|
||
tests passed before the full eight-stage CI run on the merged source. The only formatting change
|
||
after the source review wraps one let-chain condition; the lockfile remains unchanged.
|
||
|
||
Actual visible UI, native platform/IME/PRIMARY, browser behavior, final release-package notices
|
||
and paired performance acceptance remain open. This application branch has not been published.
|
||
|
||
## Historical plan and review revisions
|
||
|
||
Status: **Revision R3.2** — R3.1 plus the 2026-10-24 implementation-status amendments:
|
||
control-gesture delivery no longer re-checks the state snapshot after the gesture's own event has
|
||
applied (freshness is still checked before resolution and immediately before injection), every
|
||
dropdown now publishes a semantic accessible name, and the craft-libs reference moved to the local
|
||
`3832c47…` reconciliation base. The R3.1 concrete implementation notes for control-gesture
|
||
delivery, screenshot path identity, `ui.scroll` units/phases, the hidden-paint predicate and
|
||
disclosure `Expanded` state remain in force. R3 replaced
|
||
R2 (SHA-256 `385e46055494cc24b58e41ee08982c876eb423df314b0abffcdc28ac81cf9d24`) after the
|
||
independent review in `plan-reviews/lightcraft-v2.md`. Every review finding is resolved inline in the section
|
||
that owns it; §13 records each disposition, including the parts rejected. No production code,
|
||
manifest, lockfile, test, asset or build was changed and **no test, build, benchmark or GUI run was
|
||
performed for R3**: every source statement below is static inspection of the pinned trees. No
|
||
executed-validation, GPU-pass or browser-pass claim was made at that planning stage.
|
||
|
||
No push, pull request, release, tag, comment or other publication is authorized. Every future push
|
||
or new pull request needs explicit human approval of the exact content and the validation results
|
||
first.
|
||
|
||
This plan covers only `storytold/lightcraft` at
|
||
`23e181283cb448205d865e8cd288b56afe987986` (branch `codex/egui-037-plan-20261022` in the assigned
|
||
checkout). It does not migrate the egui fork, winit, craft-libs or a sibling app; those are separate
|
||
plans, separate review units and separate publication gates.
|
||
|
||
The root `AGENTS.md`/`CLAUDE.md` and the nested `docs/user/AGENTS.md`/`CLAUDE.md` were read. This
|
||
plan changes no user documentation, asset, contributor metadata or build artefact; the only file it
|
||
adds is this plan.
|
||
|
||
The historical diagnostics in
|
||
`/home/codex/work/ssd2/task-evidence/egui037-audit-20261022/{fork,stock}/lightcraft.errors` and
|
||
`lightcraft.log` are clues from an earlier checkout. They are not current source, not a build of this
|
||
checkout and not acceptance evidence. They show 17 library errors and 26 library-test errors plus
|
||
243 warnings from the fork trial; the source audit below found additional semantic changes (panel
|
||
IDs, capture correlation, dropped-file replay) that a compiler diagnostic cannot show.
|
||
|
||
## 1. Exact baseline and provenance
|
||
|
||
| Item | Revision or value |
|
||
|---|---|
|
||
| LightCraft source baseline | `23e181283cb448205d865e8cd288b56afe987986` (`Release 0.13.0`) |
|
||
| LightCraft lockfile | `Cargo.lock`, format version 4 |
|
||
| LightCraft Rust baseline | workspace `rust-version = "1.90"`; `lightcraft-ui-egui` declares `1.95`; inspection toolchain `rustc 1.99.0` |
|
||
| Current egui family | `egui`, `epaint`, `ecolor`, `emath`, `eframe`, `egui-wgpu`, `egui-winit`, `egui_glow`, `epaint_default_fonts`, `egui_kittest` all `0.36.2` |
|
||
| Current winit | `0.30.13` from crates.io, checksum `a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d` |
|
||
| Current accessibility/test stack | `accesskit 0.24.1`, `accesskit_consumer` 0.35/0.36/0.38, `kittest 0.4.0`, `egui_kittest 0.36.2` |
|
||
| craft-libs reconciliation base | `3832c47ef68a0e6214f2c1e85f3526b6adcb686e` | Current local shared-library tree and the base for the implemented port; it already carries PR 199's egui 0.37 API update, while the CL-2F capture helper remains uncommitted and is a publication gate. The exact reachable publish pin is still a central-publication gate |
|
||
| craft-libs historical inspection | `49190884278f16c9a9f5823d7698a8ffb5deb062`; PR 199 `8614e4a` + README `1764bca` | Earlier base and hunks; retained as history only. The port shape is already contained in the `3832c47…` base |
|
||
| Target upstream release | egui `0.37.0`, peeled commit `841a328ba2dd8f6996beb7189ebb7fc00e668a2a`; the annotated tag object SHA is not the commit SHA |
|
||
| Comparison-only egui tree | `aiongg/egui` `craft/0.37` at `a4f9a17f64a6bae61350fb85804c9df0d9865916` (E1–E21) |
|
||
| Comparison-only winit tree | `aiongg/winit` `craft/0.30.13-lang` at `8b35388bd719e8cc86a95971ba0bc026c9a8f058`; storytold base `ffae5198770d7730e718043f035a772b7dbc3b1b` |
|
||
| craft-libs stale trees | PR 199 head `b68de8c5f4f1c060904d211a7b1320aa0f7bce78` and audit merge `ec8840af8c5fda695cf195cdd05e5ae078415c07` | Evidence only, not mergeable trees: they omit current `craft-platform` and revert security/settings/docking/printer fixes |
|
||
| Language work | craft-libs issue/PR 198 is a separate proposal, not a merged dependency and not included here |
|
||
|
||
**Pin policy for this consumer.** `a4f9a17f` (egui) and `8b35388b` (winit) are *comparison and
|
||
inspection* references only. LightCraft must pin the final reviewed **storytold** egui and winit
|
||
revisions by immutable full SHA **once the fork and winit series are published**. Those revisions do
|
||
not exist yet, so this plan states no SHA for them and does not guess one. Every stage that needs
|
||
them is explicitly blocked (§9, §10). LightCraft's production dependency is the complete reviewed
|
||
and published storytold egui **P8** revision: E1–E21 plus **P2b** (E4 regular/PRIMARY with bounded
|
||
asynchronous W15P transfer completion), **P7** held-key release pairing, **P7b/E21b** outgoing IME
|
||
surrounding text, and **P7c** (the selected #8763/#8748 fixes and justified performance fixes). Its
|
||
companion is the complete published storytold winit revision with **W15P** and the **W27** IME
|
||
lifecycle. No stock 0.37 or intermediate fork stage is a production dependency; stock 0.37 is only
|
||
the measurement control. A published revision missing any of these is not acceptable for the stages
|
||
named below.
|
||
|
||
## 2. Dependency and entry-point inventory
|
||
|
||
All manifests in the checkout were inspected:
|
||
|
||
- root `Cargo.toml` (`[workspace]`, `[workspace.dependencies]`, `[workspace.lints]`, profiles);
|
||
- `apps/lightcraft/Cargo.toml`, `apps/lightcraft-cli/Cargo.toml`,
|
||
`apps/lightcraft-web/Cargo.toml`;
|
||
- all 29 `crates/*/Cargo.toml` and `xtask/Cargo.toml`.
|
||
|
||
There is one workspace, one lockfile, no nested workspace, no Cargo `vendor/` directory and no
|
||
`[patch]` section today. `crates/raw/src/vendor/` is a set of in-tree raw-decoder modules (Sony,
|
||
Nikon, Canon, Fujifilm, …), not a Cargo vendor directory and not egui-related; it is preserved and
|
||
out of scope. There are no vendored egui/winit/egui-wgpu copies. `.cargo/config.toml` only defines
|
||
the `xtask` alias. `egui_extras` is declared in the workspace but unused and absent from the lock
|
||
graph; do not add it to production just to patch it. `egui_plot`, `egui_dock` and
|
||
`egui_inspection` are unused. `craft-ui` is the only shared UI dependency; `craft-docpath` is not
|
||
egui-related.
|
||
|
||
| Target | egui entry point | Notes |
|
||
|---|---|---|
|
||
| `lightcraft` (native) | `apps/lightcraft/src/main.rs:60` `impl eframe::App`; `logic` calls `LightCraftApp::logic`, `ui` calls `LightCraftApp::ui` | eframe features `default_fonts`, `wgpu`, `glow`, `accesskit`, `wayland`, `x11` today; native menu, GPU probe/fallback, control server (`MAX_CONNECTIONS = 16`, `MAX_LINE = 4 MiB`) |
|
||
| `lightcraft-web` | `apps/lightcraft-web/src/web.rs:652` `impl eframe::App for WebApp`; `start` at `:726` | eframe `default_fonts`, `glow`; browser-loaded Chinese face; workers; OPFS/IndexedDB; `command()` → `LightcraftApp::run`, **no control server** |
|
||
| `lightcraft-cli` | `apps/lightcraft-cli/src/main.rs`: `snapshot` at `:767`, `render`, `mcp` | headless `Headless`/`HeadlessView`; no eframe |
|
||
| `lightcraft-ui-egui` | `crates/ui-egui/src/lib.rs:1077` `LightCraftApp::logic`, `:1441` `LightCraftApp::ui`; `headless.rs:133` `HeadlessView::run`, `headless.rs:311` `Headless::step` | the UI crate and its integration tests |
|
||
| `crates/ui-egui/tests` | `accessibility.rs`, `docking_workflows.rs`, `shutdown.rs` | kittest harnesses and AccessKit assertions |
|
||
| examples | `drag_latency`, `grid_bench`, `open_timeline`, `thumb_request_bench` | existing UI/perf harnesses |
|
||
|
||
Platform-specific declarations that must stay coherent with the selected winit/egui:
|
||
|
||
- `apps/lightcraft` has direct `winit = "0.30"` with `x11` only on
|
||
`cfg(all(unix, not(target_os = "macos")))`;
|
||
- `apps/lightcraft` has `rfd` only on non-wasm; `zbus` only on Linux; `muda`, `objc2`,
|
||
`objc2-app-kit`, `objc2-foundation` only on macOS;
|
||
- `apps/lightcraft-web` puts eframe/egui/wasm-bindgen/web-sys under `cfg(target_arch = "wasm32")`;
|
||
- `lightcraft-ui-egui` has `rust-version = "1.95"` and dev-dependency `egui_kittest` with
|
||
`wgpu` and `snapshot`.
|
||
|
||
### 2.1 Feature policy (explicit, per root)
|
||
|
||
egui 0.37 has **no default `default_fonts`** (`egui/Cargo.toml`: `default_fonts` off by default),
|
||
and 0.37 moved the monochrome emoji faces out of `default_fonts` into
|
||
`monochrome_emoji_fonts` (`epaint/src/text/font_definitions.rs:127-183` inserts
|
||
`NotoEmoji-Regular`/`emoji-icon-font` only under that cfg). eframe 0.37's default feature set
|
||
includes `clipboard`, and its `egui-winit` dependency is `default-features = false`, so an app that
|
||
turns eframe defaults off must add `clipboard` itself (`eframe/Cargo.toml:142-144`;
|
||
`egui-winit/Cargo.toml:44-46`, `clipboard.rs`).
|
||
|
||
| Root | Features after migration | Why |
|
||
|---|---|---|
|
||
| workspace `egui` | `default-features = false`, `features = ["default_fonts", "monochrome_emoji_fonts"]` | every resolution root needs the bundled faces: CLI snapshot, headless harnesses and unit tests never see eframe's features. Monochrome emoji are needed because user keywords/filenames carry emoji (`tests_keywording.rs:558-583` uses `🙂`) and 0.36's `default_fonts` supplied them. The workspace feature is what supplies LightCraft roots; `egui_kittest`'s own dev-dependency enables them only for upstream kittest's tests and does not propagate |
|
||
| `lightcraft` (native) | eframe: `default_fonts`, `clipboard`, `wgpu`, `glow`, `accesskit`, `wayland`, `x11`; egui: workspace `monochrome_emoji_fonts` | `clipboard` restores the real OS clipboard; without it egui-winit simulates an in-process clipboard and cut/copy/paste, `RequestPaste` and the E4/W15P Wayland path are unavailable |
|
||
| `lightcraft-web` | eframe: `default_fonts`, `glow`; egui: workspace `monochrome_emoji_fonts` | browser clipboard is not the egui-winit `clipboard` feature; the browser path is qualified separately |
|
||
| `lightcraft-cli`, `lightcraft-ui-egui` | inherit the workspace `egui` features | headless rendering and kittest must resolve the same faces |
|
||
|
||
`monochrome_emoji_fonts` adds roughly 1 MB to the binary (its own doc comment); the size impact is a
|
||
measured deliverable for both native and WASM (§7.2). Adding both faces must not weaken
|
||
`MissingGlyphPolicy`: production stays `Tofu`, kittest keeps its `Panic` default.
|
||
|
||
**kittest does not supply these faces.** Its normal `[dependencies] egui` uses the workspace entry
|
||
with `default-features = false`; only `egui_kittest`'s own `[dev-dependencies]` enables
|
||
`default_fonts`/`monochrome_emoji_fonts` (`egui_kittest/Cargo.toml:39-42,65-68`), and a dependency's
|
||
dev-dependencies do not propagate to consumers. LightCraft's workspace/root feature selection is
|
||
therefore what gives every CLI, headless and kittest root its faces; verify each produced graph with
|
||
`cargo tree -e features`, as §5.3 requires.
|
||
|
||
Current source/test gates (source-declared, not executed here): thousands of `#[test]` annotations;
|
||
`cargo xtask ci` runs fmt, clippy `--workspace --all-targets -D warnings`, the HEIF feature check,
|
||
`cargo test --workspace`, GPU probe/backends, `parity`, `layers`, `assets` and the wasm check
|
||
(`xtask/src/main.rs:295-366`). CI matrix files also cover FreeBSD (with craft-fonts), Nix, Windows
|
||
ARM64, packaging and release. `assets/ATTRIBUTION.md` matches every image, font, icon and media
|
||
path; migration screenshots stay in task evidence rather than the repository unless separately
|
||
approved and attributed.
|
||
|
||
## 3. What actually changes in 0.37 (and what does not)
|
||
|
||
The app already receives a root `egui::Ui` through `App::ui` and `Context::run_ui` on 0.36.2, and
|
||
`App::logic`/`App::ui` already exist in 0.36.2. Do not describe that split or root-Ui pass-through as
|
||
introduced by 0.37, and do not mechanically rewrite `update` into `ui` where no `update` exists.
|
||
`eframe 0.36.2` already calls `app.logic(ui.ctx())` before `app.ui(ui)` in each pass and calls
|
||
`run_logic` when hidden; the 0.37 source at `a4f9a17f` has the same shape (see §4.1). The real
|
||
incompatibilities and semantic risks are these. In the table, `egui/src/...` means
|
||
`crates/egui/src/...` in the egui checkout, and `egui_kittest/src/...` means
|
||
`crates/egui_kittest/src/...`; LightCraft paths remain repository-relative.
|
||
|
||
| Change | Effect in LightCraft | Source anchor |
|
||
|---|---|---|
|
||
| `WidgetType` replaced by `accesskit::Role` (`egui::Role`), alias deprecated | `WidgetInfo` constructors and tests must use roles; `CollapsingHeader` becomes `DisclosureTriangle`, `SelectableLabel` becomes `Button`, `Checkbox` becomes `CheckBox` | `egui/src/lib.rs` 0.37 role alias/table; PR #8555 |
|
||
| `Id::new` deprecated in favor of `Id::unique`; `AsIdSalt`/`IdSalt` are explicit | **221** `egui::Id::new` occurrences on 218 lines plus **three** bare `Id::new` calls (`menu_level.rs:19`, `canvas_keys.rs:15,20`, imported with `use egui::Id`) and **8** `Ui::id()` calls; the remaining `*Id::new` spellings are `LayerId`/`FontId`/`TextureId`, unrelated. `Id::new` and `Id::unique` hash identically, so persisted egui state does not reset | `egui/src/id.rs`; `egui/src/ui.rs:363-381`; PRs #8534/#8586/#8542 |
|
||
| `Panel` constructors take `impl AsIdSalt`; panel ID is `parent_ui.scope_id().with_salt(self.id_salt)`, and `Panel::new` stores `IdSalt::new(id_salt)` | This is a **semantic change** from 0.36's exact `Id`. Code that removes/reads `PanelState` must use the same parent-salted key, computed from the original salt source. Passing an already-built `IdSalt` to `Panel::left/right` hashes it **again** (`IdSalt::new(IdSalt)` is a distinct salt — the id_salt.rs unit test `debug_format_nested_salt` proves nesting is not identity) | `egui/src/containers/panel.rs:284-300, 974-975`; `egui/src/id_salt.rs`; PR #8592 |
|
||
| `default_fonts` is no longer an `egui`/`epaint` default; monochrome emoji moved behind `monochrome_emoji_fonts` | The CLI, headless harnesses and any graph without eframe's features lose the bundled faces and the monochrome emoji; `FontDefinitions::default()` becomes empty and the font-count test changes meaning | `egui/Cargo.toml`; `epaint/src/text/font_definitions.rs:127-183`; PR #8583; §2.1 |
|
||
| eframe `clipboard` is a separate feature | Turning eframe defaults off silently selects egui-winit's simulated clipboard | `eframe/Cargo.toml:142-144,168-170`; `egui-winit/src/clipboard.rs` |
|
||
| `ViewportCommand::Screenshot` takes a one-shot `ScreenshotCallback`; `UserData`/`Event::Screenshot` are gone | `issue_screenshots`/`collect_screenshots` move to the CL-2F completion slot; the callback runs on the renderer thread, is `Send + 'static`, and must not touch app state | `egui/src/data/screenshot_callback.rs`; `viewport.rs`; PR #8489 |
|
||
| `Event::MouseWheel` gains `source: MouseWheelSource` | Every initializer in `control.rs`, tests, examples and headless helpers needs a source; use `Unknown` for the control channel | `egui/src/data/input/event.rs:171-195`; `mouse_wheel_source.rs` |
|
||
| `scroll_area::State.offset` is private; `clamped_offset`/`unclamped_offset` are the API | `menu_level` reads `.offset` and writes `st.offset.y`; `right` reads `.offset`. Use `clamped_offset()` for reads and a next-frame scroll target for writes; no private-field workaround | `egui/src/containers/scroll_area.rs:100-148` |
|
||
| Kinetic scrolling/rubber-banding and `MouseWheelSource` are new | Menu/wheel/scroll behavior can differ; `ui.scroll` must carry a source; tests cover wheel vs trackpad/momentum and overscroll | `scroll_area.rs` 0.37 |
|
||
| `ComboBox` is `ComboBox<'a>`; `Atom::custom` takes `IdSalt`; `WidgetAtomResponse::rect` takes `IdSalt` | `widgets::combo_box` needs a named lifetime; `arrow_button` uses `IdSalt::new("arrow")` consistently | `containers/combo_box.rs:45-101`; `atomics/atom.rs:98`; `atomics/widget_atom.rs:228-240` |
|
||
| `Ui::id` renamed `scope_id`; `UiBuilder::id` renamed `scope_id` | Replace the 8 `ui.id()` uses; child UIs use `scope_id`; `id_salt` remains for local children | `ui.rs:363-381`; `ui_builder.rs:59-82` |
|
||
| `Role`/AccessKit tree and strict kittest checks | kittest 0.37 panics on an input widget with no accessible name and on a missing glyph; `HarnessBuilder` defaults are `missing_glyph_policy: Panic`, `check_accessibility: true`, and the check runs after every settle and before every snapshot | `egui_kittest/src/builder.rs:46-60`; `accessibility.rs:26-42`; PRs #8589/#8611 |
|
||
| TextEdit click-away selection regression in 0.37 | `text_field`'s custom Cut/Copy menu and toolbar actions that read the selection can see a collapsed range; keep the existing memo/store logic and add the #8763 regression | upstream #8763, open; PR #8764 open/unmerged |
|
||
| `Style` defaults are roomier, TextEdit layout changes | LightCraft pins spacing/button padding/corner radii in `theme::apply_kind`, but menus, tooltips, scrollbars and plain dialogs can shift; review geometry/pixels, never auto-refresh | PRs #8617/#8445; `theme.rs:397-448` |
|
||
| PRIMARY selection support | New middle-click paste and PRIMARY publishing; verify separately from the normal clipboard, through E4 + W15P | PR #8428; E4; #8657 |
|
||
| `egui_extras`/`egui_kittest` have independent version numbers | Do not infer versions from `egui`; `egui_plot` is unused | crate manifests at `0.37.0` |
|
||
|
||
Not a 0.37 migration item: `Context::root_ui`/`run_pass` (new in 0.37) are not needed by LightCraft's
|
||
`App::ui`, which already receives the root `Ui`. Never call `ctx.root_ui` from inside `run_ui`,
|
||
`App::ui` or `root_ui`; upstream #8535 documents the panic. Use the passed `&mut Ui`. Likewise, do
|
||
not claim `App::logic` is new or replace `App::ui` with a `Context` bridge.
|
||
|
||
## 4. Concrete source design
|
||
|
||
### 4.1 How the hosts actually call the app
|
||
|
||
Verified statically at `a4f9a17f` (and unchanged in shape from 0.36.2):
|
||
|
||
- **Native** (`eframe/src/native/epi_integration.rs`): `App::logic` and `App::ui` are both called
|
||
**inside** the `Context::run_ui` closure, so both run **once per layout pass** (a discard can
|
||
produce more than one). `raw_input_hook` is called once per host `update`, before `run_ui` (and
|
||
before `run_logic` on a hidden logic-only update), not per pass.
|
||
- **Web** (`eframe/src/web/app_runner.rs`): the same — `logic` then `ui` inside the `run_ui`
|
||
closure, per pass.
|
||
- **Headless** (`crates/ui-egui/src/headless.rs:320-325`): `Headless::step` calls
|
||
`HeadlessView::run(raw, |ui| { app.logic(ui.ctx()); app.ui(ui) })`, which is
|
||
`ctx.run_ui(raw, …)`; again per pass.
|
||
- **Hidden**: the integration calls `Context::run_logic`, which updates only window/focus state;
|
||
events, input time and layout remain from the last UI pass.
|
||
- After a completed `run_ui`, `current_pass_index()` is **0** (`ContextImpl::end_pass` takes the
|
||
output with `mem::take`; `run_dyn` restores the count only immediately before the next pass, and
|
||
`run_logic` does not restore an old index). During a second pass it is 1, 2, ….
|
||
- `ViewportInfo::visible()` derives from minimized/occluded and is `None` when unknown. **A hidden
|
||
window can still run a full UI pass**: `egui-winit`'s `ActionRequested::Screenshot` and
|
||
`PaintWhileHidden` both report `wants_paint()`, and eframe treats that as `is_visible` for the
|
||
pass (`wgpu_integration.rs:687-704`, `glow_integration.rs:680-693`). In that forced paint
|
||
`visible()` still reports `Some(false)`.
|
||
|
||
Consequences: (a) `visible()` and fresh egui time are **not** a way to tell UI work from logic work;
|
||
(b) `current_pass_index() == 0` is a valid *known-UI-boundary* test for "first pass of this
|
||
`run_ui`", and it is also true during `run_logic`, so a hidden tick is never suppressed by it; (c) a
|
||
second pass has empty input events (`InputState::begin_pass` replaces events with `new.events`), so
|
||
event-driven code cannot run twice, while time/state-gated code can.
|
||
|
||
### 4.2 The tick contract (no `TickPhase`, no visibility classifier)
|
||
|
||
R1's `TickPhase { First, Later, Hidden }` and its time/visibility classification are **removed**.
|
||
They had no defined first-call, `visible() == None` or forced-hidden-paint branch, and their
|
||
stale-`current_pass_index` rationale was false. The replacement is a small, explicit split with a
|
||
monotonic clock and consumed queues:
|
||
|
||
```rust
|
||
impl LightcraftApp {
|
||
/// Background/queue work. Called before layout on every host pass (native and web
|
||
/// inside `run_ui`, headless in `Headless::step`) and from `Context::run_logic`
|
||
/// while hidden. It may read `ctx.input(|i| i.viewport())` and `i.raw.focused` only
|
||
/// (those `RawInput` fields are refreshed by `run_logic`; `i.focused` and `i.time`
|
||
/// are not). Never events, dropped files or keys.
|
||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||
i18n::set_language(self.ui.language);
|
||
panels::library_problem::logic(self);
|
||
panels::notices::logic(self);
|
||
if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) {
|
||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||
}
|
||
let now = self.tick_now(); // monotonic; see 4.3
|
||
self.drain_control(ctx); // control_rx.take() + try_recv: consumed once
|
||
self.renderer.poll(ctx, &mut self.session);
|
||
merge::poll(self, ctx);
|
||
import::poll_scan(self, ctx);
|
||
sync::poll(self, ctx);
|
||
sync::poll_run(self, ctx);
|
||
tasks::poll(self, ctx);
|
||
import::tick(self, ctx, now); // time-gated; second pass is a no-op
|
||
lightroom_import::tick(self, ctx, now);
|
||
recheck::tick(self, now);
|
||
self.slideshow_tick(ctx, now);
|
||
self.auto_import(ctx, now);
|
||
self.retry_sidecars(ctx, now);
|
||
self.scan_devices(ctx, now);
|
||
self.preview_build_status(ctx, now);
|
||
self.save_status(ctx, now);
|
||
self.pump_background_queues(ctx, now); // faces/denoise/model/focus worker results
|
||
self.collect_captures(ctx); // completion slot: take once
|
||
self.issue_captures(ctx, now); // admit/issue; deadlines use `now`
|
||
self.session.persist_if_dirty();
|
||
if !self.synthetic.is_empty() {
|
||
ctx.request_repaint(); // visible gesture steps
|
||
}
|
||
if self.needs_paint_while_hidden(ctx) { // pending gesture, queued capture or settle
|
||
ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden);
|
||
}
|
||
}
|
||
|
||
/// Input and once-per-layout-frame work: the known UI phase.
|
||
fn ui_pass(&mut self, ui: &mut egui::Ui) {
|
||
let ctx = ui.ctx().clone();
|
||
if ctx.current_pass_index() == 0 {
|
||
self.take_dropped_files(); // from the queue filled once by raw_input_hook
|
||
self.pump_ui_input(&ctx); // the input-reading halves of faces/denoise
|
||
self.update_perf(&ctx);
|
||
}
|
||
self.sync_theme(&ctx);
|
||
self.apply_settings(&ctx);
|
||
if self.fonts_ready {
|
||
shortcuts::handle(self, &ctx); // fresh events only
|
||
}
|
||
self.check_external_editor(&ctx);
|
||
self.resolve_pending_gestures(); // after this pass's layout, see 4.4/4.5
|
||
}
|
||
}
|
||
```
|
||
|
||
Rules that the implementation and its tests must keep:
|
||
|
||
1. **One consumer per queue.** `drain_control` takes `control_rx` out, drains with `try_recv`,
|
||
puts it back; replies are sent exactly once. The screenshot slot is `take`n once. Worker results
|
||
carry a generation id and are applied once. There is no "run the queue again" path.
|
||
2. **`current_pass_index() == 0` guards once-per-host-frame work**: dropped-file consumption,
|
||
input-reading halves (`pump_ui_input`) and perf update, and `import::tick`'s batch commit. A
|
||
hidden `run_logic` also reports index 0, so the same gate lets import work advance on a hidden
|
||
tick. It is *not* the gate for the other background timers: Lightroom import, slideshow,
|
||
auto-import, sidecar retry, device scan, recheck, toasts and autosave live in `logic` behind
|
||
monotonic deadlines. A wall-clock gate cannot substitute for the pass index: a discarded first
|
||
pass that takes longer than the throttle would re-run it inside the same host frame. Where an
|
||
operation needs "how many frames have actually run", use `Context::cumulative_frame_nr()` (one
|
||
per `run_ui`), not the pass index and not `cumulative_pass_nr`.
|
||
3. **Input lives in the UI phase.** `shortcuts::handle`, dropped-file handling and the
|
||
external-editor focus check move out of `logic_inner` into `ui_pass`. A hidden `run_logic` tick
|
||
can no longer re-read the last pass's `dropped_files` (today it would spawn the same import
|
||
again) and cannot consume a queued key.
|
||
4. **Only refreshed window state is safe in `logic`.** `run_logic` refreshes
|
||
`RawInput::viewports`/`RawInput::focused` only; `InputState::focused`, `InputState::time` and
|
||
events stay from the last pass. Read `i.viewport()`/`i.raw.focused` in `logic`, and `i.focused`/
|
||
`i.time` only in the UI phase.
|
||
5. **No freshness inference from visibility.** `Some(false)` + a running `App::ui` is a normal forced
|
||
paint (pending screenshot or `RequestPaintWhileHidden`); `None` is normal for headless and
|
||
`RawInput::default()`. Visibility must not be used to tell UI work from logic work. It *is*
|
||
used deliberately for user-facing pacing: a minimized/occluded (`Some(false)`) slideshow pauses
|
||
instead of advancing and requesting full-size slide renders, and resumes with its remaining
|
||
time on unhide (P2.5 in the post-port review).
|
||
6. **First call / first frame.** `tick_now` is initialised at construction and every timer is an
|
||
`Option`/deadline comparison that is true on the first tick, so `Headless::step` at time `0.0`
|
||
and `ctx.run_ui(RawInput::default(), …)` (whose input time is `prev + predicted_dt`, not 0) both
|
||
run their first logic work. Add explicit tests for both, plus `visible() == None` and a forced
|
||
hidden paint (§6.1).
|
||
7. **No `will_discard` prediction.** A later widget may request a discard after logic has already
|
||
run; correctness comes from one-consumer queues and idempotent side effects, not from guessing.
|
||
|
||
### 4.3 Hidden-time accounting: one monotonic clock, painting stays egui time
|
||
|
||
`run_logic` leaves `ctx.input(i.time)` frozen, so any timer read from it in `logic` is stale.
|
||
Introduce an app clock and use it for everything that must progress while hidden:
|
||
|
||
```rust
|
||
/// Seconds since the app was constructed. Monotonic (`web_time::Instant`): never goes
|
||
/// backwards, even when the window is restored after hours hidden.
|
||
fn tick_now(&self) -> f64;
|
||
```
|
||
|
||
- Use `tick_now` for: `import::tick` (additionally gated by `current_pass_index() == 0` so a
|
||
discarded pass cannot commit twice), `lightroom_import::tick`, `recheck`, slideshow playback
|
||
(`panels/slideshow.rs:157-183`; auto-paused while `ViewportInfo::visible() == Some(false)`),
|
||
auto-import (`lib.rs:1154-1187`), sidecar retry
|
||
(`lib.rs:1190-1214`), device scan, screen-profile refresh (`lib.rs:1316-1322`),
|
||
denoise/faces/focus/model timers, autosave/prefs cadence, capture settle/fallback/timeout
|
||
deadlines, and **toast expiry**. Each stores a next deadline and advances it only when
|
||
`now >= deadline`, so a second pass in the same host frame is a no-op.
|
||
- Toasts: store the deadline as a monotonic app-clock value; `save_status` (`lib.rs:785-808`),
|
||
`preview_build_status` (`lib.rs:756-781`) and `panels::toast` (`panels/mod.rs:80`) read
|
||
`tick_now`, not `ctx.input(i.time)`. A hidden toast then keeps its remaining time instead of
|
||
expiring the instant the window is shown.
|
||
- Keep egui time for painting, animation, gestures and widget interaction (all of which only run in
|
||
the UI phase).
|
||
- The web host's `safety::take_notices` (`apps/lightcraft-web/src/web.rs:654-656`) runs in `logic`
|
||
and today uses `ctx.input(|i| i.time)`; give it the same monotonic deadline.
|
||
- `Headless` exposes its simulated time through the same clock (a test hook; the current
|
||
`Headless::time` starts at `0.0`).
|
||
|
||
### 4.4 Duplicate-work inventory (narrowed)
|
||
|
||
The second pass has **empty events**, so `shortcuts::handle`, dropped-file handling and
|
||
`collect_screenshots`-style event scans cannot run twice from one gesture. The real duplicate
|
||
candidates are state/time-gated calls that `logic` runs on every pass (a monotonic deadline makes
|
||
the second pass in a host frame a no-op), and one call that is literally duplicated today:
|
||
|
||
- `panels::faces::pump` is called twice in one pass (`lib.rs:1139` and `:1228`);
|
||
- `import::tick`, `lightroom_import::tick`, slideshow timers, auto-import, sidecar retry, device
|
||
scan, recheck and the toast/status updates are time-gated, so a slow second pass could fire them
|
||
again;
|
||
- `WebApp::run_inbox` and `load_originals` drain queues on every call (correct — they are consumed
|
||
queues), while `import_dropped` re-reads `raw.dropped_files` on every call and must not run in a
|
||
hidden tick or a later pass; it moves to the UI phase and consumes an app queue filled once in
|
||
`raw_input_hook`.
|
||
- `panels::denoise::pump` (`denoise.rs:45-52`) and `panels::faces::pump` (`faces.rs:705-712`)
|
||
read `ctx.input` keys/pointer/`i.time` to pace background work. Split each into
|
||
`pump_background` (download/index worker results, monotonic deadlines — safe in `logic`, including
|
||
hidden) and `pump_ui_input` (fresh key/pointer activity and its timestamp — UI phase only). A
|
||
hidden tick must never pace work from the previous pass's pointer state.
|
||
- `WebApp::save` stays guarded by its own `last_save` deadline, now on the monotonic clock.
|
||
|
||
There is no evidence that event-driven handlers need multi-pass guards, so do not add them.
|
||
|
||
### 4.5 Control gestures while hidden (geometry, paint requests, one reply)
|
||
|
||
Today `ui.clickWidget`/`ui.dragWidget`/`ui.hoverWidget` read `app.widgets` from the previous pass,
|
||
`ui.pointer` maps through the previous `app.image_rect`, and `raw_input_hook` injects one pointer
|
||
event (or one up-to-release key run) per host frame. A hidden request can therefore click stale
|
||
geometry, and a multi-event gesture can stall after one `RequestPaintWhileHidden`.
|
||
|
||
New contract:
|
||
|
||
- **Geometry is resolved only after a fresh layout.** Control requests that need geometry are
|
||
admitted into a `pending_gestures` list with their original params and `admitted_epoch =
|
||
layout_epoch`; `layout_epoch` (a monotonic counter, not a hash) is incremented at the **end** of
|
||
every completed `App::ui` pass. A request resolves, using that pass's `app.widgets`/
|
||
`app.image_rect`, at the end of the first pass where `layout_epoch > admitted_epoch`. If no UI
|
||
pass has happened since admission, the request stays pending and the app sends `ViewportCommand::RequestPaintWhileHidden` (when
|
||
`ctx.input(|i| i.viewport().visible()) != Some(true)`) so the next host iteration paints.
|
||
If the session has never laid out a UI, reply with an explicit "no current UI frame" error at the
|
||
deadline instead of inventing geometry.
|
||
- **One unapplied step survives repeated hidden ticks.** `raw_input_hook` is called before
|
||
`run_ui` and before `run_logic` (`epi_integration.rs:338-365,413-430`), so a hidden logic-only tick
|
||
can invoke it. The hook injects at most one queued gesture step and marks it in flight; it must
|
||
not inject another step until a completed UI pass (tracked by a UI-pass counter incremented at the
|
||
end of `ui_pass`, never by `visible()`) has consumed that step. The step remains in eframe's
|
||
`pending_raw_input` across hidden ticks unchanged. This prevents two hidden ticks from batching a
|
||
press and release into one later pass, and preserves key-press-through-release semantics.
|
||
- **Each gesture step keeps painting.** While `synthetic` is non-empty (a drag has many moves; a
|
||
click is move+down+up) the app sends `RequestPaintWhileHidden` again after each step is consumed,
|
||
so hidden gestures complete instead of waiting for the user. The hook still injects only one
|
||
pointer move or one key sequence per host update.
|
||
- **Exactly one reply, after delivery.** The reply is deferred until the gesture's final synthetic
|
||
event has been injected into a UI pass (a `pending_gesture.delivered` flag set at the end of that
|
||
pass), or until the monotonic deadline, when it replies with an error. A dropped sender gets no
|
||
reply; a canceled/closed request is removed before replying. This makes a successful reply mean
|
||
"the UI saw the gesture", not "events were queued".
|
||
- `ui.key`, `ui.text` and `ui.scroll` need no geometry but do need a UI pass to be consumed
|
||
(`ctx.request_repaint()` alone only runs `run_logic` while hidden), so they follow the same
|
||
request-paint/one-reply pattern.
|
||
- `ui.screenshot` is **not** a gesture, but it has two phases. Once the
|
||
`ViewportCommand::Screenshot` is processed, `ActionRequested::Screenshot::wants_paint()` makes the
|
||
integration paint a hidden window. A capture that is merely queued or settling in `logic` has not
|
||
reached that phase: while hidden it needs `RequestPaintWhileHidden` from `logic` (the same
|
||
predicate as pending gestures) to reach a UI pass that issues the command. Test both the
|
||
issued-command forced paint and the queued/settling hidden case; do not expect a missing
|
||
issued-command paint to fail.
|
||
|
||
**Implementation amendment (2026-10-24, before code).** The app owns the queue described above:
|
||
|
||
- `layout_epoch` is a `u64` incremented once at the end of every **completed, nondiscarded**
|
||
`App::ui` pass (the same place `ui_passes` advances). `PendingGesture` records `admitted_epoch`,
|
||
an admission-time `GestureFrame` (view, active photo, library generation, dialog discriminant,
|
||
left/right/presets/filmstrip switches), a monotonic `deadline_ms`, the original method and params,
|
||
its reply sender, and the computed result once resolved. At most 32 may be pending; a 33rd is
|
||
refused by a normal control error. The deadline is 5 s from admission (`GESTURE_TIMEOUT_MS`).
|
||
- `ui.clickWidget`, `ui.dragWidget`, `ui.hoverWidget`, `ui.pointer`, `ui.move`, `ui.click`,
|
||
`ui.drag`, `ui.key`, `ui.text`, `ui.scroll`, `ui.zoom` and `ui.clipboard` are queued. Their
|
||
existing parsing/event construction moves to one `control::gesture_events(&mut app, ctx, method,
|
||
params)` function, called **only after** a completed layout newer than admission. Widget ids and
|
||
`app.image_rect` therefore always come from that fresh pass, never the previous registry. A
|
||
widget that no longer exists, a missing image rect, an invalid key/unit/phase or a removed text
|
||
focus answers once with the existing error, after that fresh pass (not at admission).
|
||
- Resolution happens in `finish_ui_pass` after `layout_epoch += 1`. Before computing geometry the
|
||
request's `GestureFrame` must still equal the current frame. A view, active-document, library,
|
||
dialog or panel-layout change between admission and resolution cancels the request with one
|
||
error instead of retargeting the new UI. The per-frame `layout_epoch` advance that makes the
|
||
layout fresh is expected and is not itself a cancellation. A request that never sees a completed
|
||
UI pass (hidden with no paint) answers `no completed UI frame` at its deadline.
|
||
- Resolution appends the gesture's synthetic events and marks the request resolved. It is **not**
|
||
answered yet. The reply is sent when `app.synthetic` is empty after a completed nondiscarded pass
|
||
(for a zero-event action such as a clipboard paste request, after the next completed pass), so
|
||
`{"ok": true}` means the UI consumed the last event. A hidden run keeps sending
|
||
`RequestPaintWhileHidden` while any gesture is unresolved or resolved-but-undelivered; a visible
|
||
run keeps `request_repaint`.
|
||
- The queue is fail-closed on timeout: the request is removed, its reply error is sent once, and
|
||
queued synthetic input is cleared so a late event cannot click after an error. This can discard
|
||
another queued gesture's still-undelivered events; those requests are answered with their own
|
||
timeout/cancel error rather than being retargeted. The queue is intentionally not a general
|
||
scheduler: immediate commands (`engine.execute`, `ui.set`, `ui.inspect`, `ui.resize`,
|
||
`ui.zoomFactor`, screenshot admission) keep their current direct semantics.
|
||
- Limit: the `GestureFrame` cancellation covers the navigation/modal/panel state that can change the
|
||
meaning of an automation id. It cannot prove that a widget with the same id kept the same
|
||
semantic target across an arbitrary catalog refresh inside the same frame; the fresh layout and
|
||
the explicit view/document snapshot are the contract, not a per-widget identity guarantee.
|
||
|
||
### 4.6 Screenshot capture: the shared CL-2F weak completion slot
|
||
|
||
Anchors: `crates/ui-egui/src/lib.rs:909-987` (`drain_control`, `issue_screenshots`),
|
||
`:1034-1057` (`collect_screenshots`), `:1502-1525` (`PendingShot`), `control.rs:432-439`,
|
||
`headless.rs:133-174` and `:310-349`.
|
||
|
||
craft-libs selects one narrow completion-slot helper (`crates/ui/src/capture.rs`, CL-2) after the
|
||
DesignCraft and GridCraft adapters, then freezes the public API (CL-2F). LightCraft is one of the
|
||
craft-ui capture consumers and **adopts that helper** rather than keeping the local
|
||
`mpsc::Receiver`/token design; names and signatures come from the reviewed CL-2F revision, not from
|
||
this document. Every creative capture consumer adopts the CL-2F completion contract:
|
||
`craft-ui-consumers-exact.json` shows eleven apps whose TOML declares `craft-ui` (LightCraft among
|
||
them). All eleven require craft-libs **CL-1** and the frozen **CL-2F** API in the same atomic
|
||
dependency stage as egui/winit; only DesignCraft/GridCraft pilot the provisional CL-2 API, and their
|
||
adapters land before CL-2F freezes it. DeckCraft, FigCraft and ArtCraft Native use
|
||
contract-compatible local adapters without adding the crate, and all fourteen capture consumers
|
||
remain in scope. (The earlier "thirteen craft-ui consumers" wording was wrong; it is not repeated
|
||
here.)
|
||
|
||
LightCraft's adapter (app-owned policy, shared completion transport):
|
||
|
||
- **One record per admitted request**, owned by the app: monotonic request id, path, reply sender,
|
||
`headless`, admission/deadlines (settle ≥3 painted frames and ≥120 ms; compositor fallback 2 s;
|
||
hard timeout after admission), frames waited, reserved bytes, and a snapshot of capture-time
|
||
geometry (`content_rect`, `viewport_pixels`, `pixels_per_point`, UI zoom). "Frames" means host
|
||
frames (`Context::cumulative_frame_nr()`, incremented once per `run_ui`), not passes, so a
|
||
multipass frame counts once and does not make a screenshot fire early. Take the geometry from the
|
||
refreshed viewport information (`i.viewport().inner_rect`, `native_pixels_per_point`) when present,
|
||
otherwise from the last laid-out content rect; a hidden window has no new layout, so both are the
|
||
last known size, and the reserved bytes are revalidated against the returned image.
|
||
- **Issue** with a callback holding **only** a `Weak` reference to the app-owned completion slot;
|
||
the app owns the strong slot and a live, owner-specific completion waker. The callback receives
|
||
pixels only: no `Context` clone, no `&mut LightCraftApp`, no encoding, file I/O or command
|
||
execution. On completion it locks the slot, stores the image, unlocks, and only then invokes the
|
||
waker; it never wakes itself from inside the lock or re-enters the app. A late or canceled
|
||
callback finds no upgradable slot and is a no-op. The renderer owns the callback until it fires,
|
||
so this is not a `Context` reference cycle; `Weak` lets cancel/close drop the app state and makes
|
||
a late result harmless. A pending capture keeps `request_repaint_after(16 ms)`, which also runs
|
||
`run_logic` on a hidden native window, so the woken poll collects completion while hidden.
|
||
- **Completion**: take the slot once, release the byte reservation with a checked subtraction, and
|
||
bind the image to the **issuance** UI geometry, DPI and zoom epoch. Any change to logical viewport
|
||
size, surface pixels, `pixels_per_point` or UI zoom between issue and save cancels and retries (or
|
||
replies with an error at the deadline) — even if the geometry changes back to the original values
|
||
before the image arrives. Never combine old pixels with new geometry, and never accept an image
|
||
merely because its dimensions happen to match. Reject an image larger than the reservation and
|
||
release its bytes, then encode/write on the UI thread and reply exactly once. Derive the effective
|
||
scale from the returned image dimensions and the recorded logical viewport (E2 surface fitting can
|
||
shrink the image); record that with the result. Additive result fields (`scale`, `viewport`) are
|
||
optional and backward compatible.
|
||
- **Admission is bounded before issue** using the shared capture defaults: at most **4**
|
||
outstanding requests, at most **128 MiB** per image, and at most **256 MiB** aggregate reserved
|
||
in-flight/completed bytes. App-specific caps may be stricter, never looser. Require
|
||
`0 < width,height <= max_texture_side` (16 384 at inspection), compute the reservation with
|
||
checked `width * height * 4` before issuing, and revalidate it after any geometry/DPI/zoom change
|
||
before issue. Reserve the **worst surface the request can produce** — the full logical viewport ×
|
||
`pixels_per_point` × UI zoom × 4 bytes — not the smaller E2-fitted size, and keep
|
||
completed-but-not-yet-drained images counted until they are taken (in-flight and completed bytes
|
||
are both checked before issue). The renderer's own readback allocation is accounted separately in
|
||
peak RSS; a post-callback size check alone does not bound peak memory.
|
||
- **Cancel/close/drop/timeout**: remove the record first; the late callback's `upgrade()` fails and
|
||
the image is dropped with no second reply and no panic. On shutdown all reservations return to
|
||
zero. Tests assert retained bytes reach zero after drain/cancel.
|
||
|
||
**Implementation amendment (2026-10-24, before code): path identity at write time.** Admission pins
|
||
the destination as a `WriteTarget` owned by the app record:
|
||
`WriteTargetState::Absent` (the directory entry did not exist) or `Existing(FileIdentity)` (device
|
||
and inode on Unix; canonical path, length and creation time on Windows, whose volume/file-index
|
||
metadata is still unstable; canonical/len/mtime elsewhere; taken from `symlink_metadata` so a link
|
||
replacement is a different entry). Admission
|
||
still runs the library `OriginalGuard`. Immediately before encoding/writing, **both** the renderer
|
||
callback completion and the CPU/headless fallback call `revalidate_write_target`:
|
||
- an `Absent` target must still be absent; a file created after admission (ordinary file, imported
|
||
original, hard link or symlink) is refused, never replaced;
|
||
- an `Existing` target must still be the same directory entry and must pass `OriginalGuard` again
|
||
against the current catalog, so a photo imported at that path while the capture was queued is
|
||
refused;
|
||
- an existing allowed screenshot destination keeps its normal overwrite behavior.
|
||
An absent destination is written through the new host `write_new` service
|
||
(`lightcraft_engine::export::write_file_new` → `safe_file::write_new`): it prepares a complete temp
|
||
file and publishes it with `create_new`/hard-link semantics, so the final TOCTOU window cannot
|
||
replace a file that appeared. The bounded temp file is encoded bytes only. An existing destination
|
||
uses the existing atomic writer after revalidation. Failure to configure `write_new` for an absent
|
||
target is an explicit error (fail closed), not a fallback to the destructive writer. Limits: an
|
||
existing destination can still be swapped by another process in the instant between revalidation
|
||
and the atomic rename; closing that would require an OS conditional-replace primitive. The lexical parent is now re-resolved before completion and compared with its admitted
|
||
canonical parent; a retargeted directory symlink is refused. The deterministic tests cover an absent
|
||
path that becomes an ordinary file, an absent path that becomes a catalogued original through a
|
||
hard link, an existing original/symlink refused at admission, an existing allowed overwrite, and an
|
||
existing target replaced by a new entry while queued, and a parent symlink retargeted between
|
||
admission and completion (neither destination receives a screenshot).
|
||
- **Both headless entry points change**: `HeadlessView::run` retains `ViewportCommand::Screenshot`
|
||
callbacks and calls `callback.complete(Arc::new(self.paint()))` after the pass; `Headless::step`
|
||
deletes its `ViewportCommand::Screenshot(user_data)` arm (it no longer compiles in 0.37) and no
|
||
longer pushes `Event::Screenshot`. Completion happens exactly once, in the headless host.
|
||
- `headless_host` remains the CLI/headless flag; `WebApp` does not set it, and the web path has no
|
||
control server (§4.11, §8).
|
||
- Boundary: the shared piece is only the completion slot. Request ids, paths, settle delays,
|
||
deadlines, reply schema and `Services.write/png` stay LightCraft policy in
|
||
`lightcraft-ui-egui`; do not grow a generic screenshot framework in craft-ui for LightCraft.
|
||
|
||
### 4.7 Panels, IDs and docking
|
||
|
||
Anchors: `crates/ui-egui/src/panels.rs:52-77` (`resizable_side`),
|
||
`panels/bottombar.rs:24,371`, `panels/strip.rs:19`, `panels/topbar.rs:57`,
|
||
`panels/library_problem.rs:169`, `crates/ui-egui/src/docking.rs:430`, `shortcuts.rs:339`.
|
||
|
||
`resizable_side` must pass the **original salt source** to the panel and derive the persisted key
|
||
from the same parent scope:
|
||
|
||
```rust
|
||
// `Panel::new` stores `IdSalt::new(id)`; `Panel::id` is `parent_ui.scope_id().with_salt(store)`.
|
||
// `Id::with(salt)` is exactly `with_salt(IdSalt::new(salt))`, so this is the panel's real key.
|
||
let pid = ui.scope_id().with_salt(egui::IdSalt::new(id));
|
||
ui.ctx().data_mut(|d| d.remove::<egui::containers::panel::PanelState>(pid));
|
||
let panel = if left { egui::Panel::left(id) } else { egui::Panel::right(id) };
|
||
// ... show ...
|
||
if !ui.ctx().is_being_dragged(pid.with("__resize")) {
|
||
return None;
|
||
}
|
||
```
|
||
|
||
Why this exact form: `Panel::left(salt)` with an already-built `IdSalt` would store
|
||
`IdSalt::new(salt)` and derive `parent.scope_id().with_salt(IdSalt::new(salt))`, which is not the
|
||
`pid` above. The `resize_widget_id` is `panel_id.with("__resize")`, so the drag check matches only
|
||
when `pid` is the panel's real key. Do **not** add a public `panel_state_id()`-style method to
|
||
production code: the mapping is a local detail, and a public helper would invite callers to use a key
|
||
that the panel never stores. Tests derive the key themselves from the same `parent_ui.scope_id()` and
|
||
salt, or assert on the returned response rect and the `panel:*` widget registration.
|
||
|
||
Scope of the risk, honestly: at this revision `resizable_side` has **no production caller** (the
|
||
shipped side panels are craft-ui `DockArea` panels and the `Panel::top/bottom/right` bars). The bug
|
||
is latent, not user-visible today; fix it anyway, and keep the mutation test so a future caller
|
||
cannot reintroduce it. The `bottombar` fixture currently reads
|
||
`PanelState::load(&ctx, Id::new("bottom_bar"))` (`bottombar.rs:371`); under 0.37 the key is
|
||
`ctx.viewport_id().root_ui_id().with_salt(IdSalt::new("bottom_bar"))` in that harness (the fixture
|
||
shows the panel in the root `Ui`), or read the response rect instead. Audit every
|
||
`Panel::top/bottom/left/right` id whose value is persisted or queried; fixed-size bars can keep
|
||
string salts, but tests must not assume the old exact key.
|
||
|
||
The first compatibility PR also migrates the old panel state explicitly: 0.36 stored
|
||
`PanelState` under the exact `Id`, 0.37 stores it under the parent-salted key, so old entries are
|
||
unreachable. Do not try to read them: at start, remove the legacy exact-`Id` `PanelState` entries for
|
||
the known panel salts (`top_bar`, `bottom_bar`, `tool_strip`, `library-problem-banner` and any
|
||
`resizable_side` caller) with `data_mut(|d| d.remove::<PanelState>(legacy_id))`, document the one-time
|
||
panel-width/layout reset in the stage notes, and assert in a test that the new keys are used. No
|
||
user-visible document, workspace JSON, catalog or XMP data is migrated; this is egui memory only.
|
||
|
||
Second window: it already uses `ctx.show_viewport_immediate`, and `CentralPanel::show` takes
|
||
`&mut Ui` in both 0.36.2 and 0.37, so this is not a 0.37 rewrite. Verify under 0.37 that embedded and
|
||
native callbacks both register `view:secondWindow`, render the active photo and close correctly.
|
||
`#8748` (running an overdue window's UI without painting so deferred children open/close while the
|
||
root is hidden) is closed upstream but **not present** in the inspected fork source (no
|
||
`PassMode::Overdue`): P7c in the required P8 revision owns the selected fix. The second window while
|
||
the root is hidden/minimized remains an explicit real-platform test, not an assumed fix. Do not call
|
||
`root_ui` from the viewport callback.
|
||
|
||
Docking stays `craft_ui::docking::DockArea`. LightCraft is an LTR application and must state that
|
||
policy explicitly at both call sites (`docking.rs:430`, `shortcuts.rs:339`) with the reviewed
|
||
`DockArea::direction(DockDirection::LeftToRight)` builder from craft-libs §7.7 (exact name from the
|
||
reviewed revision). Defaults must not be relied on. Persisted split first/second, axes, ratios,
|
||
floating rectangles and logical tab order must be unchanged, and painting, clipping, hit testing,
|
||
AccessKit order and drag insertion must use the same rectangles. `UiState.docking` format is not
|
||
migrated; verify save/reopen and `ui.dock` behavior in `tests/docking_workflows.rs`, plus that LTR
|
||
geometry is byte-identical to the current behavior.
|
||
|
||
### 4.8 Fonts, language, accessibility
|
||
|
||
Anchors: `crates/ui-egui/src/theme.rs:260-332` (`install_fonts_with_chinese`,
|
||
`font_definitions_with_chinese`), `i18n.rs:218-223`, `text_field.rs:180-235`,
|
||
`widgets.rs:1004-1020`, `tests/accessibility.rs`, `tests_keywording.rs:558-583`.
|
||
|
||
- Enable `default_fonts` **and** `monochrome_emoji_fonts` on the workspace `egui` dependency, and
|
||
keep eframe defaults off in both apps while adding native `clipboard` (§2.1). This is a LightCraft
|
||
root/workspace decision, not inherited from kittest: kittest's normal `egui` dependency is
|
||
`default-features = false`, and only its own private dev-dependencies enable the fonts, which do
|
||
not propagate. Verify every root (native, CLI/headless, kittest, wasm) resolves them through
|
||
`cargo tree -e features`/`cargo metadata`, not through the lockfile (§5.3); a consumer test root
|
||
without the workspace features would otherwise see an empty `FontDefinitions::default()`.
|
||
- Tests: assert `FontDefinitions::default()` contains `Hack`, `Ubuntu-Light`, `egui-icons`,
|
||
`NotoEmoji-Regular` and `emoji-icon-font` (`epaint/src/text/font_definitions.rs:128-159`; the
|
||
family key is `Ubuntu-Light`, not `Ubuntu`); assert U+1F642 `🙂` and U+1F44D `👍` are paintable in
|
||
the Proportional family and that an emoji keyword/filename renders without tofu; keep the existing
|
||
font-count relation (`theme.rs:700-705` compares against `FontDefinitions::default()`, so adding
|
||
the two default emoji faces keeps the relation valid — re-check, do not silently update).
|
||
- Font fixtures for measurement/edge cases come from the egui plan §10.6 pins: craft-fonts
|
||
`2481a8a324b986a45954e90045ed1f8f2efaaf3c` (Arabic/Thai/CJK SC/Japanese, manifest-recorded
|
||
SHA256 + OFL) and the test-only Hebrew variable font and Noto Color Emoji v2.047 with their
|
||
recorded SHA256 and licences. Fetch them into the task's fixture directory; **copy no font file
|
||
into this repository** and do not add them to `assets/ATTRIBUTION.md` (nothing new is committed).
|
||
`CRAFT_FONTS_DIR` stays the app's font input; a future bundled font belongs in craft-fonts.
|
||
- Keep `egui_system_font_provider`/`system_fonts` off unless a separate measured decision adopts the
|
||
startup/coverage trade-off: the product policy is bundled Inter + craft-fonts and must stay
|
||
deterministic. #8512's startup cost is a separate workload if that decision is revisited.
|
||
- Production `MissingGlyphPolicy` stays `Tofu`; kittest keeps `Panic` (`HarnessBuilder` defaults in
|
||
0.37 are `missing_glyph_policy: Panic`, `check_accessibility: true`). **All** existing harnesses
|
||
are therefore strict from the first green compatibility stage: every unnamed input and every
|
||
missing glyph must be fixed (names via `Response::labelled_by`/`accessible_name`/
|
||
`ui.label_inputs_by`, real fallback faces), never by disabling the check globally. A deliberate
|
||
tofu/unnamed probe opts out locally in that test only.
|
||
- Wire the fork's `Context::set_language` (`context.rs:2629`) from `i18n::language()` on start and
|
||
when it changes, so the AccessKit root language and bidi runs follow the locale. Command ids,
|
||
catalog data and user strings stay logical; this is presentation only. The in-process i18n
|
||
remains the source of truth. Do not import craft-libs #198 or any font asset.
|
||
- Keep `TextField`'s custom menu and selection memo; add the #8763 click-away regression (§6.3).
|
||
|
||
### 4.9 Text input, IME, RTL, clipboard and PRIMARY
|
||
|
||
- `Event::MouseWheel` initializers in `control.rs`, `headless.rs`, examples and tests get
|
||
`source: egui::MouseWheelSource::Unknown`; `ui.scroll` accepts optional
|
||
`"source": "unknown"|"wheel"|"trackpad"|"momentum"`, `"unit": "point"|"line"|"page"` and
|
||
`"phase": "start"|"move"|"end"|"cancel"`. Omitted values keep the pre-0.37 request meaning:
|
||
`Unknown`, `Point`, `Move`. Every value is validated before an event is queued; an invalid value
|
||
is a control error and changes nothing. This lets tests drive the grid/filmstrip
|
||
`MouseWheelUnit::Line` path and trackpad/momentum `TouchPhase::Start`/`End` cycles, which the
|
||
point/move-only API could not represent. `docs/control-protocol.md` documents the three optional
|
||
keys. Unit tests assert wheel/line/start, trackpad/point/end and the backward-compatible omitted
|
||
defaults.
|
||
- `raw_input_hook` keeps the §4.5 one-step-per-UI-pass contract; a logic-only tick must not drain
|
||
another step. `Event::PasteImage` and `MiddleClickPaste` are not app shortcuts — `keymap.rs`'s
|
||
recorder ignores them.
|
||
- **E21b/P7b is a new blocking prerequisite, not existing E21 behaviour.** Candidate egui-winit
|
||
never calls `WindowExtWayland::set_ime_surrounding_text`, and `egui::IMEOutput` carries no logical
|
||
text. The complete shared contract is in egui plan P7b and `ime-surrounding-gap.md`; LightCraft
|
||
must take all of it, not a weaker subset:
|
||
- owner/context-generation identity is present even when surrounding text is **Unavailable**. An
|
||
available **empty** document is distinct from unavailable and is sent explicitly as an empty
|
||
payload, not omitted;
|
||
- changing owner or available/unavailable support clears the cached text and commits the
|
||
protocol's disable/enable boundary (not merely empty text); same-owner ordinary updates preserve
|
||
composition; the old preedit is canceled, not inserted into the new editor; there is no
|
||
cross-window bleed;
|
||
- the final completed pass's descriptor, including an authoritative `None`, is what
|
||
`PlatformOutput::append` uses (replace `ime.or(self.ime)`); descriptor, owner and payload are
|
||
one atomic value;
|
||
- focus epochs and protocol serials are tracked separately: a current-epoch `done` with a
|
||
mismatched serial applies incoming edits **once** and defers outgoing state until a matching
|
||
serial; an obsolete owner/epoch invalidates its pending batch. Never drop valid incoming edits;
|
||
- the payload is bounded (≤4000 UTF-8 bytes, valid boundaries, preedit excluded, byte
|
||
cursor/anchor, explicit unavailable when a selection cannot fit) and the **new** payload path
|
||
allocates no full-document clone. Existing `TextEdit` change-detection/undo clones are a
|
||
separately measured baseline, not rewritten here;
|
||
- built-in `TextEdit` produces the payload after delete/commit/cursor updates (logical model
|
||
text, preedit removed, byte indices remapped) and the Wayland adapter sends it through W27.
|
||
LightCraft has no custom `IMEOutput` producer: its `TextField` wraps the built-in `TextEdit` and
|
||
other panels use `TextEdit` directly, so it **inherits** the producer and must test the inherited
|
||
behaviour. The family's custom editors (FilmCraft `panels/graphics.rs` and the others in
|
||
`ime-surrounding-gap.md`) need real logical-model/preedit/UTF-8-index adapters before family
|
||
acceptance; `None` placeholders are not a completed port.
|
||
- IME tests: outgoing surrounding text immediately after deletion-only and delete+commit batches;
|
||
preedit excluded; reversed selection; supplementary/combining/RTL text; cursor movement; a very
|
||
long line and an over-limit selection (explicit unavailable, not a truncated claim); available
|
||
empty vs unavailable; field and two-window switches; owner transition with committed
|
||
disable/enable; password/no-IME clears; focus loss/reset/late serial; a current-epoch serial
|
||
mismatch applying incoming edits once while outgoing waits, and a stale-epoch batch discarded;
|
||
multi-pass final output including authoritative `None`. One native Wayland
|
||
Vietnamese/predictive trace checks the outgoing protocol plus editor text and one undo
|
||
transaction.
|
||
- Bidi: a mixed Arabic/Hebrew + Latin filename and keyword stays logical in catalog/XMP while
|
||
painted text/caret/selection follow visual order, with no double bidi and no tofu when the
|
||
craft-fonts face is present. The UI stays LTR; do not enable `Style::right_to_left`.
|
||
- Clipboard: native `clipboard` feature (§2.1), normal cut/copy/paste through `ui.clipboard`, the
|
||
text-field menu, and PRIMARY middle-click. E4 and **W15P** are one compatibility unit: the
|
||
inspected E4 uses an arboard fallback and does not provide the required winit-owned per-seat
|
||
PRIMARY path. Require the complete P8/W15P revision: PRIMARY has its own protocol object on the
|
||
existing connection/event queue (SCTK 0.19.2 `PrimarySelectionManagerState`/device/source/offer;
|
||
never a second regular `wl_data_device`), and accepts a fresh trigger serial from **either a
|
||
pointer press or keyboard focus** on the focused seat — do not impose a keyboard-only rule.
|
||
Selection claims are synchronous while the trigger serial is fresh; serving and transfer are
|
||
asynchronous. P2b replaces blocking reads/writes on the egui event path with caller-owned bounded
|
||
async transfer handles: a total **2 s** deadline (not a per-`WouldBlock` poll timeout), a
|
||
**16 MiB** byte cap, at most **four** read workers and **two** writer workers with queue depth
|
||
**8**, an owner-specific live completion waker, and cancellation/discard of stale results when
|
||
focus, target, owner or input generation changes. Test on wlroots/KWin/Mutter: keyboard- and
|
||
pointer-triggered selection with valid per-seat serials, middle-click paste, password exclusion,
|
||
no reclaim of an unchanged selection, owner/seat change, empty/absent offer, self-paste, slow
|
||
trickle, oversized offer, cancellation, and UI event-loop responsiveness during a slow transfer.
|
||
A compositor limitation is recorded, not papered over with a control-channel substitute.
|
||
|
||
### 4.10 GPU and renderer behavior
|
||
|
||
Anchors: `apps/lightcraft/src/window_gpu.rs:104-200` (`choose`, `base_options`, `probe`),
|
||
`main.rs:917-940`, `crates/ui-egui/src/softpaint.rs`, `crates/ui-egui/src/render.rs`.
|
||
|
||
- Keep the Windows probe/fallback design: DX12 → Vulkan → GL → glow, with error scopes and a
|
||
non-panicking wgpu error handler. `Renderer::new(device, format, RendererOptions)` is unchanged in
|
||
the inspected source; re-run the probe tests and the Windows startup matrix.
|
||
- E2/E3 (surface fit and configure/renderer error scopes) are required for very large or
|
||
multi-monitor surfaces; test a window larger than the device limit via
|
||
`LIGHTCRAFT_GPU_LIMITS=webgpu` and the fork's unit tests. A surface failure must be a typed error
|
||
and the next renderer/CPU fallback, never a panic.
|
||
- #8736 (DX12 FXC shader-compile cost) is measured as its **own** workload: the probe's pipeline
|
||
construction and eframe's first frame separately, fresh processes. A clamp-only shader change is
|
||
not a valid fix (it changes Repeat/MirroredRepeat semantics); evaluate the upstream/float-`select`
|
||
or pipeline-constant design with equivalent wrap tests.
|
||
- #8731 (plain-text tessellation) is its **own** workload: a text-heavy fixture (grid metadata,
|
||
menu labels, panel rows) with mixed color-glyph rows, baseline/stock-0.37/fork. Do not cache or
|
||
alter texture wrap/filter semantics to hide it.
|
||
- Photo textures remain `TextureOptions::LINEAR`; `softpaint`'s CPU mirror preserves nearest vs
|
||
linear per `ImageDelta` and texture wrap. kittest uses `RendererOptions::PREDICTABLE`; check
|
||
#8441's nearest-filter fix did not change preview/texture semantics.
|
||
- On DPI/zoom/resize, verify textures are updated/freed, the screenshot size contract stays
|
||
`viewport_pixels`/`resized_viewport`, and the softpaint shadow context uses the same UI zoom.
|
||
|
||
### 4.11 Host adapters and persistence
|
||
|
||
- Native `App::logic` becomes `self.0.logic(ctx);` then the native menu and `PrefsWriter::tick`
|
||
(both on the monotonic clock). `App::ui` stays one `self.0.ui(ui)`. Do not call `self.0.logic`
|
||
again from `ui`. `PrefsWriter::tick` must not record `Seen::of` window geometry from a hidden
|
||
`run_logic` pass whose `content_rect` is stale: keep the last known placement.
|
||
- Web `WebApp::logic`: `safety::take_notices` (monotonic), `run_inbox` (every call: consumed queue),
|
||
`load_originals` (every call: drains a queue), the worker budget, then `self.app.logic(ctx)`, then
|
||
`save` (own monotonic deadline), then the bench step (UI phase). `WebApp::ui` remains one
|
||
`self.app.ui(ui)` plus the first-frame log. `import_dropped` moves into the UI phase and consumes
|
||
the queue filled once by `WebApp::raw_input_hook`, so neither a later pass nor a hidden tick can
|
||
re-import the same drop.
|
||
- Keep `raw_input_hook` once per host frame; it takes synthetic events and dropped files into
|
||
app-owned queues, and the in-flight step gate in §4.5 prevents repeated logic-only ticks from
|
||
draining more than one synthetic step. Do not move injection into `ui`, where it would repeat on multipass.
|
||
- Keep `PrefsWriter` and `UiState` JSON unchanged. The only visible ID change is egui memory/panel
|
||
layout; `UiState` docking, settings, recent libraries, keymap and library catalog formats are not
|
||
migrated. Test edit → save → close → reopen and undo semantics with the existing library tests.
|
||
|
||
## 5. Dependency graph, pin and lockfile acceptance
|
||
|
||
### 5.1 Intended graph
|
||
|
||
```text
|
||
lightcraft / lightcraft-web / lightcraft-cli / lightcraft-ui-egui
|
||
-> egui 0.37.0 (published storytold revision, immutable SHA)
|
||
+ epaint, epaint_default_fonts, ecolor, emath
|
||
-> eframe 0.37.0, egui-winit, egui-wgpu, egui_glow
|
||
-> egui_kittest 0.37.0 (dev) -> kittest 0.5.0 (registry), accesskit 0.25.x
|
||
-> winit 0.30.13 (published storytold revision, immutable SHA)
|
||
-> craft-ui from the reviewed craft-libs revision (stock-egui-0.37 source compatible, CL-2F)
|
||
```
|
||
|
||
An incompatible `craft-ui` bump in any consumer is part of the same atomic dependency stage as the
|
||
egui/winit graph; never a separate version step that leaves the suite half-migrated.
|
||
|
||
Root `[patch.crates-io]` must name every used package at the same full SHA: `egui`, `eframe`,
|
||
`egui-winit`, `egui-wgpu`, `egui_glow`, `egui_kittest`, `epaint`, `epaint_default_fonts`, `ecolor`,
|
||
`emath`, and `winit`. Add `egui_extras` only if it enters the graph. Do not patch `egui_plot`,
|
||
`egui_dock`, `egui_inspection` or `egui_system_font_provider` unless used. Never use a branch name
|
||
or a machine-local path in a publishable manifest.
|
||
|
||
`craft-ui`'s own `[patch]` does not propagate: LightCraft's root patch is what gives
|
||
`eframe`/`egui-winit` the fork. A graph that resolves `winit` from crates.io while `egui-winit`
|
||
expects the fork API is a failure. `craft-ui` must compile against the stock egui 0.37 source API
|
||
(PR 199's role/`scope_id`/`IdSalt` edits) so the shared crate stays publishable; fork-only APIs stay
|
||
out of craft-ui. Pin `craft-ui` to the reviewed craft-libs revision and verify its `serde` feature
|
||
under LightCraft's root patch.
|
||
|
||
### 5.2 craft-libs: use the reconciled base, never the stale trees
|
||
|
||
- Current local shared-library base: `3832c47ef68a0e6214f2c1e85f3526b6adcb686e`. It already
|
||
carries the egui 0.37 API port (the historical `8614e4a` shape and the `1764bca` README hunk);
|
||
those commits and the earlier `49190884278f16c9a9f5823d7698a8ffb5deb062` main are history, not
|
||
the base to apply again.
|
||
- PR 199 head `b68de8c5f4f1c060904d211a7b1320aa0f7bce78` and the audit merge
|
||
`ec8840af8c5fda695cf195cdd05e5ae078415c07` miss `craft-platform` and revert newer
|
||
security/settings/docking/printer fixes. Never merge either as a tree.
|
||
- Keep the `craft_ui::capture::CaptureSlot` consumer adapter on the reconciled base, preserve
|
||
every newer fix, and re-run craft-ui's own tests and visual fixtures after the port. The CL-2F
|
||
helper and the exact immutable publish SHA remain central-publication gates; neither is guessed
|
||
here.
|
||
|
||
### 5.3 Checks
|
||
|
||
Add an `xtask` dependency check (for example `xtask/src/deps.rs`, invoked by `cargo xtask ci`
|
||
before clippy). The check takes its expected graph from a mode/fixture: L1 runs it in 0.36.2
|
||
baseline mode and is green today; L2a flips the expected graph to the 0.37 P8 graph in the same
|
||
atomic dependency commit. The negative fixtures run in both modes. `Cargo.lock` records packages,
|
||
versions and sources but **not resolved features**, so it can only prove identity:
|
||
|
||
- lockfile/metadata identity: exactly one `egui`/`epaint` family source and one `winit` source, all
|
||
at the expected git URL and full revision; no registry copy beside them; `craft-ui` and
|
||
`craft-docpath` at the reviewed revision, not a branch or a path;
|
||
- resolved features from `cargo metadata` + `cargo tree -e features` for every accepted root
|
||
(native, headless/CLI, tests, wasm): `egui` has `default_fonts` and `monochrome_emoji_fonts`;
|
||
native `eframe` has `clipboard`; the wasm root does not accidentally pull `wgpu`; `craft-ui`'s
|
||
features resolve as intended. Record the raw output in the evidence;
|
||
- negative fixtures, each of which must fail: missing patch, branch pin, second registry `egui`, old
|
||
`craft-ui`, machine-local path, feature-less `egui` (the last one only through the
|
||
metadata/tree mechanism — a lockfile-only check cannot catch it);
|
||
- `wasm-bindgen` in the lock matches the CLI (`xtask/src/web.rs` already checks this).
|
||
|
||
Run `cargo tree -d`, `cargo tree -i egui@0.37.0`, `cargo tree -i winit@0.30.13`,
|
||
`cargo metadata --locked`, `cargo check --locked --workspace --all-targets` and the wasm graph under
|
||
the same pin in managed jobs (§7.1). A clean checkout must fetch every approved revision and pass
|
||
`--locked` without local overrides. Recheck remote heads before merge; an immutable SHA is required
|
||
even if the remote branch moved. Record the lockfile SHA-256 and keep the 0.36.2 lockfile for
|
||
rollback. `accesskit` moves to 0.25.x, `kittest` to 0.5.0; review that churn. Nix's `cargoLock`/
|
||
`allowBuiltinFetchGit` must fetch the git sources; run `nix build` if available, otherwise record
|
||
the explicit unavailable gate.
|
||
|
||
## 6. Regression assertions
|
||
|
||
### 6.1 Functional and lifecycle
|
||
|
||
- **One command/undo step per user action under multipass.** Use the existing
|
||
`right.rs:1161` `request_discard` path plus a test-only forced discard, press one rating/flag key,
|
||
and assert one catalog op/undo entry, one toast, one repaint request and identical settings.
|
||
Repeat through the control channel.
|
||
- **Control gesture delivery and screenshot paint predicate (implementation amendment).** A queued
|
||
`ui.clickWidget` with a stale registry answers only after a completed fresh layout; an id that
|
||
disappears answers once; a view/dialog/document change between admission and resolution answers
|
||
once with a cancel error; a hidden gesture receives `RequestPaintWhileHidden` while unresolved or
|
||
undelivered and replies only after its last synthetic event is consumed; a 5 s deadline answers
|
||
once and clears the queued events. A screenshot that is only queued/settling (`sent_at_ms ==
|
||
None`) requests a hidden paint; one already issued (`sent_at_ms == Some`) does not, while its
|
||
2 s compositor fallback and hard timeout checks still run every logic tick.
|
||
- **Hidden logic continues.** Repeated `ctx.run_logic` calls while hidden advance
|
||
import/Lightroom-import/recheck/task/autosave/persist/retry/slideshow work, do not consume a queued
|
||
key/text/drop, and do not replay the last pass's dropped files. A hidden tick that invokes
|
||
`raw_input_hook` leaves one queued synthetic step in flight; repeated hidden ticks must not inject
|
||
further steps or batch press+release, and the next full pass consumes exactly one. Cover: a
|
||
completed multipass frame followed by hidden ticks (`current_pass_index() == 0` each time, so
|
||
nothing is skipped); `visible() == Some(false)`; a forced hidden paint with pending input and with
|
||
a pending screenshot; restore with buffered events (each consumed once); a worker completion
|
||
between passes.
|
||
- **First frames.** `Headless::step` at simulated time `0.0` runs logic and lays out; a harness that
|
||
calls `ctx.run_ui(egui::RawInput::default(), …)` runs logic; `visible() == None` is treated as
|
||
normal; the first forced hidden paint processes input.
|
||
- **Screenshot capture.** Two outstanding requests get their own image and exactly one reply; a
|
||
queued/settling hidden request receives `RequestPaintWhileHidden` before issue while an issued
|
||
screenshot forces its own paint;
|
||
complete out of order; drop/cancel then complete the callback (no reply/file/panic; reservation
|
||
back to zero); 2 s compositor fallback and hard timeout; an issued hidden-window request completes
|
||
via the screenshot's own forced paint while a queued/settling one first needs the paint request;
|
||
headless request completes through `HeadlessView::run` and
|
||
`Headless::step` (both, once); a resize between issue and completion retries or errors rather than
|
||
mixing geometry; oversized dimensions are rejected before issue.
|
||
- **Control gestures while hidden.** `ui.clickWidget`, `ui.dragWidget`, `ui.pointer` with
|
||
`space: "image"`, and a multi-step drag each complete while the viewport reports hidden, using the
|
||
first fresh layout after admission, send a paint request per step, and reply exactly once after
|
||
the last event was delivered. A widget that no longer exists replies with an error once.
|
||
- **Save/reopen.** Edit exposure/keywords/mask, save/persist, close, reopen the same synthetic
|
||
library; assert document meaning, selection/workspace and undo. Assert `UiState` JSON round-trips
|
||
and docking/floating geometry survives unchanged.
|
||
- **Docking and second window.** Real drag → float → close → reopen and `ui.dock` invalid requests
|
||
preserve the selected tool/layout (`tests/docking_workflows.rs`); explicit LTR direction leaves
|
||
saved rectangles and tab order unchanged; the embedded second window renders the active photo and
|
||
closes; the native second window focus/close/reopen is a real-platform test.
|
||
|
||
### 6.2 Geometry, hit-testing and pixels
|
||
|
||
At 1400×900 and 900×700, scales 1.0/1.5/2.0, dark/light themes:
|
||
|
||
- top bar 42 pt, bottom bar 48 pt, tool strip 48 pt, default side widths from `UiState`; panel order
|
||
top → banner → strip → central → side panels; canvas rect equals the remaining rect within 0.5 pt;
|
||
- dragging the left/right inner edge by ±120 pt changes the stored width within 2 pt and the
|
||
registered `panel:*` rect within 1 pt; the photo area never falls below `MIN_PHOTO_WIDTH`;
|
||
- the `resizable_side` test asserts the drag edge is live through the corrected `pid` (and fails if
|
||
the panel is constructed with a pre-hashed `IdSalt`);
|
||
- grid/filmstrip/menu scroll offsets and hit rects agree after wheel, drag and resize; menu arrows
|
||
scroll and stop at bounds;
|
||
- dialogs are centered and fit; a long translated title/label does not overflow (`tests_dialog_fit`,
|
||
`tests_labels`, `tests_masking_layout`).
|
||
|
||
Pixel comparisons use 1600×1000 at 1× and 2×, deterministic `Session::with_demo()` and pinned fonts.
|
||
Keep reviewed baselines in task evidence, never `UPDATE_SNAPSHOTS=force`; compare with a predeclared
|
||
metric: region geometry exact ±0.5 pt; count of pixels differing by >2 LSB and mean absolute
|
||
difference per region; a change in the top bar, side panels, canvas or menu requires review.
|
||
Determinism alone is not acceptance.
|
||
|
||
### 6.3 Accessibility and keyboard
|
||
|
||
- The strict kittest checks are on in every harness from L2a: no unnamed input, no missing glyph.
|
||
Extend `tests/accessibility.rs` to the roles `is_input` uses (`DisclosureTriangle`, `Splitter`,
|
||
`Tab`, inputs) and to the second-window embedded tree. Sliders have value/range; dialogs have
|
||
titles; hidden controls disappear.
|
||
Custom disclosure rows use the actual 0.37 API: `WidgetInfo::labeled(Role::DisclosureTriangle,
|
||
enabled, label)` plus `ctx.accesskit_node_builder(resp.id, |node| node.set_expanded(open))`.
|
||
They must not use `WidgetInfo::selected`, which fills AccessKit `Toggled` rather than `Expanded`.
|
||
A unit test asserts `is_expanded()` is `true`/`false` for `widgets::section_header`; the same
|
||
helper is used by `widgets::flyout_row`, the panel fold headers and the sidebar section headers.
|
||
(An accessibility-tree run over the Edit/Keywords views remains part of the broader accessibility
|
||
suite, not this focused state test.)
|
||
- Tab order stays top-to-bottom in panels; screen-reader Increment/Decrement/SetValue and
|
||
Enter/Space/arrow actions trigger the same commands as the pointer.
|
||
- Focus/edit/drag state does not transfer when the language changes, a panel hides, rows are
|
||
inserted, or a menu opens. `Id::unique` preserves `Id::new`'s hash, but a wrong `IdSalt`/`scope_id`
|
||
in a container can still move state.
|
||
- Text field: select range → click-away action uses that range (#8763); Esc restores; Return/away
|
||
commit; IME preedit/commit/cancel one edit/undo; bidi caret/selection in logical and visual order;
|
||
emoji keyword/filename renders without tofu.
|
||
|
||
### 6.4 Mutation/negative controls
|
||
|
||
Bounded probes that fail before the fix, kept as unit-level negative tests where cheap:
|
||
|
||
- a capture slot completed for the wrong request: no file/reply; a late callback after cancel: no
|
||
reply/panic and released bytes;
|
||
- a deliberately duplicated command path under `request_discard`: the one-undo test fails when the
|
||
first-pass guard is removed;
|
||
- `resizable_side` with the old exact `Id` or a pre-hashed `IdSalt`: the drag test fails to see the
|
||
resize widget / does not store the width;
|
||
- one unnamed input in a scratch kittest harness: `check_accessibility` panics;
|
||
- one unsupported glyph: `MissingGlyphPolicy::Panic` panics and `Tofu` does not;
|
||
- a hidden `ui.clickWidget` while the widget registry is stale: the request resolves only after a
|
||
fresh layout (test fails if the old geometry is used).
|
||
|
||
## 7. Performance protocol
|
||
|
||
### 7.1 Baseline, candidate and control
|
||
|
||
Build the baseline `23e18128`, the **stock** 0.37 control and the fork candidate in separate managed
|
||
`task.py run --kind build` allocations with distinct source/evidence paths (§8). Use the same Rust
|
||
toolchain (`1.99.0` at inspection), `--release`, same features (`denoise` default; `heif` separately),
|
||
same `CRAFT_FONTS_DIR` revision (craft-fonts `2481a8a`), same corpus files, same profile (including
|
||
`debug = "line-tables-only"`), same renderer/driver, viewport and DPI. Record full commit, lock
|
||
SHA-256, resolved features, fonts, fixtures, OS, CPU, RAM, GPU adapter/driver, renderer backend, DPI
|
||
and whether the GPU is enabled. Alternate execution order; never benchmark concurrently with a build
|
||
or another noisy workload.
|
||
|
||
Fixtures: `Session::with_demo()` (deterministic), a 10,000-photo synthetic library from the
|
||
`grid_bench` generator, and the pinned 24 MP corpus raw (`cargo xtask corpus --download`; gitignored).
|
||
No corpus or `CRAFT_FONTS_DIR` was present at inspection, so these are explicit prerequisites.
|
||
|
||
**Comparisons.** Report baseline 0.36.2 → candidate, and stock 0.37 → candidate wherever an
|
||
equivalent workflow exists in stock 0.37, so a fork-induced regression is not confused with a
|
||
0.37-induced one. The stock control uses the same fixtures, profile, fonts and environment.
|
||
|
||
### 7.2 Operations and sample counts
|
||
|
||
- **Warmups**: at least **10** warmup iterations per steady operation, discarded.
|
||
- **Steady operations**: at least **30** timed iterations (slider drag, menus, control operations,
|
||
opening photos), 10 repetitions for cold/warm open.
|
||
- **Startup**: **20** fresh processes per side, to first UI frame and to thumbnails settled;
|
||
separate process start, renderer/probe construction, font setup and first frame.
|
||
- **Frame distributions**: at least **1,000 actual painted frames** before any p50/p95/p99 claim
|
||
(not synthetic logic ticks, not 20/30/50 samples), counted from `Context::cumulative_frame_nr()`
|
||
or the equivalent painted-frame counter. Report median/p95/p99 with the actual frame count.
|
||
- **Grid**: `grid_bench` 200 and 10,000 photos, 60 unchanged frames, both grid modes, 1× and 2×;
|
||
median/p95, per-frame UI allocations, cells visited, layout rebuilds.
|
||
- **Slider drag**: `drag_latency` on the 24 MP raw, 27 controls, `TICKS=30`, CPU and GPU separate;
|
||
median/p95 draft texture latency and full render after release. Existing budgets: ≤16 ms draft,
|
||
≤60 ms loupe; export ≤1 s/24 MP JPEG.
|
||
- **Thumbnails/menus**: `thumb_request_bench` with blocked rendering for allocation counts; a long
|
||
menu open with wheel/arrow scroll, 1,000 actual frames.
|
||
- **Opening photos**: `open_timeline` with pinned raws, warm and cold, 10 repetitions.
|
||
- **Control/UI operations**: `ui.screenshot` (headless), `ui.inspect`, grid scroll and panel drag,
|
||
measured request → reply over 30 samples.
|
||
- **Separate workloads, never one blended number**: (a) **#8731 text tessellation** — text-heavy
|
||
fixture, plain text vs mixed color glyphs; (b) **#8736 DX12** — fresh-process pipeline/probe
|
||
construction and first frame on Windows DX12; (c) **#8512 font startup** — font discovery/setup
|
||
time in a fresh process with and without the bundled faces.
|
||
- **Idle**: 60 s visible and 60 s minimized/hidden; process CPU time, `cumulative_frame_nr` (an
|
||
additive `perf.frames` only in the instrumentation stage), repaint count, GPU work and RSS. A new
|
||
busy loop or a repaint while hidden beyond requested work is a failure.
|
||
- **Web**: `cargo xtask web` **raw and compressed WASM size** (raw, gzip, brotli; separately record
|
||
the effect of `monochrome_emoji_fonts`), `?bench` first frame, thumbnails, eight slider steps and
|
||
release render, 10 fresh page loads per side, one browser/version and a software/ANGLE equivalent
|
||
if available.
|
||
|
||
Record peak RSS, allocation/texture-upload counters, cold vs warm caches and idle CPU separately.
|
||
|
||
### 7.3 Predeclared acceptance and noise policy
|
||
|
||
Shared suite thresholds (do not loosen them): investigate a repeatable **>5 % median** or
|
||
**>10 % p95** latency regression, **>5 % peak-RSS increase**, any new idle busy loop/repaint, any
|
||
missed slider/loupe/export budget, or a visual/document regression. App-specific gates may be
|
||
stricter. Apply a documented noise floor: for sub-millisecond values require the relative increase
|
||
to exceed 0.05 ms or corroborate with stable instruction/allocation counts. The **stock 0.37
|
||
equivalent control** is required in the first report and for every regression decision, so a
|
||
0.37-induced change is not attributed to the fork; stock 0.37 remains a control, never a production
|
||
stage. Reject noisy comparisons as inconclusive and rerun; **do not widen thresholds** after seeing a
|
||
failure. #8731/#8736/#8512 figures are upstream reports, not our measurements; compile duration is
|
||
not UI performance.
|
||
|
||
## 8. Platform evidence and provisioning gates
|
||
|
||
Every row is a **provisioned environment gate**, not a claim that the current host or a previous run
|
||
passes. A row without provisioned hardware/OS/browser is recorded as unresolved, never as a pass.
|
||
|
||
| Platform | Required workflow | Acceptance |
|
||
|---|---|---|
|
||
| Linux X11 | Managed native run of the exact candidate with `--control PORT`, control script, native screenshot, edited/saved/reopened library; both themes, 1×/2×; renderer recorded. Requires a real X11 session (none is inherited by this server shell) | Real window; CPU and available GPU reported separately |
|
||
| Linux Wayland | Same on a named compositor (wlroots/KWin/Mutter) provisioned for the task; clipboard/PRIMARY, drop, IME, hidden second window | E4 + W15P matrix from §4.9; any compositor limitation recorded if reproduced |
|
||
| Windows | Owned `craft-windows` clone (own disk, UEFI, TPM, ports, 128 GiB reservation), native build of the exact candidate, visible grid/detail/edit workflow, screenshots, save/reopen, FXC/DX12 probe, software-renderer baseline and real GPU if present | Start-up/first frame measured; no panic/black window; DX12→Vulkan→GL fallback |
|
||
| Web | `cargo xtask web --serve` in a provisioned browser environment; browser automation drives observable `command(...)` actions (thumbnail/detail/slider/save state) and captures the canvas with the **browser's** screenshot facility; console errors checked; `?bench` | Real browser workflow; wasm module and CJK/emoji assets present; not just `cargo check`. There is no control server and `ui.screenshot` is not a `LightcraftApp::run` command (`web.rs:609-644` → `self.app.run`), so a browser capture is the only screenshot path unless a separate reviewed download/writer API is added |
|
||
| FreeBSD | Repo workflow with craft-fonts, if a runner is provisioned | Compile/test evidence or explicit unresolved gate |
|
||
| macOS | Real macOS hardware (not cross-compilation) for menu/IME/Metal/GL/termination/AccessKit teardown | Separate unresolved blocker |
|
||
|
||
No claim is made that this server has no GPU: read-only inspection finds an i915 PCI `8086:3E96`
|
||
render device and readable/writable `/dev/dri/renderD128`. That is **availability evidence only**,
|
||
not a qualified Vulkan/OpenGL/compositor result. No display session is inherited, and Windows
|
||
baseline rendering remains software-rendered.
|
||
|
||
Headless rendering is not proof of native focus, IME, file picker, compositor clipboard, screen
|
||
reader or GPU presentation. A windowed `ui.screenshot` that logs the "rendering headlessly" fallback
|
||
does not satisfy native acceptance.
|
||
|
||
## 9. Reviewable PR stages, dependency DAG and rollback
|
||
|
||
```text
|
||
P0 (external; BLOCKED until published)
|
||
storytold egui immutable revision P8: E1-E21 + P2b (E4 regular/PRIMARY with bounded
|
||
async W15P transfer) + P7 held-key pairing + P7b/E21b + P7c (#8763/#8748 and
|
||
justified perf fixes), reviewed and published
|
||
storytold winit immutable revision: complete W15P + W27 lifecycle (+ W15/W25/W26 series)
|
||
craft-libs CL-1 revision, then CL-2 provisional capture slot
|
||
CL-2 -> DesignCraft/GridCraft adapters -> CL-2F API freeze
|
||
|
|
||
L1 baseline harness/instrumentation + baseline-aware xtask dependency check + evidence scripts
|
||
(0.36.2 expected-graph mode; works today; no product UI change, no new pins)
|
||
|
|
||
L2a coherent graph + compile migration (manifests, lock, patch, Id/Role/scope/ComboBox/Event
|
||
source, default_fonts + monochrome_emoji_fonts + native clipboard, CL-2F screenshot
|
||
callback/admission/drain/timeout/basic reply handling, scroll offset read/write, strict kittest naming/glyph fixes, all -D warnings)
|
||
-- atomic; no unbuildable intermediate commit on main. BLOCKED on P0.
|
||
|
|
||
L2b lifecycle/root-UI hardening (tick split, monotonic clock, dropped-file queue, panel IDs,
|
||
hidden gesture queue, CL-2F capture stress tests, both headless hosts, second-window/docking tests)
|
||
-- needs CL-2F (DesignCraft/GridCraft are the CL-2 pilots).
|
||
|
|
||
L2c fonts/accessibility/language/IME/clipboard hardening (set_language, #8763, E21b/W27 tests,
|
||
W15P/E4 PRIMARY matrix)
|
||
|
|
||
L2d measured visual/perf acceptance, docs updates, platform evidence and final review
|
||
```
|
||
|
||
P0 is **explicitly blocked**: the final approved storytold immutable SHAs are unknown until the fork
|
||
and winit series are published, and this plan does not invent them. `a4f9a17f`/`8b35388b` are used
|
||
only to inspect behavior. L2a–L2d cannot be prepared as merge-ready changes until P0 exists.
|
||
L1 can start now in 0.36.2 expected-graph mode; L2a switches the dependency check to the 0.37 P8
|
||
graph. L2a is one atomic dependency stage: the complete egui/winit P8 graph plus the required
|
||
craft-ui revision (CL-1 and CL-2F for the eleven `craft-ui` consumers; DesignCraft/GridCraft CL-2
|
||
pilot adapters precede CL-2F). Any incompatible existing craft-ui bump belongs in that same atomic
|
||
stage. Stock 0.37 is only the measurement control, not a production stage.
|
||
|
||
Expected file ownership: L1 adds `xtask/src/deps.rs` (plus dispatch), test/bench harness scripts
|
||
under `tools/egui037/` and non-product tests; L2a changes `Cargo.toml`, `Cargo.lock`, the three app
|
||
manifests, `crates/ui-egui/Cargo.toml` and the mechanical compile sites in
|
||
`crates/ui-egui/src/{lib.rs,widgets.rs,control.rs,headless.rs,menu_level.rs,panels/*}` plus
|
||
`apps/lightcraft/src/main.rs` where an API changed; L2b owns `lib.rs`, `panels/mod.rs`,
|
||
`panels/{faces,denoise}.rs`, `panels/second.rs`, `headless.rs`, `control.rs`,
|
||
`apps/lightcraft/src/main.rs`, `apps/lightcraft-web/src/web.rs` and lifecycle tests; L2c owns
|
||
`theme.rs`, `i18n.rs`, `text_field.rs`, `widgets.rs`, `tests/accessibility.rs` and the
|
||
IME/clipboard tests; L2d owns only reviewed visual/perf evidence and progress documentation.
|
||
|
||
L2b–L2d are separate dependent PRs; they may be implemented on separate branches after L2a but merge
|
||
one at a time, re-running `cargo xtask ci` after each merge. Do not land a dependency bump that
|
||
leaves `main` unbuildable, and do not combine unrelated cleanup with the migration.
|
||
|
||
Rollback is the whole previous coherent set, not an egui version string: revert the stage range and
|
||
restore the 0.36.2 manifests/lockfile plus craft-ui `5c874d51...` and registry winit `0.30.13`; keep
|
||
the old lockfile, patch block, package IDs and source SHA in evidence; keep fork/winit/craft-libs
|
||
revisions reachable. No persistence migration: `UiState`, catalog journal/snapshot, XMP, presets and
|
||
library paths are unchanged; only egui memory/panel layout may reset. No release, tag, branch
|
||
deletion or cache cleanup until the migration is abandoned or complete and evidence is preserved.
|
||
|
||
## 10. Blocking unknowns and acceptance criteria
|
||
|
||
1. **Published pins (blocking).** The final approved storytold egui **P8** (including P2b, P7,
|
||
P7b/E21b and P7c) and the completed winit (W15P + W27) immutable revisions do not exist until the
|
||
fork/winit series is published and reviewed; no SHA is assumed here. Stages L2a–L2d are blocked
|
||
on them. `a4f9a17f`/`8b35388b` are comparison references only; no stock or intermediate stage is
|
||
a production dependency.
|
||
2. **E21b/P7b (blocking).** The complete shared contract — owner identity even when unavailable,
|
||
available-empty distinct and explicitly sent, committed disable/enable clear, latest-pass
|
||
authoritative `None`, current-epoch serial mismatch applying incoming edits once while outgoing
|
||
waits, stale-epoch invalidation, bounded ≤4000-byte payload with no full-document clone — plus the
|
||
`TextEdit` producer and W27 adapter is not in the inspected candidate. LightCraft's built-in
|
||
`TextEdit` consumers inherit it, and the family's custom producers (e.g. FilmCraft
|
||
`panels/graphics.rs`) need their own logical-model adapters before family acceptance.
|
||
3. **W15P + E4 + P2b (blocking for native PRIMARY).** The inspected E4 has an arboard fallback, not
|
||
the required winit-owned per-seat PRIMARY path. Require the complete published P8/W15P revision
|
||
with bounded asynchronous transfers (2 s total deadline, 16 MiB, four readers/two writers/queue
|
||
8, owner-specific waker, stale-result cancellation), pointer- or keyboard-triggered fresh serials
|
||
and the §4.9 native matrix.
|
||
4. **craft-libs CL-1/CL-2F.** The capture slot exists only as a provisional CL-2 API until
|
||
published; LightCraft requires the frozen CL-2F API, which only exists after the
|
||
DesignCraft/GridCraft pilot adapters. The port sits on the reconciled
|
||
`3832c47ef68a0e6214f2c1e85f3526b6adcb686e` base; PR 199's head and the audit merge are stale
|
||
trees and `8614e4a`/`1764bca` are already contained in that base.
|
||
5. **Hidden viewport children.** #8748's overdue-UI behavior is not in the inspected fork; P7c is
|
||
required to carry the selected fix. The second window while the root is hidden/minimized still
|
||
needs native Wayland/Windows evidence.
|
||
6. **DX12 startup (#8736)** and **text tessellation (#8731)** are open upstream reports; measure on
|
||
our fixtures as separate workloads and require a semantics-preserving fix if thresholds are
|
||
breached. **#8512** is a separate font-startup workload, not a reason to enable system fonts.
|
||
7. **Strict kittest findings.** The number and identity of unnamed inputs/missing glyphs under 0.37
|
||
is unknown until the first L2a run; they are real fixes, not a reason to disable the checks.
|
||
8. **Fonts.** Whether the bundled faces cover every shipped locale and script fixture without system
|
||
fonts is a measured gate; CJK tests skip without `CRAFT_FONTS_DIR`, which is not release
|
||
acceptance. Monochrome emoji add ~1 MB; measure native and WASM size.
|
||
9. **PRIMARY/clipboard on GNOME/Mutter** needs a real compositor; a control-channel paste is not a
|
||
substitute.
|
||
10. **macOS**: no real hardware evidence for menu, IME, Metal/GL, termination or AccessKit teardown;
|
||
an explicit rollout blocker, not a pass.
|
||
11. **Web workflow**: no control server and no `ui.screenshot` command; browser capture plus
|
||
observable `command` state is the acceptance path unless a separate download/writer API is
|
||
separately reviewed and approved.
|
||
12. **Visual deltas**: 0.37 style/scrollbar/menu changes may alter reviewed screenshots; no bulk
|
||
golden refresh.
|
||
13. **Lock/feature drift**: `accesskit`/`kittest` movement, `wasm-bindgen` CLI matching, Nix git
|
||
fetching and a second `egui`/`winit` source must pass the dependency check.
|
||
14. **Performance environment**: no display session, corpus or fonts are guaranteed on this host;
|
||
Linux/Windows/browser results need the managed resources in §7/§8. The i915 render node is
|
||
availability evidence only.
|
||
|
||
Acceptance requires a fresh independent adversarial review of this plan, then of each implementation
|
||
at its exact revision, with resolved findings and hashes recorded. It also requires all repository
|
||
gates, selected features, native Windows/Linux and browser workflows, the measured before/after
|
||
report, accessibility and visual review, and explicit human publication approval. No unresolved
|
||
correctness finding is downgraded to "later".
|
||
|
||
## 11. Research references (primary sources; proposals distinguished from merged behavior)
|
||
|
||
- egui 0.37 changelog and release commit
|
||
[`841a328b`](https://github.com/emilk/egui/blob/841a328ba2dd8f6996beb7189ebb7fc00e668a2a/CHANGELOG.md);
|
||
the inspected suite branch is `a4f9a17f64a6bae61350fb85804c9df0d9865916`, not `main`.
|
||
- Lifecycle: [`epi_integration.rs`](https://github.com/emilk/egui/blob/841a328ba2dd8f6996beb7189ebb7fc00e668a2a/crates/eframe/src/native/epi_integration.rs),
|
||
[`app_runner.rs`](https://github.com/emilk/egui/blob/841a328ba2dd8f6996beb7189ebb7fc00e668a2a/crates/eframe/src/web/app_runner.rs),
|
||
[`context.rs`](https://github.com/emilk/egui/blob/841a328ba2dd8f6996beb7189ebb7fc00e668a2a/crates/egui/src/context.rs).
|
||
- Callback screenshots: PR #8489, `crates/egui/src/data/screenshot_callback.rs` (merged).
|
||
- Roles: PR #8555; `egui/src/lib.rs` role table (merged).
|
||
- IDs/scope: PRs #8534, #8586, #8590, #8542; `id.rs`, `id_salt.rs`, `ui.rs`, `ui_builder.rs` (merged).
|
||
- Panel salts: PR #8592; `containers/panel.rs` (merged).
|
||
- Root UI/run pass: PR #8535; `context.rs` (`root_ui` panic documented) (merged).
|
||
- Default fonts / emoji features: PR #8583; `egui/Cargo.toml`, `epaint/src/text/font_definitions.rs`.
|
||
- Strict accessibility/missing glyphs: PRs #8589, #8611, #8601; `egui_kittest` source.
|
||
- PRIMARY selection: PR #8428 (merged); Wayland clipboard #8657 (open draft) and winit #4729 (open),
|
||
paired with E4/W15P.
|
||
- Bidi/RTL: #8577 (open) and the fork's E5/E12/E13/E16/E17/E20/E21 series.
|
||
- TextEdit click-away selection: issue #8763, PR #8764 (open/unmerged at inspection).
|
||
- Text tessellation: issue #8731 (open); DX12 FXC pipeline: issue #8736 (open); system-font startup:
|
||
#8512 discussion.
|
||
- Hidden/overdue viewport children: issue #8748 (closed upstream; absent from the inspected fork).
|
||
- IME surrounding text gap: `ime-surrounding-gap.md` and the egui plan §E21b/P7b, winit W27.
|
||
|
||
## 12. Evidence to preserve
|
||
|
||
Retain: the baseline/candidate/control commit IDs, lock SHA-256 and package/source tables,
|
||
`cargo tree -d`/`-i` and `cargo tree -e features` output, `cargo xtask ci` reports and test
|
||
counts/skips, dependency-check negative fixtures, accessibility tree dumps, reviewed
|
||
baseline/candidate screenshots and geometry JSON, performance raw samples and summaries (with
|
||
environment and separate #8731/#8736/#8512 workloads), native/browser/Windows logs and screenshots,
|
||
the exact published egui/winit/craft-libs revisions and their tests, and this plan's reviewed hash.
|
||
Do not preserve build targets as evidence; they are rebuildable and are cleaned only after all checks
|
||
and deliverables are saved.
|
||
|
||
## 13. Review disposition record
|
||
|
||
### 13.1 R1 review (`lightcraft-v1.md`)
|
||
|
||
| # | Finding | Disposition | Resolved in |
|
||
|---|---|---|---|
|
||
| 1 | `Panel::left(salt)` double-hashes; `pid` mismatch | **Accepted.** `Panel::new` stores `IdSalt::new(id_salt)` and `Panel::id` = `parent_ui.scope_id().with_salt(store)`; `IdSalt::new(IdSalt)` is a distinct salt (`id_salt.rs` nested-salt test). Fixed to pass the original source and derive `pid` with `with_salt(IdSalt::new(id))`; no public panel-state-id helper. Impact narrowed: the helper has no production caller at inspection, so the defect is latent today. | §3, §4.7 |
|
||
| 2 | eframe `clipboard` feature missing | **Accepted.** Add `clipboard` to the native app and assert it through resolved features; headless clipboard is not native acceptance. | §2.1, §4.9, §5.3 |
|
||
| 3 | `TickPhase` has no first-frame/`None`/forced-hidden branch; stale-index premise false | **Accepted, with two sub-claims rejected.** The stale-index claim and the `has_visible_pass` contradiction are correct. The specific example "`RawInput::default()` has time 0" is wrong: `InputState::begin_pass` uses `new.time.unwrap_or(prev + predicted_dt)`, so the input time is `predicted_dt`; the real zero-time first frame is `HeadlessView::raw_input(..., time = 0.0)`. The classifier is removed entirely rather than patched, so both cases are covered. | §4.1, §4.2, §6.1 |
|
||
| 4 | Hidden geometry-dependent controls use stale geometry; one paint request cannot carry a gesture | **Accepted.** Geometry resolves only after a fresh `layout_epoch`; each gesture step requests a hidden paint; exactly one reply, after delivery. | §4.5, §6.1 |
|
||
| 5 | `default_fonts` loses 0.36 emoji faces | **Accepted.** Enable `monochrome_emoji_fonts`; tests cover `🙂`/`👍` and the emoji keyword fixture; ~1 MB size is measured. | §2.1, §4.8, §7.2 |
|
||
| 6 | E4 does not provide the winit-owned PRIMARY path | **Accepted.** W15P is a prerequisite with the full native matrix; no limitation is recorded as "solved". | §1, §4.9, §10 |
|
||
| 7 | E21b/W27 outgoing IME surrounding text missing | **Accepted.** Added as a blocking prerequisite with the built-in-`TextEdit` inheritance tests and the family custom-producer requirement (FilmCraft graphics etc.). | §4.9, §10 |
|
||
| 8 | Web cannot invoke `ui.screenshot` | **Accepted.** Web `command()` only reaches `LightcraftApp::run`; browser capture plus observable commands is the workflow; no new app API unless separately reviewed. | §4.11, §8, §10 |
|
||
| 9 | A `Cargo.lock` check cannot assert features | **Accepted.** Identity from lock/metadata; features from `cargo metadata` + `cargo tree -e features`; feature-less fixture targets that mechanism. | §5.3 |
|
||
| 10 | `DockArea::direction` unspecified | **Accepted.** Explicit LTR policy at both call sites with unchanged persisted geometry/tab order. | §4.7, §6.1 |
|
||
| 11 | Screenshot design does not adopt the shared completion slot | **Accepted.** LightCraft requires the frozen CL-2F helper; request paths/deadlines/replies stay app-owned. | §4.6, §9 |
|
||
| 12 | Hidden-time accounting misses toasts/status and faces/denoise input | **Accepted.** One monotonic clock for toasts/status/timers; faces/denoise split into background drain vs UI-phase input. | §4.3, §4.4 |
|
||
| 13 | Duplicate-work claim overbroad; web inbox table contradiction | **Accepted with correction.** A second pass has empty events (`InputState::begin_pass`), so event handlers cannot duplicate; the real duplicates are time-gated ticks and the double `faces::pump`. Web inbox runs every `logic` call, dropped files only in the UI phase. | §4.2, §4.4, §4.11 |
|
||
| 14 | `Headless::step` still has the removed `Event::Screenshot` arm | **Accepted.** Both `HeadlessView::run` and `Headless::step` are updated; exactly one completion. | §4.6 |
|
||
| 15 | Count/factual issues (`221` id calls; web inbox table) | **Accepted.** 221 `egui::Id::new` occurrences (218 lines) plus three bare `Id::new`, and 8 `ui.id()` calls verified in this checkout; the table/text disagreement is removed. | §3, §4.4 |
|
||
|
||
### 13.2 R2 review (`lightcraft-v2.md`)
|
||
|
||
| # | Finding | Disposition | Resolved in |
|
||
|---|---|---|---|
|
||
| 1 | Hidden timers placed in `ui_pass` where hidden `run_logic` cannot reach them | **Accepted.** `import`/`lightroom_import`/`recheck`/slideshow/auto-import/sidecar/device/toast/autosave ticks were moved back to `logic`, with monotonic deadlines so a second pass is a no-op; only UI input/drop/perf work stays behind `current_pass_index() == 0`. | §4.2, §4.3, §4.4, §6.1 |
|
||
| 2 | E21b/W27 omission of owner/serial/epoch rules | **Accepted.** Restated the complete P7b/W27 contract: owner identity while unavailable, available-empty sent explicitly, committed disable/enable clear, latest-pass authoritative `None`, current-epoch mismatch applies incoming once and defers outgoing, stale epochs invalidate, bounded payload without a new full-document clone. | §4.9, §6.3, §10 |
|
||
| 3 | W15P required without bounded async transfer | **Accepted.** P2b is now part of the P8 prerequisite: 2 s total deadline, 16 MiB cap, four readers/two writers/queue 8, owner waker and stale cancellation, with slow/canceled-transfer and responsiveness tests. | §1, §4.9, §6.3, §10 |
|
||
| 4 | PRIMARY matrix is keyboard-only | **Accepted.** Fresh pointer-press or keyboard-focus serials are both valid; keyboard-only is explicitly rejected, and middle-click paste is tested. | §4.9, §10 |
|
||
| 5 | Hidden synthetic input can accumulate in `pending_raw_input` | **Accepted.** Hook may run in logic-only, so it injects at most one step and marks it in flight until a completed UI pass consumes it; no second injection from repeated hidden ticks and no press/release collapse. | §4.5, §6.1 |
|
||
| 6 | L1 cannot both run on 0.36.2 and enforce the 0.37 check | **Accepted.** The dependency check is baseline-aware; L1 runs in 0.36.2 expected-graph mode, and L2a flips it to the P8 graph atomically. | §5.3, §9 |
|
||
| 7 | Counts/detail inaccuracies | **Accepted, normalized.** This checkout has 218 `egui::Id::new` lines plus three bare `Id::new` calls (221 lines, 224 occurrences; the review's line/occurrence wording was slightly mixed). The font key is `Ubuntu-Light`; the `logic` doc comment now matches per-pass host calls; the weak-slot reason is corrected (the renderer owns the callback, so this is not a `Context` cycle). | §2.1, §3, §4.2, §4.6, §4.8 |
|
||
|
||
|
||
## Local implementation update — 2026-10-23
|
||
|
||
The user clarified that reviewed plans remain local and implementation proceeds before publication. The earlier requirement to publish/land this plan first is superseded; pushes and PR publication still require explicit approval.
|
||
|
||
Canonical `storytold/lightcraft` `main` now resolves to `d5e639b3d1ad079c39a924c1b65d0a96ee2134ac`. The owned checkout was fast-forwarded to this revision. Earlier baseline SHAs and line anchors above are retained as historical review evidence; use this new revision and its exact lockfile for current-main integration and paired measurements. The original refs remain reachable.
|
||
|
||
No migration API redesign required by this delta; preserve the merged packaging, test and chrono changes where present.
|
||
|
||
The complete static delta review and exact old/new object identities are retained in the task evidence: `canonical-diffs-refresh-20261023.json` and `overnight-consumer-review-20261023.md`. This amendment claims no new build, native GUI or performance acceptance.
|
||
|
||
## Local implementation — stage L2a/L2b source migration (2026-10-23)
|
||
|
||
Status: **source changes only, not validated here.** No build, test, `cargo metadata`, fmt,
|
||
clippy, GUI, browser, Windows or performance run was made in this worktree. The parent runs the
|
||
managed jobs listed at the end. This section records exactly what changed and what is still
|
||
open; it is not acceptance evidence.
|
||
|
||
### Dependency and feature stage (L2a)
|
||
|
||
- Workspace `egui` is now `0.37` with `default-features = false` and
|
||
`features = ["default_fonts", "monochrome_emoji_fonts"]`; `eframe` and `egui_kittest` are `0.37`
|
||
(`egui_extras` is declared at `0.37` although LightCraft still does not use it).
|
||
- Native `eframe` adds the separate 0.37 `clipboard` feature; web keeps `default_fonts` + `glow`.
|
||
- The workspace feature choice is what gives the CLI/headless and kittest roots their faces:
|
||
`egui_kittest`'s own dev-dependencies do not propagate. `tests_lifecycle_037` asserts the
|
||
`Hack`, `Ubuntu-Light`, `egui-icons`, `NotoEmoji-Regular` and `emoji-icon-font` faces and that
|
||
U+1F642/U+1F44D are paintable.
|
||
- No final storytold SHA, `[patch]` entry or lockfile was invented. The parent supplies the local
|
||
path patch from `../PLANNING-CONTRACT.md` / `local-integration-config.toml` and generates the
|
||
lockfile. `crates/raw/src/vendor/` is untouched.
|
||
|
||
### API migration
|
||
|
||
- `egui::Id::new` → `egui::Id::unique` (same hash, so egui state does not reset) and `Ui::id()` →
|
||
`Ui::scope_id()` across the UI crate and tests.
|
||
- `WidgetType` → `egui::Role` with the documented variant mapping (`CollapsingHeader` →
|
||
`DisclosureTriangle`, `SelectableLabel` → `Button`, `ComboBox` stays `ComboBox`).
|
||
- `Panel` salts: `resizable_side` now passes the original salt to `Panel::left/right`, derives the
|
||
persisted key with `ui.scope_id().with(id)`, and checks the resize drag on that key (the old code
|
||
passed a prebuilt `Id` and would be salted twice). 0.36 exact-`Id` `PanelState` entries for
|
||
`top_bar`, `bottom_bar`, `tool_strip` and `library-problem-banner` are removed once at startup;
|
||
this resets only egui panel memory, never `UiState`, docking JSON, catalog or XMP data.
|
||
- `ScrollArea::State.offset` is private: menu arrows store a one-frame `vertical_scroll_offset`
|
||
target, and the right panel reads `clamped_offset()`.
|
||
- `Event::MouseWheel` initializers carry `source`; `ui.scroll` accepts optional
|
||
`"source"`, `"unit"` and `"phase"` with the validated values and defaults from §4.9.
|
||
- `ComboBox` has the documented lifetime; atoms take `IdSalt` (`Atom::custom`,
|
||
`WidgetAtomResponse::rect`).
|
||
- `ViewportCommand::Screenshot` now uses the one-shot callback and `Event::Screenshot`/`UserData`
|
||
are gone. Both headless entry points answer the callback with their painted frame exactly once.
|
||
- The fork's `Context::set_language` is fed from `i18n::language()` (the AccessKit root language
|
||
and bidi runs follow the UI locale; command ids and user data stay logical).
|
||
|
||
### Lifecycle and capture stage (L2b)
|
||
|
||
- `LightcraftApp` now has a monotonic `AppClock` (`web_time::Instant`, never `i.time` or
|
||
`Date.now` for timing; `now_ms` remains wall-clock only for `library-restored-<ms>` names).
|
||
Headless hosts feed it their simulated time; the first `Headless::step` runs at 0.0.
|
||
- `logic` keeps queues, worker polls and time-gated work. Import committing is gated on
|
||
`current_pass_index() == 0` (once per host frame, and once per hidden logic tick), not on the
|
||
clock; all other hidden-relevant timers (Lightroom import, slideshow, auto-import, sidecar retry, screen profile, denoise/faces/focus/model,
|
||
autosave/prefs, toasts, captures) use the app clock.
|
||
- `ui` is split into `ui_pass` (first pass only: fresh input, dropped files, external-editor focus,
|
||
shortcuts, perf) and `draw_window` (presentation), then `finish_ui_pass` (completed, nondiscarded
|
||
pass; clears the synthetic in-flight gate). A hidden `run_logic` never reaches it.
|
||
- Dropped files are taken once in `raw_input_hook` into an app queue and consumed only in the UI
|
||
phase; web consumes the same queue in `WebApp::ui` (it needs `bytes_async`). Native and web tests
|
||
cover that a hidden logic tick leaves the queue intact.
|
||
- `raw_input_hook` injects at most one synthetic batch, refuses to inject before fonts are ready,
|
||
marks the batch in flight until a completed pass consumes it, and keeps a hidden window painting
|
||
with `RequestPaintWhileHidden` while a batch, settlement or queued capture is pending.
|
||
- Screenshot capture adopts `craft_ui::capture::CaptureSlot` (CL-2 local tree): four outstanding
|
||
requests, 128 MiB per image, 256 MiB aggregate including completed-not-drained images, admission
|
||
before issue, fresh native viewport geometry, a three-completed-frame/120 ms settle, a forced
|
||
capture at 3 s even while renders are in flight, a 2 s compositor fallback and an 8.12 s
|
||
admission deadline (settle + forced capture + fallback + headless settle). The strong slot stays in the app request
|
||
record; the callback only holds a weak slot and never writes files or touches app state. A
|
||
completion from an older geometry epoch or library generation is retried, never written; an
|
||
image larger than its reservation is rejected; a cancel/timeout/late callback releases its bytes
|
||
with a checked subtraction and replies once.
|
||
- Web `Bench::step`, `WebApp` notices/save and the native `PrefsWriter` use the monotonic clock.
|
||
The native menu and window-watch no longer record a hidden pass's stale content rect.
|
||
|
||
### IME inventory and next step
|
||
|
||
LightCraft has no custom `egui::IMEOutput` producer: `TextField` wraps the built-in `TextEdit` and
|
||
every other editor uses `TextEdit`, so it will inherit the P7b/E21b logical-model/preedit/UTF-8
|
||
producer once the reviewed egui revision is pinned. No `None` placeholder was added. The next step
|
||
is to re-run the P7b outgoing-surrounding-text cases (deletion-only and delete+commit, preedit
|
||
excluded, reversed selection, supplementary/RTL text, over-limit selection, available-empty vs
|
||
unavailable, window/owner switch, password clear, stale serial) against that pinned revision. The
|
||
incoming byte-deletion path is the built-in `TextEdit` path and is not reimplemented here;
|
||
`tests_lifecycle_037` asserts that `TextField` applies `ImeEvent::DeleteSurroundingBytes`
|
||
exactly (the pinned fork's winit byte-range event).
|
||
|
||
### Tests added (source only)
|
||
|
||
`crates/ui-egui/src/tests/lifecycle_037.rs`:
|
||
|
||
- bundled monochrome emoji + explicit font-feature gate;
|
||
- parent-salted panel state (and no legacy exact-`Id` state);
|
||
- capture admission count/per-image/aggregate bounds before issue;
|
||
- stale-geometry completion rejected before any file write, reservation retained for retry;
|
||
- one synthetic batch until a completed pass, and dropped files waiting for the UI phase;
|
||
- first headless frame at simulated time 0 and the app clock following the host.
|
||
|
||
### Unresolved prerequisites and validation
|
||
|
||
- Final published storytold egui P8 (E1–E21 + P2b + P7 + P7b/E21b + P7c) and winit W15P/W27
|
||
revisions, and the reviewed craft-libs CL-1/CL-2F revisions, do not exist as pins here.
|
||
- This source stage calls `craft_ui::capture::CaptureSlot` (the provisional CL-2 module in the
|
||
local craft-libs tree). The parent must provide a craft-ui revision that actually contains that
|
||
module; if CL-2F renames or changes it, the thin app adapter in `crates/ui-egui/src/lib.rs` must
|
||
be updated rather than copied into a second helper.
|
||
- Native PRIMARY (W15P/E4), E21b/W27 outgoing IME, #8748 deferred viewport children and #8763 are
|
||
inherited from those pins; none is claimed.
|
||
- The explicit LTR `DockArea::direction(DockDirection::LeftToRight)` policy is not added here:
|
||
the reviewed craft-ui revision in the local tree has no `direction` builder yet. Add it in the
|
||
same dependency stage that freezes CL-1/CL-2F; persisted splits, axes, ratios, floating
|
||
rectangles and logical tab order must stay unchanged.
|
||
- `accesskit` 0.24 → 0.25 and `kittest` 0.4 → 0.5 churn is not pinned here; check that the
|
||
Linux `zbus = "=5.19.0"` pin still resolves to one zbus source, and that `wasm-bindgen` in the
|
||
generated lock matches the CLI.
|
||
- `RequestPaintWhileHidden` has no web support in the shared fork: the browser path is unqualified
|
||
and remains a pending fork fix. Headless rendering is not native focus/IME/picker evidence.
|
||
- Strict kittest naming/missing-glyph checks may surface unnamed inputs or glyph gaps; those are
|
||
real fixes, not reasons to disable the checks.
|
||
- Visual/style deltas from roomier 0.37 defaults, Windows software rendering, macOS and native
|
||
compositor/PRIMARY evidence are still separate gates.
|
||
- Control gestures (`ui.pointer`, `ui.clickWidget`, `ui.dragWidget`, and the other input methods)
|
||
now resolve only after a completed fresh layout and reply only after the final nondiscarded pass
|
||
consumed their last event; the 5 s deadline is the fail-closed fallback. Limits are recorded in
|
||
§4.5: cancellation covers the admission-time view/document/dialog/panel snapshot, not a
|
||
per-widget semantic identity across an arbitrary catalog refresh in the same frame, and a
|
||
timeout clears queued synthetic input rather than removing one gesture's events individually.
|
||
|
||
### Baseline backports and performance operations
|
||
|
||
- Backports this consumer depends on but does not own: egui P7c (#8763 click-away selection,
|
||
#8748 overdue viewport children, the semantics-preserving text-tessellation fix) and E21b/P7b;
|
||
winit W27 outgoing IME lifecycle and W15P bounded PRIMARY; craft-libs PR 199's API-port hunks
|
||
(`Role`/`scope_id`/`IdSalt`) and the CL-2F capture helper on current main, never the stale PR
|
||
tree.
|
||
- Performance operations to measure after the pins exist, each as its own workload: menu/panel
|
||
text tessellation (#8731), DX12/FXC first-pipeline startup (#8736), system-font startup only if
|
||
the font policy is revisited (#8512), capture settle/issue overhead and hidden idle repaint/CPU.
|
||
Record raw samples and the environment; do not treat compile time as UI performance or refresh
|
||
visual goldens in bulk.
|
||
|
||
### Parent-managed validation commands
|
||
|
||
Use one new resource (for example `egui037-lightcraft-candidate-20261023`) and a fresh job id per
|
||
command. `--locked` only after the parent generates the lockfile.
|
||
|
||
```sh
|
||
python3 /home/codex/work/maintenance/task.py run \
|
||
--job-id egui037-lightcraft-20261023-check-01 --kind build \
|
||
--task egui037-lightcraft-candidate-20261023 \
|
||
--source "$PWD" --evidence /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/lightcraft-candidate \
|
||
-- cargo check --workspace --all-targets
|
||
python3 /home/codex/work/maintenance/task.py run \
|
||
--job-id egui037-lightcraft-20261023-lifecycle-01 --kind build \
|
||
--task egui037-lightcraft-candidate-20261023 \
|
||
--source "$PWD" --evidence /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/lightcraft-candidate \
|
||
-- cargo test -p lightcraft-ui-egui --lib tests_lifecycle_037
|
||
python3 /home/codex/work/maintenance/task.py run \
|
||
--job-id egui037-lightcraft-20261023-ci-01 --kind build \
|
||
--task egui037-lightcraft-candidate-20261023 \
|
||
--source "$PWD" --evidence /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/lightcraft-candidate \
|
||
-- cargo xtask ci
|
||
```
|
||
|
||
Then inspect every `status`, record `cargo tree -e features` for the native, CLI/headless, kittest
|
||
and wasm roots, run the accessibility/docking integration tests and the `cargo xtask web` browser
|
||
workflow, and only afterwards consider the measured before/after workloads (menu text tessellation,
|
||
DX12/FXC startup, capture settle cost, hidden idle repaint). Do not refresh visual goldens in bulk.
|
||
|
||
## Post-port review repairs (2026-10-23)
|
||
|
||
Source-only repair of `egui037-lightcraft-postport-review-20261023-root-v1.md`. No build, test,
|
||
fmt, clippy, `cargo metadata`, GUI, browser, Windows or performance run was made here; the parent
|
||
submits the managed jobs.
|
||
|
||
- **P1.1**: the four bare `Context::run_ui` statements in `tests_lifecycle_037.rs` bind their
|
||
`FullOutput` (`let _ = …`) so `-D warnings` no longer sees `unused_must_use`.
|
||
- **P2.1**: a settled capture is issued at a forced instant (`SHOT_FORCE_BUSY_MS`, three seconds
|
||
from admission) even when `renderer.in_flight()` or the merge is busy; the hard deadline (five
|
||
seconds) stays the only timeout path. `shot_ready` is a pure helper with a busy-renderer test.
|
||
- **P2.2**: capture identity is the library generation only. `catalog.revision` (bumped by every
|
||
applied edit/import/keyword op) no longer invalidates an in-flight image; the test applies one
|
||
catalog op between issue and completion and still writes the image.
|
||
- **P2.3**: a surface that grows after admission is checked as an *image* against the 128 MiB
|
||
per-image cap and only its growth against the 256 MiB aggregate. The old code compared the delta
|
||
to the per-image cap and named the delta as the image.
|
||
- **P2.4**: a completion larger than its reservation but within the per-image cap is treated as a
|
||
stale reservation (Wayland has no `inner_rect`), re-reserved and written, not rejected.
|
||
- **P2.5**: a slideshow auto-pauses while `ViewportInfo::visible() == Some(false)`, requests no
|
||
playback repaint, and resumes on unhide with the slide's remaining time preserved. This replaces
|
||
the 0.36 behavior where `i.time` froze while hidden.
|
||
- **P2.6**: the “0.37 has no `visible()`” rationale was wrong. `lib.rs` and
|
||
`apps/lightcraft/src/main.rs` now use `ViewportInfo::visible()`; §4.1/§4.2 above are corrected.
|
||
- **P3.1**: `import::tick` is gated by `current_pass_index() == 0` (once per host frame, and once
|
||
per hidden logic tick), not by a wall-clock throttle. The wall-clock comment was false.
|
||
- **P3.2**: the app retains the host's last `RawInput::max_texture_side` (default 16 384) and reports
|
||
“no usable surface” and “over the host's per-side texture limit” as distinct admission errors.
|
||
- **P3.3**: screenshot encoding no longer makes the extra flat RGBA `Vec` copy; `Rgba8::from_pixels`
|
||
fills the image's own buffer once. `MAX_SHOT_WORKING_SET_BYTES` documents the bound: reserved
|
||
images (≤ 256 MiB) + one `Rgba8` copy + encoded PNG (each ≤ 128 MiB) + 64 KiB zlib/framing slack.
|
||
Encoding stays on the UI thread, one image at a time. The MCP layer's decode/base64 transient is
|
||
outside the app process path and remains bounded by its own 1600 px default.
|
||
- **P3.4**: `apps/lightcraft-web/src/bench.rs` now measures `first_frame_ms` and `thumbs_done_ms`
|
||
from `WebApp` construction (`monotonic_ms()` origin), not navigation start. Old
|
||
`lightcraft-bench` samples are therefore not comparable; record the origin with every sample.
|
||
`web.rs`'s “first UI frame at perf_now()” log is a different origin in the same file and is
|
||
diagnostic only.
|
||
- **P3.5**: `Headless::step` no longer carries a second `ViewportCommand::Screenshot` completion
|
||
arm; `HeadlessView::run` is the only headless completion point.
|
||
- **P4**: the AccessKit language is cached instead of allocating through `Context::language()` on
|
||
every logic pass; the wasm drop branch names `WebApp` as the queue consumer; the legacy panel
|
||
eviction comment now says the old keys are unreachable anyway and only keeps egui memory tidy,
|
||
with a test that the four legacy keys are removed and the parent-salted keys survive.
|
||
|
||
Still open from the review: P3.3's MCP decode/base64 transient (documented, not reserved), the
|
||
web `RequestPaintWhileHidden` gap, #8748/#8763/E21b/W15P/W27, strict kittest findings, and all
|
||
runtime/visual/performance acceptance. The tests above are deterministic source oracles, not
|
||
runtime evidence.
|
||
|
||
## Review-test repairs — 2026-10-24 (source only)
|
||
|
||
The managed review-test job `egui037-lightcraft-candidate-20261024-review-tests-13` reported
|
||
1155 library passes, 106 failures, 4 ignored, plus 7 of 8 integration tests and the shutdown test
|
||
passing. Grouping the failures gave two root causes, repaired here without relaxing a check:
|
||
|
||
- **Gesture delivery re-checked its own effect.** `reply_delivered_gestures` compared the
|
||
admission-time view/document/dialog/panel snapshot again after the batch had been consumed. Every
|
||
click or key whose purpose is to change that state (opening a dialog, leaving the grid, closing a
|
||
dialog, selecting another photo) was answered with “changed before delivery” although the input
|
||
had done its job. Delivery now requires only that a completed, nondiscarded pass consumed the
|
||
batch. Freshness stays fail-closed twice: at resolution (a later completed layout must match the
|
||
admission snapshot) and in `drop_gestures_stale_before_injection` immediately before a waiting
|
||
gesture's first batch is injected, so a hidden logic tick cannot retarget it; later batches of
|
||
the same gesture may see its own partial effect, and a concurrently queued gesture is checked
|
||
when its events reach the head. A changed snapshot before the fresh layout still cancels, and a
|
||
stale pre-injection batch is cleared and answered with an error.
|
||
- **Unlabelled dropdowns.** egui 0.37's `ComboBox::from_id_salt` publishes the selected text as the
|
||
node's value but no accessible name, and kittest 0.37's strict accessibility check rejects it.
|
||
`widgets::name_combo` now gives every dropdown a semantic label (copyright status, keyword set,
|
||
proof profile, denoise limit, smart-album match/field/operator/value, export/contact-sheet/import/
|
||
settings/credits/print dropdowns), so no strict check was disabled.
|
||
|
||
One lifecycle test assumed the app opened in the photo grid while `UiState` defaults to Detail, so
|
||
its “view changed” setup was a no-op; it now changes to PhotoGrid explicitly. New regression tests
|
||
cover a delivered gesture that opens a dialog (stays `ok`) and a view change between resolution and
|
||
injection (answers once with `canceled` and injects nothing).
|
||
|
||
No build, test, fmt, clippy, GUI, browser, Windows or performance run was made for this amendment;
|
||
the parent runs the managed jobs. The 106 failures and the ComboBox finding are the only failures
|
||
inspected in that output; any further finding is reported separately rather than folded in here.
|